Katha Shah — Writing Essay No. 004 · 2025
AEM · AEMaaCS · Access Control · Repoinit

Taming AEM
Access Control

A developer's battle-tested guide to restricting authors to specific content areas in AEMaaCS

If you have ever tried to restrict an AEM author to only see and edit a specific folder in the Sites or DAM console, you have probably gone down the same rabbit hole I did. The documentation exists, the concepts are clear enough on paper, but getting it to actually work in AEMaaCS with repoinit is a different story entirely. This post documents everything I learned the hard way so you do not have to.

The goal: create author groups in AEMaaCS where each group can only see and edit their assigned content area — for example, a campaign-authors group that only sees /content/mysite/us/en/campaigns in the Sites console and /content/dam/mysite/campaigns in the DAM. Nothing else visible. Nothing else touchable.

Simple on paper. Weeks of debugging later, here is what actually works.

TL;DR — Skip to the answers
  • The .config format silently drops configs with unescaped quotes — cp2fm gives no warning and the pipeline succeeds. Always escape inner double quotes as \"
  • restriction(rep:glob,"") causes a ParseException. Use non-empty path globs on a high-level ancestor instead
  • Oak permissions are additive — users inherit from contributors and everyone. You must use explicit deny statements to override inherited access
  • Always start your ACL block with delete ACL for your-group — stale ACEs accumulate across deployments and cause permission conflicts
  • Jump to Lesson 7 for the complete working repoinit pattern
Lesson 01 of 09

The File Format Trap — .config vs .cfg.json

Adobe recommends .cfg.json for OSGi configurations in AEMaaCS. For repoinit specifically, the official Sling documentation recommends .config because it handles multiline strings better. Both statements are true. Both will send you in circles.

Here is the real issue nobody documents: the .config format uses Apache Felix's own string escaping rules. If your repoinit script contains double quotes — for example in restriction(rep:glob,"") — those quotes must be escaped as \". If you don't, the cp2fm converter silently drops the entire config file. No error. No warning. The pipeline succeeds. Your ACEs just never get applied.

Silent failure
cp2fm drops malformed .config files without any error output. The CI/CD pipeline reports success. You will have no idea your repoinit never ran unless you actively verify.

The way to catch this is to unzip the cp2fm converted package and grep for your config:

bash
# Build and check if your config made it into the feature model
mvn clean install -pl all -am
grep "your-config-name" ./all/target/cp-conversion/fm.out/aem-project.all-author.json

If your config does not appear in the feature model JSON, cp2fm dropped it. This is the first thing to check when your repoinit runs but nothing happens.

The fix
Escape all inner double quotes in your .config file using \". Every single one. No exceptions.
Lesson 02 of 09

restriction(rep:glob,"") Does Not Work in Repoinit

Every AEM blog and Stack Overflow answer about restricting traversal to a single node tells you to use rep:glob="" — an empty string glob that matches only the node itself, not its children. In theory this is correct. In practice, the repoinit parser throws a ParseException when it encounters an empty string value.

I tried every variation:

repoinit
# All of these fail in different ways
restriction(rep:glob,"")   # ParseException
restriction(rep:glob,)     # ParseException
restriction(rep:glob,'')   # Lexical error
restriction(rep:glob)      # Accepted BUT restriction is silently dropped
The dangerous one
restriction(rep:glob) with no value is the most dangerous. The parser accepts it, no error is thrown, the ACE is written — but the restriction is silently dropped. Your traversal-only entry becomes a full subtree read. You will not know until you test with a real user.

The official Sling parser test cases show restriction(rep:glob) as valid syntax. It is valid for the parser. The JCR repoinit module that writes to Oak simply does not store it correctly. This is a known gap between what the parser accepts and what the runtime supports.

Lesson 03 of 09

What Actually Works for Traversal Restriction

Instead of fighting the empty glob, use a non-empty path glob that precisely targets only your content area. Set the ACE on a high-level ancestor node with a glob that resolves to only your target path:

repoinit
# Two ACEs on /content replace eight individual traversal entries
allow jcr:read on /content restriction(rep:glob,/mysite/us/en/campaigns)
allow jcr:read on /content restriction(rep:glob,/mysite/us/en/campaigns/*)

For node /content, the glob /mysite/us/en/campaigns matches exactly /content/mysite/us/en/campaigns and all descendants when combined with the wildcard entry. Sibling paths like /content/mysite/us/en/blog or /content/mysite/us/en/products do not match and remain invisible.

Why this is better
This approach replaces the need for multiple traversal entries on every ancestor node. Two ACEs on /content replace eight individual traversal entries up the tree. Cleaner, more maintainable, and it actually works.
Lesson 04 of 09

The Contributors and Everyone Inheritance Problem

Even with perfectly scoped ACEs on your custom group, your users will still see everything. This one stumped me for a long time.

Here is why: every user created in AEM is automatically added to the contributors and everyone groups. These groups — along with content-authors — may have broad jcr:read on /content that cascades down to every child node. Your carefully crafted glob restrictions on your custom group are irrelevant because the user inherits unrestricted read from these other groups.

Oak permission evaluation is strictly additive. If ANY group in the user's membership chain has allow jcr:read on a node, the user can read that node. There is no narrowing without explicit denies.

The only reliable fix is to add explicit deny jcr:read statements on your custom group to override the inherited read:

repoinit
# Grant broad read for navigation, then deny siblings, then allow back target
allow jcr:read on /content
deny jcr:versionManagement,crx:replicate,rep:write,jcr:lockManagement,
     jcr:removeChildNodes,jcr:removeNode,jcr:addChildNodes,
     jcr:nodeTypeManagement on /content
deny jcr:read on /content/mysite/us/en restriction(rep:glob,/*)
allow jcr:read on /content/mysite/us/en restriction(rep:glob,/campaigns)
allow jcr:read on /content/mysite/us/en restriction(rep:glob,/campaigns/*)

The deny jcr:read on /content/mysite/us/en restriction(rep:glob,/*) line blocks read on ALL direct children of /en. The subsequent allow statements grant back only the specific folders you want visible. A deny on a more specific group overrides an allow inherited from a parent group in Oak's evaluation model.

Lesson 05 of 09

delete ACL Does Not Remove Inherited Permissions

The repoinit delete ACL for my-group statement is useful but widely misunderstood. It removes only the ACEs where my-group is the explicit principal. It does not remove my-group from any parent groups, remove ACEs belonging to those parent groups, or block permissions flowing down through group inheritance.

If campaign-authors is a member of content-authors, and content-authors has allow jcr:read on /content, deleting the ACL for campaign-authors does nothing to stop that inherited read. The user still gets it.

Important
You cannot clean up inherited permissions through delete ACL — you can only override them with explicit denies on your group. This is why the deny-based approach in Lesson 4 is necessary.
Lesson 06 of 09

Stale ACEs Persist Across Deployments

Repoinit scripts are idempotent for node creation but additive for ACEs. If you deploy a script, then change the ACEs and deploy again, the old ACEs remain in the repository alongside the new ones. This causes permission conflicts that are very hard to debug because you can't easily see the full accumulated state.

The fix
Always include delete ACL for my-group at the start of your ACL block. This cleans up the group's own explicit ACEs before re-applying. Combined with versioned file names for the OSGi config, you get a clean apply on every deployment.

Remember from Lesson 5: delete ACL only cleans explicit ACEs. Inherited ones remain. Plan your ACL strategy around this from the start — trying to retrofit it later is painful.

Lesson 07 of 09

The Working Pattern

After all of the above, here is the complete repoinit pattern that reliably works in AEMaaCS. The structure is consistent throughout: broad read for navigation, deny all writes on /content, deny read on siblings at each level, allow back only the specific paths, grant full access only on the actual target folder.

repoinit — complete working pattern
# Create group and add to necessary parent groups
create group campaign-authors with path /home/groups/mysite
add campaign-authors to group content-authors
add campaign-authors to group workflow-users

# Always clean stale ACEs first
delete ACL for campaign-authors

set ACL for campaign-authors
  # Broad read for console navigation
  allow jcr:read on /content

  # Deny all write operations across /content
  deny jcr:versionManagement,crx:replicate,rep:write,jcr:lockManagement,
       jcr:removeChildNodes,jcr:removeNode,jcr:addChildNodes,
       jcr:nodeTypeManagement on /content

  # Block sibling folders, allow only campaigns
  deny jcr:read on /content/mysite/us/en restriction(rep:glob,/*)
  allow jcr:read on /content/mysite/us/en restriction(rep:glob,/campaigns)
  allow jcr:read on /content/mysite/us/en restriction(rep:glob,/campaigns/*)

  # Within campaigns, restrict to specific sub-folder
  deny jcr:read on /content/mysite/us/en/campaigns restriction(rep:glob,/*)
  allow jcr:read on /content/mysite/us/en/campaigns restriction(rep:glob,/summer-2024)
  allow jcr:read on /content/mysite/us/en/campaigns restriction(rep:glob,/summer-2024/*)
  allow jcr:all on /content/mysite/us/en/campaigns/summer-2024

  # DAM — same pattern
  deny jcr:read on /content/dam/mysite restriction(rep:glob,/*)
  allow jcr:read on /content/dam/mysite restriction(rep:glob,/campaigns)
  allow jcr:read on /content/dam/mysite restriction(rep:glob,/campaigns/*)
  allow jcr:all on /content/dam/mysite/campaigns

  # Supporting paths
  allow jcr:read on /conf/mysite
  allow jcr:read on /etc/replication
  allow jcr:read on /etc/replication/agents.author
end
Lesson 08 of 09

IMS Groups and Repoinit

In AEMaaCS, user identities come from Adobe IMS via the Admin Console. Users are assigned to IMS user groups in the Admin Console, and those groups sync into AEM on first login. The synced groups appear in AEM under /home/groups with a rep:externalId property containing the IMS identifier.

The correct pattern is to make your IMS group a member of your repoinit group — not the other way around:

repoinit
# IMS group becomes member of repoinit group
# Identity managed in Admin Console. Permissions managed in repoinit.
add My Campaign Authors IMS to group campaign-authors
Timing caveat
This repoinit statement will fail if the IMS group does not yet exist in AEM. The group only gets created in AEM after the first user from that IMS group logs in. On a brand new environment, either skip this statement on initial setup or ensure at least one IMS user logs in before the repoinit script runs.
Lesson 09 of 09

Verifying Your ACEs Actually Got Applied

The AEM Permissions UI does not show restriction values clearly. The querybuilder API returns ACE nodes but does not include rep:restrictions child node properties by default. The most reliable verification is to navigate directly to the ACE node in CRXDE:

crxde path
# The rep:glob property here shows the exact value stored in Oak
/content/rep:policy/allowXX/rep:restrictions

If the restrictions node does not exist, your glob restriction was not stored. Also verify using the querybuilder to see all ACEs for your group:

querybuilder url
/bin/querybuilder.json?type=rep:ACE
  &property=rep:principalName
  &property.value=campaign-authors
  &p.limit=-1
  &p.hits=full

And always test with an actual user in an incognito window. The Sites console can sometimes appear to show pages due to admin session caching. A real user test with a .json endpoint call confirms actual permission state:

verification
# Test as a real user — not admin
/content/mysite/us/en/blog.json

# Expected responses:
# 403 → correctly blocked ✓
# 200 → user genuinely has access (check your ACEs)
Summary
Getting AEM author restrictions right in AEMaaCS requires understanding several non-obvious behaviours: cp2fm silently drops malformed configs, the repoinit parser does not support empty glob strings, Oak permissions are additive across group membership, and stale ACEs accumulate without explicit cleanup. Once you understand the model, it is consistent and maintainable. Getting there just takes longer than it should.