DEV Community

Cover image for Four-eyes Validation in AEM
Theo Pendle
Theo Pendle

Posted on

Four-eyes Validation in AEM

Prevent initiators from approving their own workflows

πŸ’‘ As always, scroll to bottom of the article to find all the source code on Github!

Pre-requisites

In order to get the most out of this article, make sure you are at least familiar with the following concepts:

Use cases

It is a common business process to include an approval of some kind before any corporate content is published, committed, filed or broadcasted. A four-eyes validation adds a certain level of confidence as it means that at least two individuals have seen a piece of content (the creator and an approver) before it goes live.

Web content management is no exception, and AEM supports the ability to approve content before release using workflows. More specifically, I will be referring to the Request for activation workflow.

As a reminder, the Request for activation workflow is used when a user who does not have crx:replicate permissions attempts to publish a page. By default, the content must be approved by a member of the administrators group:
The default configuration of the Request for activation workflow

However, depending on how your organisation envisions its content delivery pipeline, the supported functionality may be not be sufficient. Let's look at some examples:

Centralized authoring

In this model, web content is managed by a centralized web team that receives briefs from business teams and converts them into AEM web pages.

Once the content is ready, the team which issued the brief is asked to approve the content to make sure it meets their expectations.

Centralized web team diagram

Pros Cons
Content management stays in the hands of the expert AEM authors The whole organisation relies on the web team for any updates. This creates a bottleneck.

De-centralized authoring

In this model, some teams may be allowed to manage their own limited domain. For example, HR can publish new job offers while sales can maintain its own marketing materials.

In this case, some team members are responsible for creating content, which then get approved by their managers before publication.

De-centralized authoring diagram

Pros Cons
Simpler content can be managed independently by the topic experts If a manager is busy or unavailable, delivery will slow down
Managers may end up doing a lot of little-value-added approval work

Democratized authoring

In this model, teams are empowered to deliver content for which they are responsible in the most independent and streamless manner.

Content can be approved by any member of the team, except the creator.

If you are reading this, you are probably a software engineer familiar with the concept of code review. This model follows the same principles!

Democratized authoring diagram

Pros Cons
Simpler content can be managed independently by the topic experts
Creator and approver roles are flexible
Delivery velocity is maximised. No need to call the manager in order to approve typo fixes or style changes, etc.

Hybrid model

Of course, it is common to want to mix and match authoring models. Some content might be managed by the web team as per the Centralized model, whereas other content may be managed by the business teams as per the Democratized model. Yet other content might be deemed sensitive and require a manager-level approval as per the De-centralized model, etc.

Problem statement

The Centralized and De-centralized models described above are relatively easy to implement in AEM using the Request for activation workflow as the initiator (person who requests publication) and approver are in two distinct user groups.

However, in the Democratized authoring model, the initiator and approver are in the same user group. This is not supported by AEM. The reason is that the ParticipantStepChooser which is responsible for telling AEM which user should be assigned to a particular step can only return a single participant ID (user or group).

This means that (according to the diagram above), if allan requests the publication of a new job offer page, and the approval is assigned to the hr group, then allan could approve his own request.

Obviously, this breaches the four-eyes principle. Let's look at how we can implement this use case.

Solution

πŸ’‘ The code below focuses on business logic. Some utility code has been abstracted. See the Github diff at the bottom of the article to see the exhaustive source code.

Concept

The solution design relies on the following idea: we can create a special group (an exclusion group) at runtime which includes all members of an existing group or groups, minus the initiator. Members of this exclusion group can then perform the approval, and finally the exclusion group is removed when the workflow ends.

Creating the exclusion group

To create the exclusion group, we will implement a custom workflow process step for that purpose, as per Adobe's documentation: Custom Process Step.

import com.adobe.granite.workflow.WorkflowException;
import com.adobe.granite.workflow.WorkflowSession;
import com.adobe.granite.workflow.exec.WorkItem;
import com.adobe.granite.workflow.exec.WorkflowProcess;
import com.adobe.granite.workflow.metadata.MetaDataMap;
import com.theopendle.core.workflow.WorkflowUtil;
import com.theopendle.core.workflow.queries.GroupWithId;
import com.theopendle.core.workflow.queries.UsersOfGroup;
import lombok.extern.slf4j.Slf4j;
import org.apache.commons.lang.StringUtils;
import org.apache.jackrabbit.api.security.user.Authorizable;
import org.apache.jackrabbit.api.security.user.Group;
import org.apache.jackrabbit.api.security.user.User;
import org.apache.jackrabbit.api.security.user.UserManager;
import org.apache.jackrabbit.oak.spi.security.principal.PrincipalImpl;
import org.apache.jackrabbit.value.StringValue;
import org.apache.sling.api.resource.LoginException;
import org.apache.sling.api.resource.PersistenceException;
import org.apache.sling.api.resource.ResourceResolver;
import org.apache.sling.api.resource.ResourceResolverFactory;
import org.osgi.framework.Constants;
import org.osgi.service.component.annotations.Component;
import org.osgi.service.component.annotations.Reference;

