Permissions as Code in AEM: A Practical Guide to the AC Tool

Fragments, variables, loops, and test users: how to build an AEM permission model that starts from "deny", stays readable as it grows, and stores every permission only once.

Permissions are usually the part of an AEM project that nobody wants to own. They get clicked together in the Security console on one environment, exported as a package, half-reapplied on another, and a year later nobody can say why a reviewer can delete pages in one country but not in another.

The Netcentric Access Control Tool (AC Tool) fixes the mechanics: groups, memberships and ACEs live in YAML files in Git and are applied the same way on every instance. But the tool only fixes the mechanics. A YAML file can be just as messy as a hand-made ACL. Structure is what makes the difference.

This article is the structure I keep coming back to. It covers:

All examples use a fictional site called acme. They show the structure, not a ready-to-deploy configuration: always check the effective permissions on your own AEM version before rolling anything out.

The model in one picture

Before any YAML, here is the shape we are aiming for:

Layers of an AC Tool permission model. Base fragments deny almost everything and allow the minimum to log in. Functional fragments each open one console or feature. Role fragments combine them, each role building on the previous one: editor on viewer, lead editor on editor. Business groups pick one role fragment and add content paths; users are members of business groups only. Each layer is a member of the layer above it via isMemberOf.

Two kinds of groups, with a clear split of responsibility:

Start from "deny", then allow

AEM is generous out of the box. Built-in groups such as everyone and contributor already grant read access to large parts of the repository and to most of the UI. You could add denies for everything a role should not do, but that list never ends: every new site, AEM release or add-on brings new paths and new consoles, and they are all open until someone remembers to close them.

The opposite approach scales better. One fragment closes the content and the UI, and every role opens only what it needs:

# global-fragments.yaml
- group_config:
    - fragment-restrict-for-everyone:
        - name: "Fragment: deny by default"
          path: acme-fragments

- ace_config:
    - fragment-restrict-for-everyone:
        - path: /
          permission: allow
          privileges: jcr:read

        - path: /content
          permission: deny
          privileges: jcr:read,jcr:readAccessControl

        - path: /content/dam
          permission: deny
          privileges: jcr:read,jcr:readAccessControl

        # Hide consoles and tools nobody gets by default
        - path: /libs/cq/core/content/nav/tools/security
          permission: deny
          privileges: jcr:read

        - path: /libs/wcm/core/content/sites/jcr:content/actions/selection/deletepage
          permission: deny
          privileges: jcr:read

The fragment does three things. It sets a known baseline (read on /, so /libs, /apps and the rest the product needs to run stay readable), closes the content trees, and hides the consoles and actions nobody gets by default. It does not lock down the whole repository, and it shouldn't: /libs and /apps must stay readable for AEM to work. "Deny by default" here means content and UI, the parts that grow with every new site and release.

Then a business group opens exactly its own content:

- ace_config:
    - acme-editor:
        - path: /content/acme/language-masters/en
          permission: allow
          privileges: jcr:read,jcr:modifyProperties,jcr:lockManagement,jcr:versionManagement,jcr:addChildNodes,jcr:nodeTypeManagement,jcr:removeChildNodes,jcr:removeNode,jcr:readAccessControl,crx:replicate

The privileges are listed one by one on purpose. Aggregates such as rep:write or jcr:all are shorter, but they hide what is actually granted, and an aggregate can include more than you expect (rep:write also brings jcr:nodeTypeManagement). An explicit list is easy to review in a pull request and easy to compare between roles: the only difference between "edit" and "edit and publish" is one word at the end of the line. The same goes for the less obvious ones: jcr:readAccessControl lets a user read the access control policies on that tree (not change them). Grant it only if your roles actually need it, and never add jcr:modifyAccessControl to an authoring role.

Why this works: in Oak, an ACE on a deeper path wins over an inherited one. The deny on /content applies to everything except the subtrees a group explicitly allows. Hiding UI works the same way: AEM consoles and toolbar actions are nodes under /libs and /apps, and a user who cannot read the node doesn't see the button. A functional fragment that needs the button again simply allows that one node.

