Users, Groups, Roles and Permissions
13 – Users, Groups, Roles and Permissions
Target: 3–4 years experienced AEM Developer Syllabus point covered (22): "User roles and group permissions." Project domain: a global energy technology company's marketing site.
Before we start — the question this topic actually tests
Your syllabus point is short, but the question underneath it is not "can you list the AEM groups." It is:
"Do you understand how AEM decides whether a user can do something?"
Because permission problems in AEM are almost never "someone forgot to grant access." They are almost always evaluation problems — a deny inherited from somewhere unexpected, an ACL set at the wrong level, or a user in two groups where one contradicts the other.
So section 2.4 — how permissions are evaluated — is the centre of this file. Three rules govern everything, and if you can state them cleanly you can reason your way through any scenario an interviewer throws at you.
There is also a pattern worth noticing that connects several earlier files. "The component renders on author but is blank on publish" has appeared in files 02, 05 and 08, and one of the standard causes has always been permissions. This file is where that finally gets explained properly: author and publish run as different users, and the anonymous user on publish has a much smaller world.
1. Introduction
1.1 Where users and groups live
Everything in AEM is content, and users are no exception.
Two things worth noticing:
The single-letter folders are buckets. AEM hashes the authorizable ID and distributes users into subfolders, so you don't end up with fifty thousand child nodes under one parent — the flat-structure problem from file 01. So you can't reliably guess a user's path; you look them up by ID.
Users are nodes, so they have properties and ACLs like anything else. Which is why user management is just content management with a specialised UI on top.
1.2 The one rule that matters most
Before any detail:
Grant permissions to groups. Put users in groups. Never grant permissions to a user directly.
Why: people join, leave and change roles. A permission granted to maria.schmidt becomes invisible technical debt the moment Maria moves teams — nobody knows why it exists, nobody dares remove it, and when her replacement arrives they don't inherit it.
A permission granted to energy-de-authors survives every personnel change. Onboarding becomes "add to group"; offboarding becomes "remove from group."
This is the first thing to say in an interview, because it is the practice that actually matters and it costs nothing to state.
1.3 A real project example to adapt
"We have a group per country editorial team —
energy-de-authors,energy-us-authorsand so on — each with read and write on their own country branch under/content/energy, plus read on the language masters so they can see what they're inheriting. There's a separate group for the people who can publish, because in most countries authoring and publishing are different responsibilities.All permissions go to groups, never to individuals, and group membership on author comes from our identity provider rather than being managed locally, so joiners and leavers are handled by the same process as everything else.
On the publish side the important one is anonymous — it has read on the published content tree and nothing else, and that's what makes the site public. Anything that shouldn't be public either isn't published or sits behind a closed user group."
That covers group-based permissions, the country structure, SSO, and the publish side — four follow-ups pre-empted.
2. Core Concepts
2.1 Privileges versus permissions
Two words for related things, and being precise is a small credibility marker.
Privileges are the atomic, JCR-level rights. They're what actually gets stored.
Permissions are the friendlier grouping shown in AEM's UI — read, modify, create, delete, read acl, edit acl, replicate. Each maps to one or more privileges.
Two worth calling out specifically:
crx:replicate is AEM's own, not part of the JCR spec. It's what separates "can edit" from "can publish" — and on most projects those are deliberately different groups, because publishing is a business decision with different accountability from editing.
rep:write is an aggregate. Granting it grants four privileges at once. Convenient, and worth knowing so you're not surprised that a user who "only has write" can also delete.
2.2 How an ACL is stored
An access control list lives on the node it protects, as a rep:policy child:
So an ACL is just content. Which is why it can be created by Repoinit, inspected in CRXDE, and — importantly — why it's covered by the same package and deployment mechanics as everything else.
Each entry is either a grant or a deny, names a principal (a user or group), and lists privileges.
2.3 Restrictions — rep:glob and friends
An ACE can be narrowed with restrictions, and rep:glob is the one that comes up.
The empty glob is the one worth remembering. rep:glob="" means "this node and nothing under it," which is how you grant access to a folder without granting access to its contents — useful for letting someone see that a branch exists while not seeing what's in it.
Other restrictions worth being able to name: rep:ntNames (restrict by node type), rep:itemNames (by property or child name), rep:prefixes (by namespace).
2.4 How permissions are evaluated — the centre of this file
Three rules. Learn them in this order, because they apply in this order.
Rule 1 — Deny by default
If nothing grants a permission, it is denied. There is no implicit access. A brand new group can do nothing at all until you grant something.
Rule 2 — The closest ACL wins
ACLs are inherited down the tree. But an ACL on a deeper node takes precedence over one on an ancestor, for that subtree.
The group can read /content/energy and /content/energy/de, but not /content/energy/de/internal or anything below it — because the closer ACL wins for that subtree.
This is what makes fine-grained control possible, and it's also the source of most confusion: a permission problem deep in the tree is often caused by an ACL several levels up that nobody remembered.
Rule 3 — At the same level, deny wins
If a user is in two groups, and at the same node one group is allowed and the other denied, the deny wins.
Why this matters practically: it means a deny is a blunt instrument. Once you deny something to a group, you cannot grant it back to a member by adding them to another group at the same level. That surprises people, and it is the mechanism behind "I added them to the right group and they still can't get in."
A useful nuance to have, honestly stated: Oak evaluates entries within a single ACL in order, so the precise mechanics are more subtle than "deny always wins." But deny-wins-at-the-same-level is the model AEM's documentation teaches and the one interviewers expect, and it correctly predicts the behaviour in essentially every real case you'll meet. If you want to sound careful, say "at the same level, an explicit deny takes precedence" rather than claiming absolute universality.
The three rules together
graph TD A["Can this user do X on this node?"] --> B{"Is there an ACL<br/>on THIS node?"} B -->|Yes| C{"Any DENY for this<br/>user or their groups?"} C -->|Yes| D["DENIED<br/>— deny wins at the same level"] C -->|No| E{"Any ALLOW?"} E -->|Yes| F["ALLOWED"] E -->|No| G["Walk UP to the parent"] B -->|No| G G --> H{"Reached the root<br/>with no grant?"} H -->|Yes| I["DENIED<br/>— deny by default"] H -->|No| B
The interview answer:
"Three rules, in order.
Deny by default — nothing is permitted unless something explicitly grants it. A new group can do nothing at all.
The closest ACL wins — permissions inherit down the tree, but an ACL on a deeper node overrides one on an ancestor for that subtree. So you grant broadly at a high level and carve out exceptions lower down.
At the same level, an explicit deny takes precedence over an allow. So if a user is in two groups and one denies what the other grants on the same node, they're denied. That's the rule people get caught by — you can't grant something back by adding someone to another group, which is why 'I added them to the right group and it still doesn't work' is such a common ticket.
The practical consequence is that deny is a blunt instrument. I'd rather structure content so that broad grants at a high level naturally cover the right thing, and use deny sparingly for genuine exceptions — because a deny somewhere high up produces confusing failures a long way down the tree."
2.5 The standard groups
Worth being able to name, and one of them has a property that matters.
everyone is the one to understand. Every authorizable in the system belongs to it automatically. You don't add anyone; membership is implicit.
Which makes it dangerous. A deny on everyone denies essentially the whole system, and because of rule 3 you cannot grant it back to a specific group at the same level. It's occasionally the right tool — denying everyone read on a sensitive branch and then granting it back at a deeper node relies on rule 2 rather than rule 3 — but it needs care and it should be a deliberate decision, not a convenience.
2.6 The user your code runs as
This is where several earlier files converge, and it's the explanation for a recurring symptom.
On author, requests come from authenticated users. Their group memberships determine what they can see.
On publish, most requests are the anonymous user. What makes your site public is precisely that anonymous has jcr:read on the published content tree.
And this is why "works on author, blank on publish" keeps appearing. Two different users with two very different worlds:
From files 05 and 07: request.getResourceResolver() gives you a resolver bound to the current user. So repository reads through it automatically respect that user's permissions — which is usually exactly what you want, and occasionally the reason your component silently renders nothing on publish.
When you genuinely need to read past the visitor's permissions, that's a service user — section 2.7.
2.7 Service users
From file 01, and worth restating because it's the correct pattern and it comes up.
A service user is a system user with no password, created for a specific piece of code, granted only the permissions that code needs.
How the wiring works:
Why not just use an admin session? Because getAdministrativeResourceResolver() grants everything, everywhere, forever — so a bug in one service becomes a full-repository problem. It's deprecated and blocked on Cloud Service.
Two things to always say:
Least privilege. Grant the service user exactly what it needs and nothing more. If it reads product pages, give it read on that branch — not read on /content.
Close it. A service resolver is one you opened, so it goes in try-with-resources. That's the file 07 rule: close what you opened, never close what Sling gave you.
2.8 Repoinit — the only supported way on Cloud Service
Users, groups, ACLs and folder structures all have to exist before your code runs. On Cloud Service you can't create them with an install hook or an admin session, so Repoinit is how it's done.
It's idempotent by design, which matters because pods restart constantly on Cloud Service — creating a group that already exists is not an error.
And it means permissions are in Git, reviewed like code and versioned. That's a genuinely better model than clicking through a UI on each environment and hoping they match, and it's worth saying so.
2.9 Closed User Groups
The requirement: part of the published site should only be visible to logged-in users — a partner portal, technical documentation behind registration, pricing for authenticated customers.
A CUG restricts a published subtree to specific principals. Two things happen: the subtree requires authentication, and access is limited to named groups.
In modern AEM this is configured through Page Properties → Permissions, which writes a CUG policy onto the page. Older AEM used cq:cugEnabled and cq:cugPrincipals properties, which you may still meet.
The point worth making: a CUG is a publish-side concern. It's not about who can author the content — it's about who can view it once it's live. Those are different questions and different groups.
2.10 Roles, honestly
Your syllabus says "user roles," so it's worth being straight about this:
AEM has no formal "role" concept. What people call roles are groups, plus the permissions granted to those groups.
If someone asks about roles, they mean "how do you model different kinds of user." The answer is a group hierarchy:
Groups can be members of groups, and permissions accumulate down that chain — which is how you build "role" hierarchies. Saying this explicitly, rather than pretending AEM has roles, is the more accurate answer.
3. Internal Working
3.1 Walking the tree
When Oak decides whether a user can read a node, it works from the node upward, and the first applicable ruling wins:
The walk stops at the first ruling. That's rule 2 in mechanical form, and it explains why an ACL high up can be invisible until you go looking — the effective permission on a deep node may be decided several levels above it.
3.2 Why "I added them to the group and it still doesn't work"
A classic ticket, and the mechanism is rule 3:
graph TD A["User added to energy-de-authors,<br/>still can't access the page"] --> B{"Is the user in another group<br/>with a DENY at the same node?"} B -->|Yes| C["DENY WINS.<br/>Adding a group cannot override it."] B -->|No| D{"Is there a DENY on an<br/>ANCESTOR closer than the grant?"} D -->|Yes| E["The closer ACL wins.<br/>Grant lower, or remove the deny."] D -->|No| F{"Did the user log out<br/>and back in?"} F -->|No| G["Group membership is resolved<br/>at login for the session"] F -->|Yes| H["Check the grant is on the<br/>right path and privilege"]
The third branch catches people out and is worth knowing: group membership is resolved into the session. A user who was already logged in when you changed their groups may need to log out and back in.
3.3 Where permissions bite in code
Tying together files 05 and 07:
That last row is the one to remember. A path-bound servlet resolves no resource, so no permission check happens. Which is why file 07 insisted that path-bound servlets do their own authorisation — this file is where that requirement comes from.
8. Configuration Examples
8.1 Groups and ACLs in Repoinit
ui.config/.../org.apache.sling.jcr.repoinit.RepositoryInitializer~energy.cfg.json
Readable form of the script:
The four things to be able to defend:
Service users get exactly what they need — /content/energy, not /content.
Groups, never users. Every ACL names a group.
Read on the language masters, because otherwise authors can't understand their own content.
crx:replicate is separate, because publishing is a different responsibility.
8.2 The service user mapping
ui.config/.../org.apache.sling.serviceusermapping.impl.ServiceUserMapperImpl.amended-energy.cfg.json
Format: <bundle symbolic name>:<subservice name>=[<system user>].
All three have to line up exactly — the bundle name must match your core bundle, the subservice name must match the constant in your Java, and the system user must be the one Repoinit created. A mismatch in any of them gives you a LoginException at runtime, and it's the most common cause of "works locally, fails on the server."
8.3 Using it in code
8.4 rep:glob — narrowing a grant
The empty glob is the one worth remembering. Without a glob, a grant covers the node and everything below it. With rep:glob="", it covers only the node — which is how you make a branch visible in the tree without exposing its contents.
8.5 Checking permissions in Java
Occasionally needed — for a component that behaves differently depending on what the user can do.
The principle here generalises: ask the system what a user can do rather than deducing it. That's true in code, and it's the reason impersonation beats ACL-reading when debugging.
9. Common Mistakes
10. Best Practices
Groups, always. Permissions to groups, users into groups. Even a permission for one person gets a group of one, because that's auditable.
Least privilege. Grant the narrowest path and the fewest privileges that work. Start too narrow and widen when something legitimately fails — starting broad means you never find out what was actually needed.
Deny sparingly. Every deny is a permanent constraint that can't be granted around at the same level. Document why each one exists.
Separate publishing from editing. crx:replicate in its own group. It matches how organisations actually work, and it gives you a place to hang approval later.
Everything in Repoinit. Permissions in Git, reviewed like code, identical across environments. And delete anything you created by hand while developing, or you haven't tested the script.
Test by impersonation, not by reading ACLs. And test publish as anonymous, in a private window, against publish directly.
Audit periodically. Look for per-user grants, unexplained denies, over-grants, and anything in the repository that isn't in Repoinit — that last one is how you catch environment drift.
11. Debugging Tips
Impersonation is the single most useful tool here. Tools → Security → Users → Impersonate. It shows you the system exactly as that user sees it, which beats reading ACLs and reasoning about them — the rules interact in ways that are genuinely easy to get wrong on paper.
CRXDE's Access Control tab shows the effective policy on a node, including entries inherited from ancestors. That's where you find the deny nobody remembered.
"They're in the group and it still doesn't work" — check in this order:
- Are they in another group with a deny at that level? Deny wins, and adding a group can't override it.
- Is there a deny on an ancestor closer than the grant? The closest ACL wins.
- Did they log out and back in? Group membership is resolved into the session.
- Is the grant on the right path and the right privilege?
jcr:readwhen they needrep:writeis easy to miss.
"Works on author, blank on publish":
Test in a private window against the publish instance directly, bypassing the dispatcher — that separates permissions from caching, which otherwise present identically. Then check whether anonymous has read on every path the component touches, and whether everything it references was actually activated.
LoginException on a service:
Check whether the system user actually exists in /home/users/system on the failing environment. If it doesn't, Repoinit failed — look at the startup logs. If it does, the subservice name or the mapping config is wrong.
12. Performance Notes
Permission evaluation happens on every read. Oak walks up the tree evaluating ACLs, so a deeply nested structure with many ACLs at many levels costs more than a shallow one with a few.
Keep ACLs high and few. A grant at a branch root covering everything below is cheaper and far easier to reason about than a hundred grants scattered through the tree.
Restrictions add cost. rep:glob is evaluated per node, so a complex glob on a heavily-read branch is not free. Use them where they genuinely help, not as a default.
Groups with very large memberships cost more to resolve at login. In practice this is rarely the bottleneck, but it's worth knowing if a directory sync has produced groups with tens of thousands of members.
And the honest framing: permissions are rarely the performance problem in an AEM project. Uncached pages and unindexed queries dominate. But an over-complicated ACL structure is a maintainability problem long before it's a performance one, and that's the better argument for keeping it simple.
15. Comparison Tables
Privileges you should be able to name
The three evaluation rules
rep:glob
Author vs Publish
Which resolver runs as whom
Groups worth naming
Next topic
14-AEM-as-a-Cloud-Service — your syllabus point 23: the genuine benefits over on-premise and how to argue them, what actually changes for a developer, and the honest limitations, so you can answer "why is cloud better" without it sounding like a sales pitch.
Topic 13 of the AEM Interview Preparation repository. Teaching style, energy-sector project domain.