import javax.jcr.RepositoryException;
import java.util.*;
import java.util.stream.Collectors;

import static com.theopendle.core.workflow.WorkflowUtil.setWorkflowVariable;

@Slf4j
@Component(property = {
        "process.label" + "=Create exclusion group",
        Constants.SERVICE_DESCRIPTION + "=Workflow step to create exclusion groups created earlier in the workflow",
        Constants.SERVICE_VENDOR + "=Theo Pendle",
})
public class CreateExclusionGroup implements WorkflowProcess {

    public static final String USER_ID_ADMIN = "admin";
    public static final String GROUP_ID_ADMIN = "administrators";
    public static final String PN_EXCLUSION_GROUP_ID = "exclusionGroupId";
    public static final String PN_GROUPS = "groups";

    @Reference
    private ResourceResolverFactory resourceResolverFactory;

    @Override
    public void execute(final WorkItem workItem, final WorkflowSession workflowSession, final MetaDataMap metaDataMap) throws WorkflowException {
        final String initiatorId = workItem.getWorkflow().getInitiator();

        // In case the initiator is the admin user, allow them to self-approve
        if (initiatorId.equals(USER_ID_ADMIN)) {
            log.warn("Initiator is admin. No exclusion group will be created.");
            setWorkflowVariable(workItem, PN_EXCLUSION_GROUP_ID, GROUP_ID_ADMIN);
            return;
        }

        final Map<String, String> arguments = WorkflowUtil.readArguments(metaDataMap);
        if (!arguments.containsKey(PN_GROUPS)) {
            throw new WorkflowException(String.format("No <%s> argument passed to step", PN_GROUPS));
        }

        final Set<String> groups = Arrays.stream(arguments.get(PN_GROUPS).split(","))
                .filter(StringUtils::isNotBlank)
                .collect(Collectors.toSet());
        if (groups.isEmpty()) {
            throw new WorkflowException(String.format("<%s> argument contains an empty list", PN_GROUPS));
        }

        try {

            try (final ResourceResolver resolver = resourceResolverFactory.getServiceResourceResolver(Map.of(
                    ResourceResolverFactory.SUBSERVICE, "user-management"))) {

                final UserManager userManager = resolver.adaptTo(UserManager.class);
                if (userManager == null) {
                    throw new WorkflowException(String.format("Could not retrieve <%s>", UserManager.class));
                }

                final Authorizable initiatorAuthorizable = userManager.getAuthorizable(initiatorId);
                if (initiatorAuthorizable == null) {
                    throw new WorkflowException(String.format("Could not find initiator of the workflow with ID <%s> ", initiatorId));
                }

                // Find all users belonging to specified groups
                final Set<Authorizable> users = groups.stream()

                        // If the initiator is not a member of the group provided, then discard it
                        .filter(groupId -> userInGroup(userManager, groupId, initiatorAuthorizable))

                        // Get all members of the eligible groups
                        .flatMap(groupId -> getUsersOfGroup(userManager, groupId).stream())

                        // Remove the initiator
                        .filter(user -> !user.equals(initiatorAuthorizable))

                        .collect(Collectors.toSet());

                if (users.isEmpty()) {
                    throw new WorkflowException(String.format("No other users found in groups <%s> (initiator <%s>)", groups, initiatorAuthorizable.getPrincipal().getName()));
                }

                // Create exclusion group
                final String exclusionGroupId = "demo-exclusion-group-" + UUID.randomUUID();
                final PrincipalImpl exclusionPrincipal = new PrincipalImpl(exclusionGroupId);
                final Group exclusionGroup = userManager.createGroup(exclusionPrincipal, "demo/exclusion");

                // Add properties to group so it can easily be found and recognized
                final String userIds = users.stream()
                        .map(user -> {
                            try {
                                return user.getPrincipal().getName();
                            } catch (final RepositoryException e) {
                                return null;
                            }
                        })
                        .filter(Objects::nonNull)
                        .collect(Collectors.joining(", "));

                for (final Map.Entry<String, String> entry : Map.of(
                        "workflowInstance", workItem.getWorkflow().getId(),
                        "includesGroups", String.join(",", groups),
                        "excludesUser", initiatorAuthorizable.getPrincipal().getName(),

                        // Name the group after the users that it contains so that authors know who is eligible to approve
                        // without needing access to the AEM user/group admin interfaces
                        "profile/givenName", userIds
                ).entrySet()) {
                    exclusionGroup.setProperty(entry.getKey(), new StringValue(entry.getValue()));
                }

                // Add users to exclusion group
                for (final Authorizable user : users) {
                    exclusionGroup.addMember(user);
                }

                // Commit the creation of the exclusion group
                resolver.commit();

                // Store the group ID so it can easily be found later for deletion
                setWorkflowVariable(workItem, PN_EXCLUSION_GROUP_ID, exclusionGroupId);

                log.info("Created exclusion group with ID <{}>", exclusionGroupId);

            } catch (final LoginException e) {
                throw new WorkflowException("Could not log to service user.", e);
            } catch (final RepositoryException e) {
                throw new WorkflowException("Unexpected error while fetching user and/group", e);
            } catch (final PersistenceException e) {
                throw new WorkflowException("Unexpected error while saving exclusion group", e);
            }

        } catch (final Exception e) {
            throw new WorkflowException(String.format("Unexpected error while running <%s>", this.getClass()), e);
        }
    }