Hiding a button is not a security control. The deny on deletepage removes the Delete action from the Sites console, but the editor group above still has jcr:removeNode on their content, and a request that doesn't go through that button will still delete the page. UI denies keep the interface clean and match it to the role. What a user can actually do is decided by the repository privileges on the content. If a role must not delete pages, don't grant jcr:removeNode and jcr:removeChildNodes on its content paths, and check that no other group grants them either.

What you get in return:

"But Oak says: avoid deny"

It does. The Oak documentation recommends relying on its implicit deny and avoiding explicit deny entries, because mixing denies and allows across several principals quickly becomes hard to follow. That advice is right for a bare repository. AEM, however, is not a bare repository: the product itself ships with broad allows, and you cannot remove them without breaking the product.

The AC Tool documentation describes the compromise this article follows: denies are used sparingly, and normally there is exactly one group that holds all the deny entries for content that must be secured, plus many groups with allow entries that selectively open what that one group denied. So the rule is not "use lots of denies". It is: all denies in one place, coarse, at the top of the tree; everything else is an allow.

Two details make this work:

There is also a cost to keep in mind. Oak checks permissions on query results after it reads them from the index. A user who can read only a small part of a large tree makes searches read many invisible nodes before finding visible ones. That is one more reason to align the content structure with the permission model: content with the same audience belongs in the same subtree.

A folder without its content

A recurring requirement: users must navigate through a folder to reach their own subfolder, but must not see its siblings. For example, an author of one brand needs to open /content/dam to reach /content/dam/acme, but must not see the other brands next to it.

rep:glob restrictions solve this. The AC Tool has a shortcut for them, repGlob:

        # The folder node itself
        - path: /content/dam
          permission: allow
          privileges: jcr:read,jcr:readAccessControl
          repGlob: ""

        # Its properties and jcr:content (title, thumbnails, metadata)
        - path: /content/dam
          permission: allow
          privileges: jcr:read,jcr:readAccessControl
          repGlob: /jcr:*

        # And the one subtree this group really works in
        - path: /content/dam/acme
          permission: allow
          privileges: jcr:read

Put the generic part (the first two entries for /content, /content/dam, /content/cq:tags) into the deny fragment itself. Then every role can at least traverse to its own area, and business groups only add their own subtree.

One caution from the AC Tool documentation: repGlob doesn't combine well with actions. Use explicit privileges on entries that have restrictions.

Fragments: one responsibility each

A fragment is just a group that collects permissions for one purpose. Three layers work well.

Base fragments: the deny fragment above, and a small fragment-basic-allow with what every logged-in user needs (read /etc/languages, /etc without children, and so on).

Functional fragments: one console or feature each:

# functional-fragments.yaml
- group_config:
    - fragment-sites:
        - name: "Fragment: Sites console"
          path: acme-fragments
    - fragment-dam:
        - name: "Fragment: Assets console"
          path: acme-fragments
    - fragment-workflows:
        - name: "Fragment: Workflow console"
          path: acme-fragments

- ace_config:
    - fragment-sites:
        - path: /libs/cq/core/content/nav/sites
          permission: allow
          privileges: jcr:read
        - path: /libs/wcm/core/content/siteadmin
          permission: allow
          privileges: jcr:read

    - fragment-dam:
        - path: /libs/cq/core/content/nav/assets
          permission: allow
          privileges: jcr:read
        - path: /libs/wcm/core/content/damadmin
          permission: allow
          privileges: jcr:read

Role fragments: combine the others with isMemberOf and add what is specific to the role. Each role builds on the previous one:

- group_config:
    - fragment-viewer:
        - name: "Fragment: Viewer"
          isMemberOf: fragment-restrict-for-everyone,fragment-basic-allow,fragment-sites,fragment-dam,fragment-inbox
          path: acme-fragments

    - fragment-editor:
        - name: "Fragment: Editor"
          isMemberOf: fragment-viewer
          path: acme-fragments

    - fragment-lead-editor:
        - name: "Fragment: Lead editor"
          isMemberOf: fragment-editor,fragment-workflows
          path: acme-fragments

- ace_config:
    - fragment-editor:
        # Editors may copy pages, viewers may not
        - path: /libs/wcm/core/content/sites/jcr:content/actions/selection/copypage
          permission: allow
          privileges: jcr:read

Business groups then pick one role fragment and add their content paths:

- group_config:
    - acme-editor:
        - name: "ACME Editor"
          isMemberOf: fragment-editor
          path: acme

A few rules keep this maintainable:

Physical vs. virtual fragments

The AC Tool can mark a group as virtual:

    - fragment-dam:
        - name: "Fragment: Assets console"
          virtual: true

A virtual group is never created in the repository. Its ACEs and memberships are copied into every group that lists it in isMemberOf. That looks attractive, since users don't end up with technical groups. But look at what happens to the ACLs.

Comparison of the ACL on /content with a virtual and a physical deny fragment. With a virtual fragment, the deny entry is copied to every business group: acme-editor, acme-viewer, acme-dam-editor and five more, so 120 entries times 8 groups gives 960 ACEs. With a physical fragment, the ACL has one entry for fragment-restrict-for-everyone, so the total is 120 ACEs. The trade-off: with virtual fragments a user's session has one group principal, with physical fragments it has the business group plus the fragments it inherits.

Say fragment-restrict-for-everyone has 120 entries and 8 business groups inherit it:

Virtual fragment
Physical fragment
Groups in the repository
8
8 + 1
ACEs written
8 × 120 = 960
120
Adding a 9th role
+120 ACEs
+1 membership
Changing one entry
8 ACEs change
1 ACE changes

The AC Tool documentation says this directly: virtual groups flatten the group tree but enlarge the ACLs, because each referencing group is listed in the ACL instead of the virtual one. With nested virtual fragments (role → functional → base), the duplication multiplies at every level.

Larger ACLs are not free. Oak keeps permission entries in a permission store organised by principal and path, and caches them per principal. With virtual fragments the same 120 entries are stored and cached eight times, once for every group. Every ACL an administrator or the AC Tool reads or writes is eight times longer, and so is every diff between environments. With physical fragments each permission exists once, on one principal, and users get it through group membership.

To be fair to virtual groups: the AC Tool documentation itself calls them useful for functionality fragments, and they have one real advantage. A user ends up with fewer principals in the session, because the group tree is flattened. With physical fragments, a user who is a member of acme-editor also carries fragment-editor, fragment-viewer, fragment-sites and the rest. In practice this matters less than it sounds: Oak looks up permission entries by principal, so a session reads roughly the same number of entries either way. The difference is whether those entries exist once or once per role.

So it's a trade-off: virtual fragments buy a flatter group list with duplicated ACLs; physical fragments keep every entry once at the cost of more, visible groups. For fragments that many groups inherit, especially the base ones with most of the entries, I default to physical groups. virtual: true still fits a small fragment that only a couple of groups reference.

The downside of physical fragments

There is a cost, and it's mostly cosmetic but real: fragments are visible. A user's group list in the Security console, and in "Groups" on the user profile, shows fragment-editor, fragment-viewer, fragment-sites, fragment-dam and so on, through inherited membership. Administrators sometimes start assigning users to fragments directly, which bypasses the business groups and their content paths.

Ways to soften it:

Variables

Repeated lists, such as DAM folders a role can edit or language roots, belong in a variable:

- DEF damEditable=[acme/products,acme/campaigns,acme/brand]

- ace_config:
    - acme-dam-editor:
        - FOR folder IN ${damEditable}:
            - path: /content/dam/${folder}
              permission: allow
              privileges: jcr:read,jcr:modifyProperties,jcr:lockManagement,jcr:versionManagement,jcr:addChildNodes,jcr:nodeTypeManagement,jcr:removeChildNodes,jcr:removeNode,jcr:readAccessControl,crx:replicate

A DEF is local: it's visible from the line where it is defined to the end of the same file. Values used by several files go into global_config instead:

# _global-config.yaml
- global_config:
    vars:
      - DEF acmeLanguages=[en,de,fr,es,it,pl,uk,cs]
      - DEF acmeRoot="/content/acme"

File naming: global settings first

Global variables are only visible to files processed after the file that defines them. The AC Tool reads files alphabetically by full path. So the file with global_config must sort first, which is why it's named with a leading underscore: _global-config.yaml.

A layout that reads well and sorts correctly:

acconfig/config.author/
├── _global-config.yaml         # global_config: vars, test users, unmanaged regexes
├── global-fragments.yaml       # base fragments: deny + basic allow
├── functional-fragments.yaml   # one console/feature per fragment
├── fragment-viewer.yaml        # role fragments
├── fragment-editor.yaml
├── fragment-lead-editor.yaml
├── acme-editor.yaml            # business groups, one file per group
├── acme-viewer.yaml
└── acme-dam-editor.yaml

Two details:

Loops and conditions

FOR repeats any part of the configuration: ACEs, groups, or both.

# One group per language, each with its own language root
- group_config:
    - FOR lang IN ${acmeLanguages}:
        - acme-editor-${lang}:
            - name: "ACME Editor (${lang})"
              isMemberOf: fragment-editor
              path: acme

- ace_config:
    - FOR lang IN ${acmeLanguages}:
        - acme-editor-${lang}:
            - path: ${acmeRoot}/${lang}
              permission: allow
              privileges: jcr:read,jcr:modifyProperties,jcr:lockManagement,jcr:versionManagement,jcr:addChildNodes,jcr:nodeTypeManagement,jcr:removeChildNodes,jcr:removeNode,jcr:readAccessControl,crx:replicate

Loops can be nested. They can also iterate over the repository itself, which is useful when sites are created by authors rather than developers:

    - FOR site IN CHILDREN OF /content/acme:
        - path: ${site.path}
          permission: allow
          privileges: jcr:read

Keep in mind that CHILDREN OF reads the content of the instance it runs on. Two environments with different content end up with different ACLs, which is sometimes exactly what you want and sometimes a surprise.

IF takes an expression. The most common one checks run modes:

    - IF ${containsAnyItem(RUNMODES, ['dev','stage','prod'])}:
        - ...

Test users for every role

Checking permissions with admin proves nothing. The AC Tool can create one test user per group automatically:

# _global-config.yaml
- global_config:
    autoCreateTestUsers:
      createForGroupNamesRegEx: "acme-.*"
      prefix: "testuser-"
      name: "Test User %{group.name}"
      path: /home/users/acme-test-users
      skipForRunmodes: prod

For every group matching the regex (acme-editor, acme-viewer, ...), you get a user such as testuser-acme-editor that is a member of exactly that group.

Use them through impersonation

In practice nobody logs in as a test user. Developers and administrators impersonate them: in the AEM user menu, "Impersonate as" switches the session to the test user, and "Revert to self" switches back. No passwords to share, no second browser profile, and you can jump between roles in a few seconds. On AEM as a Cloud Service it's the natural way anyway: people sign in through Adobe IMS, and local accounts like these aren't meant for everyday login.

Impersonation is only allowed for users listed on the test user itself, and the AC Tool maintains that list for you. impersonationAllowedFor takes a YAML list of principal names (a plain string fails):

    autoCreateTestUsers:
      # ...
      impersonationAllowedFor:
        - jane.developer
        - qa-lead

What to check

A test user is a member of exactly one business group, so it shows what that group grants. A real user can be a member of several groups and gets the sum of all of them. Test the combinations that exist in reality, too.

Check what must stay closed, not only what must be open:

Things to know:

Groups AEM creates on its own: DAM collections

The AC Tool treats its configuration as the full truth. On every run it removes memberships of managed groups that the configuration doesn't declare. Usually that's what you want, but AEM creates some groups at runtime.

The typical case is DAM collections. For every collection, AEM creates its own groups following the pattern mac-default-<collection> (a parent group plus owner and editor groups). When a collection is shared, AEM adds the selected users and groups to them. If one of your managed groups was added, the next AC Tool run removes it again, and the collection is suddenly not shared any more.

Tell the AC Tool to leave those memberships alone:

# _global-config.yaml
- global_config:
    # Memberships in groups AEM creates for shared collections
    defaultUnmanagedExternalIsMemberOfRegex: mac-default-.*

The same family of settings covers other cases where something besides the AC Tool legitimately manages part of the picture:

Keep these regexes narrow. The documentation itself warns that a broad one (for example, unmanaging contributor) lets hand-made permissions creep back into groups you think are managed.

Checklist

The AC Tool won't design a permission model for you. But once the model has a structure, a pull request that changes who can do what is a few readable lines of YAML, and that's the point.