Four-eyes Validation in AEM
Tags: aem, adobe, workflow, cms
Published: 2024-01-14 14:51:53

## 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:
* Workflows (see: [Work with Workflows](https://experienceleague.adobe.com/docs/experience-manager-65/content/sites/authoring/workflows/workflows.html?lang=en#:~:text=AEM%20Workflows%20let%20you%20automate,site%20administrator%20activates%20the%20page.))
* Customizing workflows and steps (see: [Workflow Step Reference](https://experienceleague.adobe.com/docs/experience-manager-65/content/implementing/developing/extending-aem/extending-workflows/workflows-step-ref.html?lang=en#or-split), [Custom Process Step](https://experienceleague.adobe.com/docs/experience-manager-learn/forms/adaptive-forms/custom-process-step-aem-workflow.html?lang=en))
* Participant steps and choosers

## 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](https://dev-to-uploads.s3.amazonaws.com/uploads/articles/runw8wlv041g8nlcggcp.png)

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](https://dev-to-uploads.s3.amazonaws.com/uploads/articles/4s6ah5dzhzu26h7hs5rm.png)

| 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](https://dev-to-uploads.s3.amazonaws.com/uploads/articles/187c0vl2dzxdjl90i1s9.png)

| 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](https://dev-to-uploads.s3.amazonaws.com/uploads/articles/x1iggq4tdawt16nvq6bg.png)

| 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`](https://developer.adobe.com/experience-manager/reference-materials/6-5/javadoc/com/adobe/granite/workflow/exec/ParticipantStepChooser.html) 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](https://experienceleague.adobe.com/docs/experience-manager-learn/forms/adaptive-forms/custom-process-step-aem-workflow.html?lang=en).

```java
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();
        }
    }
}
```

### 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
](https://experienceleague.adobe.com/docs/experience-manager-65/content/implementing/developing/extending-aem/extending-workflows/workflows-step-ref.html?lang=en#dynamic-participant-step-example-participant-chooser-service)):

```java
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;
    }
}
```

### 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:

```java
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);
        }
    }
}
```

### 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](https://dev-to-uploads.s3.amazonaws.com/uploads/articles/twefnerw6d4daezcpybq.png)

### 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](https://dev-to-uploads.s3.amazonaws.com/uploads/articles/tqpw383j8yffnrhgd29j.gif)

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](https://github.com/theopendle/aem-demo/tree/tutorial/4-eyes-validation).

I've created a diff so you can see just the changes to make this feature work [here](https://github.com/theopendle/aem-demo/compare/cd123fd4f766399887171f20bad0284014dd9f09...tutorial/4-eyes-validation).

Don’t hesitate to contact me on [LinkedIn](https://www.linkedin.com/in/theo-pendle-1630a52a) if you have any questions/ideas!