    private boolean userInGroup(final UserManager userManager, final String groupId, final Authorizable userAuthorizable) {
        try {
            final GroupWithId groupWithId = new GroupWithId(groupId);
            final Iterator<Authorizable> iterator = userManager.findAuthorizables(groupWithId);

            if (!iterator.hasNext()) {
                log.error("Group <{}> passed to workflow via argument <{}> does not exist", groupId, PN_GROUPS);
                return false;
            }

            final Authorizable group = iterator.next();
            if (!group.isGroup()) {
                log.error("Principal <{}> passed to workflow via argument <{}> is not a group", groupId, PN_GROUPS);
                return false;
            }

            return ((Group) group).isMember(userAuthorizable);

        } catch (final RepositoryException e) {
            log.error("Unexpected error while searching for Authorizables", e);
            return false;
        }
    }

    private Set<User> getUsersOfGroup(final UserManager userManager, final String groupName) {
        try {
            final UsersOfGroup usersOfGroup = new UsersOfGroup(groupName);

            final Iterator<Authorizable> iterator = userManager.findAuthorizables(usersOfGroup);

            final Set<User> users = new HashSet<>();
            while (iterator.hasNext()) {
                final Authorizable authorizable = iterator.next();

                if (authorizable.isGroup()) {
                    log.info("Ignoring authorizable <{}>, member of group <{}> as it is not a user",
                            authorizable.getPrincipal().getName(), groupName);
                    continue;
                }

                users.add((User) authorizable);
            }

            return users;

        } catch (final RepositoryException e) {
            log.error("Unexpected error while searching for Authorizables", e);
            return Collections.emptySet();
        }
    }
}
Enter fullscreen mode Exit fullscreen mode

Assigning the approval step

Now that the exclusion group has been created, we can create a dynamic participant step chooser that assigns the next step to the exclusion group (see Dynamic Participant Step - Example Participant Chooser Service
):

import com.adobe.granite.workflow.WorkflowException;
import com.adobe.granite.workflow.WorkflowSession;
import com.adobe.granite.workflow.exec.ParticipantStepChooser;
import com.adobe.granite.workflow.exec.WorkItem;
import com.adobe.granite.workflow.metadata.MetaDataMap;
import lombok.extern.slf4j.Slf4j;
import org.osgi.service.component.annotations.Component;

import static com.theopendle.core.workflow.WorkflowUtil.getWorkflowVariable;

@Slf4j
@Component(service = ParticipantStepChooser.class, property = {
        ParticipantStepChooser.SERVICE_PROPERTY_LABEL + "=Exclusion group"
})
public class ExclusionGroupParticipantStepChooser implements ParticipantStepChooser {

    @Override
    public String getParticipant(final WorkItem workItem, final WorkflowSession workflowSession, final MetaDataMap metaDataMap) throws WorkflowException {
        final String exclusionGroupId = getWorkflowVariable(workItem, CreateExclusionGroup.PN_EXCLUSION_GROUP_ID, String.class);

        if (exclusionGroupId == null) {
            throw new WorkflowException(String.format("No exclusion group found in workflow metadata map via property <%s>",
                    CreateExclusionGroup.PN_EXCLUSION_GROUP_ID));
        }

        return exclusionGroupId;
    }
}
Enter fullscreen mode Exit fullscreen mode

