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.
- 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
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.
The way to catch this is to unzip the cp2fm converted package and grep for your config:
# 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.
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:
# 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 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.
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:
# 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.
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:
# 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.
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.
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.
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.
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.
# 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
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:
# 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
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:
# 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:
/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:
# 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)