Cleaning up the exclusion group

Of course, we don't want hundreds of exclusion groups to build up over time, so let's make sure to delete the group once the approval step is over.

We can do this by implementing another custom process step:

import com.adobe.granite.workflow.WorkflowException;
import com.adobe.granite.workflow.WorkflowSession;
import com.adobe.granite.workflow.exec.WorkItem;
import com.adobe.granite.workflow.exec.WorkflowProcess;
import com.adobe.granite.workflow.metadata.MetaDataMap;
import lombok.extern.slf4j.Slf4j;
import org.apache.jackrabbit.api.security.user.Authorizable;
import org.apache.jackrabbit.api.security.user.UserManager;
import org.apache.sling.api.resource.LoginException;
import org.apache.sling.api.resource.PersistenceException;
import org.apache.sling.api.resource.ResourceResolver;
import org.apache.sling.api.resource.ResourceResolverFactory;
import org.osgi.framework.Constants;
import org.osgi.service.component.annotations.Component;
import org.osgi.service.component.annotations.Reference;

import javax.jcr.RepositoryException;
import java.util.Map;

import static com.theopendle.core.workflow.WorkflowUtil.getWorkflowVariable;

@Slf4j
@Component(property = {
        "process.label" + "=Delete exclusion groups",
        Constants.SERVICE_DESCRIPTION + "=Workflow step to delete exclusion groups created earlier in the workflow",
        Constants.SERVICE_VENDOR + "=Theo Pendle",
})
public class DeleteExclusionGroups implements WorkflowProcess {

    @Reference
    private ResourceResolverFactory resourceResolverFactory;

    @Override
    public void execute(final WorkItem workItem, final WorkflowSession workflowSession, final MetaDataMap metaDataMap) throws WorkflowException {
        final String exclusionGroupId = getWorkflowVariable(workItem, CreateExclusionGroup.PN_EXCLUSION_GROUP_ID, String.class);

        if (exclusionGroupId == null) {
            throw new WorkflowException(String.format("No exclusion group found in workflow metadata map via property <%s>",
                    CreateExclusionGroup.PN_EXCLUSION_GROUP_ID));
        }

        try (final ResourceResolver resolver = resourceResolverFactory.getServiceResourceResolver(Map.of(
                ResourceResolverFactory.SUBSERVICE, "user-management"))) {

            final UserManager userManager = resolver.adaptTo(UserManager.class);
            if (userManager == null) {
                throw new WorkflowException(String.format("Could not retrieve <%s>", UserManager.class));
            }

            final Authorizable exclusionGroup = userManager.getAuthorizable(exclusionGroupId);
            if (exclusionGroup == null) {
                throw new WorkflowException(String.format("Could not find exclusion group with ID <%s>", exclusionGroupId));
            }

            exclusionGroup.remove();
            resolver.commit();
            log.info("Deleted exclusion group <{}>", exclusionGroupId);

        } catch (final LoginException e) {
            throw new WorkflowException("Could not log to service user.", e);
        } catch (final RepositoryException e) {
            throw new WorkflowException("Unexpected error while fetching user and/group", e);
        } catch (final PersistenceException e) {
            throw new WorkflowException("Unexpected error while saving exclusion group", e);
        }
    }
}
Enter fullscreen mode Exit fullscreen mode

Updating the workflow model

Now that we have the steps we need, let's put them together in the Request for activation workflow model.

Here is an image of the updated workflow model. See the Github link at the bottom of the article for the exact configuration:

The updated Request for activation workflow model

Result

Once the workflow model is updated and synchronized, the use case should be satisfied.

For the purposes of the demo, I have created some test groups and users, see the Github link at the bottom of the article for details.

Watch me demo the feature in the GIF below:

A demo of the functionality

Steps in the demo:

  1. We log in as allan and start the Request for activation workflow on a page
  2. We confirm that allan is not notified of the approval step (he cannot approve his own request)
  3. We log in as betty
  4. We confirm that betty has received a notification about the approval step
  5. We approve the content
  6. We log back in as allan and confirm that the content was indeed published

Conclusion

You should now be able to create four-eyes approvals in workflows for colleagues within the same AEM user group!

This article focused on the popular use case of page activation, but of course the principle is applicable to any approval you might need to implement.

You can view the source code for the implementation and the demo on Github.

I've created a diff so you can see just the changes to make this feature work here.

Don’t hesitate to contact me on LinkedIn if you have any questions/ideas!

Top comments (0)