For AI agents: the complete documentation index is available at https://nhanbe.id.vn/llms.txt, the full documentation bundle is available at https://nhanbe.id.vn/llms-full.txt, and this page is available as Markdown at https://nhanbe.id.vn/aem/advanced/users-groups-and-permissions.md.

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.

/home/
├── users/
│   └── e/
│       └── energy-author-01     ← rep:User
└── groups/
    └── e/
        └── energy-de-authors    ← rep:Group

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-authors and 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.

PrivilegeWhat it allows
jcr:readRead nodes and properties
jcr:modifyPropertiesChange property values
jcr:addChildNodesCreate child nodes
jcr:removeNodeDelete a node
jcr:removeChildNodesDelete children
jcr:readAccessControlSee the ACLs
jcr:modifyAccessControlChange the ACLs
jcr:versionManagementCreate and restore versions
jcr:lockManagementLock and unlock
jcr:nodeTypeManagementChange node types
crx:replicateActivate and deactivate — AEM-specific
rep:writeAggregate — modifyProperties + addChildNodes + removeNode + removeChildNodes
jcr:allEverything

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:

/content/energy/de/
└── rep:policy                        (rep:ACL)
     ├── allow                        (rep:GrantACE)
     │    ├── rep:principalName = "energy-de-authors"
     │    └── rep:privileges = [jcr:read, rep:write]
     └── deny                         (rep:DenyACE)
          ├── rep:principalName = "energy-de-authors"
          └── rep:privileges = [crx:replicate]

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.

rep:glob = "*/jcr:content*"
rep:glob valueApplies to
(absent)The node and everything below it
"" (empty string)The node itself only — no descendants
*Everything below
*/jcr:content*Any jcr:content node and below it
/products*Paths starting /products under this node

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.

/content/energy              allow  read  →  energy-de-authors
    └── de                   (inherits: read allowed)
        └── internal         deny   read  →  energy-de-authors
            └── pricing      (inherits from /internal: read DENIED)

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.

User is in BOTH:
    energy-de-authors     →  allow read on /content/energy/de
    energy-restricted     →  deny  read on /content/energy/de

Result: DENIED

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.

GroupWhat it's for
administratorsFull access — effectively jcr:all on everything
contributorsThe base group authoring groups build on
dam-usersAccess to the DAM
workflow-usersParticipate in workflows
user-administratorsManage users and groups
tag-administratorsManage the taxonomy
everyoneEvery user is implicitly a member — including anonymous

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:

AuthorPublish
UserAuthenticated, in groupsanonymous
Can read /appsUsually yesNo — the allowProxy story, file 04
Can read unpublished contentYesNo — it isn't there
Can read restricted branchesDepends on groupsNo

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:

Your code asks for a resolver by SUBSERVICE NAME
        ↓
ServiceUserMapper config maps  bundle:subservice → system user
        ↓
The resolver runs as that system user, with its ACLs

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.

create service user energy-content-reader with path system/cq:services/energy

set ACL for energy-content-reader
    allow jcr:read on /content/energy
    allow jcr:read on /conf/energy
end

create group energy-de-authors

set ACL for energy-de-authors
    allow jcr:read,rep:write on /content/energy/de
    allow jcr:read on /content/energy/language-masters
    deny  crx:replicate on /content/energy/de
end

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:

contributors                     base authoring capability
    └── energy-authors           read/write on /content/energy
            ├── energy-de-authors    + their own country branch
            ├── energy-us-authors    + their own country branch
            └── energy-publishers    + crx:replicate

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:

Can energy-de-authors read /content/energy/de/internal/pricing?

  /content/energy/de/internal/pricing   → any ACL? no, keep going
  /content/energy/de/internal           → DENY jcr:read  →  DENIED. Stop.
  /content/energy/de                    (never reached)
  /content/energy                       (never reached)

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:

WhereRuns asConsequence
request.getResourceResolver()The current userReads respect their permissions automatically
A Sling Model reading contentThe same resolverA component can silently render nothing on publish
A service-user resolverThe service userReads past the visitor — deliberately
A path-bound servletWhoever calls itNo ACL check at all — file 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:

# ---------------------------------------------------------------
# SERVICE USERS -- for code, not people.
# Least privilege: exactly what the code needs, nothing more.
# ---------------------------------------------------------------

create service user energy-content-reader with path system/cq:services/energy

set ACL for energy-content-reader
    # Read only, and only the branches it actually touches.
    # NOT /content -- that would grant far more than needed.
    allow jcr:read on /content/energy
    allow jcr:read on /conf/energy
end

create service user energy-report-writer with path system/cq:services/energy

set ACL for energy-report-writer
    allow jcr:read on /content/energy
    # Write ONLY where it writes
    allow jcr:read,rep:write on /var/energy/reports
end

# ---------------------------------------------------------------
# GROUPS -- permissions go here, never to individual users
# ---------------------------------------------------------------

create group energy-authors
add energy-authors to group contributor

set ACL for energy-authors
    # Everyone authoring can read the whole site
    allow jcr:read on /content/energy
    # And the shared asset library
    allow jcr:read on /content/dam/energy
end

create group energy-de-authors
add energy-de-authors to group energy-authors

set ACL for energy-de-authors
    # Write ONLY on their own country branch
    allow jcr:read,rep:write on /content/energy/de

    # READ on the language masters matters more than people expect:
    # without it, an author can't look at the source to understand
    # why a page changed after a rollout (file 12).
    allow jcr:read on /content/energy/language-masters

    # Upload into their own asset folder only
    allow jcr:read,rep:write on /content/dam/energy/de
end

# ---------------------------------------------------------------
# PUBLISHING -- SEPARATE, because editing and publishing are
# different responsibilities with different accountability.
# ---------------------------------------------------------------

create group energy-publishers

set ACL for energy-publishers
    allow jcr:read on /content/energy
    allow crx:replicate on /content/energy
    allow crx:replicate on /content/dam/energy
end

# ---------------------------------------------------------------
# A NARROW EXCEPTION.
#
# Note this is a DENY, and denies are blunt: because deny wins at
# the same level, nobody can be granted access here by adding them
# to another group. That's the intent -- but it means this line is
# a permanent constraint on the model and needs a documented reason.
# ---------------------------------------------------------------

set ACL for energy-authors
    deny jcr:read on /content/energy/internal
end

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

{
  "user.mapping": [
    "com.energy.core:content-reader=[energy-content-reader]",
    "com.energy.core:report-writer=[energy-report-writer]"
  ]
}

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

package com.energy.core.services.impl;

import org.apache.sling.api.resource.LoginException;
import org.apache.sling.api.resource.ResourceResolver;
import org.apache.sling.api.resource.ResourceResolverFactory;
import org.osgi.service.component.annotations.Component;
import org.osgi.service.component.annotations.Reference;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;

import java.util.Collections;
import java.util.Map;

@Component(service = ProductLookupService.class)
public class ProductLookupServiceImpl implements ProductLookupService {

    private static final Logger LOG =
            LoggerFactory.getLogger(ProductLookupServiceImpl.class);

    // Must match the ServiceUserMapper config EXACTLY.
    private static final String SUBSERVICE = "content-reader";

    @Reference
    private ResourceResolverFactory resolverFactory;

    @Override
    public String readTitle(String path) {

        Map<String, Object> params =
                Collections.singletonMap(ResourceResolverFactory.SUBSERVICE, SUBSERVICE);

        // try-with-resources: THIS resolver is one we OPENED, so we
        // close it. Contrast with request.getResourceResolver(),
        // which Sling owns and we must NOT close (file 07).
        try (ResourceResolver resolver =
                     resolverFactory.getServiceResourceResolver(params)) {

            // Reads through this resolver run as the SERVICE USER,
            // with its ACLs -- deliberately reading past whatever the
            // visitor could see.
            return java.util.Optional
                    .ofNullable(resolver.getResource(path + "/jcr:content"))
                    .map(r -> r.getValueMap().get("jcr:title", String.class))
                    .orElse(null);

        } catch (LoginException e) {
            // Three usual causes:
            //   1. The subservice name doesn't match the mapping config
            //   2. The system user doesn't exist (Repoinit failed)
            //   3. The mapping config didn't deploy to this run mode
            //
            // It typically works LOCALLY because someone created the
            // user by hand while developing, and fails on a fresh
            // environment where only Repoinit ran.
            LOG.error("Could not obtain a resolver for subservice '{}'", SUBSERVICE, e);
            return null;
        }
    }
}

8.4 rep:glob — narrowing a grant

set ACL for energy-de-authors

    # The node itself ONLY -- no descendants.
    # Lets them see that the folder exists without seeing inside it.
    allow jcr:read on /content/energy/shared restriction(rep:glob,"")

    # Only page content nodes below this point
    allow jcr:read,rep:write on /content/energy/de restriction(rep:glob,"*/jcr:content*")

end

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.

import org.apache.jackrabbit.api.security.user.UserManager;
import org.apache.sling.api.resource.ResourceResolver;

import javax.jcr.Session;

public boolean canPublish(ResourceResolver resolver, String path) {
    Session session = resolver.adaptTo(Session.class);
    if (session == null) {
        return false;
    }
    try {
        // Ask the session what it's actually permitted to do, rather
        // than reading the ACLs and reasoning about them. The
        // evaluation rules interact in ways that are easy to get
        // wrong on paper.
        return session.hasPermission(path, Session.ACTION_SET_PROPERTY);
    } catch (Exception e) {
        return false;
    }
}

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

The mistakeWhat actually happensThe fix
Granting permissions to individual usersInvisible debt; breaks when they leaveAlways groups
Using deny where a narrower grant would doCan't be overridden at the same level; confusing failures far belowStructure grants; deny only for genuine exceptions
Denying on everyoneDenies essentially everybody, and you can't grant back at that levelGrant deliberately instead
Granting jcr:all or using administrators for codeNo boundary; a bug becomes a repository-wide problemLeast privilege, per path
Service user granted /contentFar more than the code needsGrant the specific branch
Not closing a service resolverSession leak → memory exhaustiontry-with-resources
Closing request.getResourceResolver()Breaks the rest of the requestOnly close what you opened
Creating users by hand during developmentWorks locally, fails everywhere elseRepoinit, then delete the manual one to prove it
Repoinit failure unnoticedThe user silently doesn't existCheck startup logs
Subservice name not matching the mappingLoginException at runtimeBundle, subservice and user must all line up
Forgetting authors need read on language mastersThey can't see why a rollout changed somethingGrant read on the blueprint branch
Publishing rights bundled with editingPages go live before reviewSeparate crx:replicate
Assuming author and publish behave the same"Works on author, blank on publish"Test as anonymous against publish directly
Reasoning about ACLs instead of testingThe rules interact in non-obvious waysImpersonate
Permissions clicked in per environmentDrift — works on stage, fails on prodEverything through Repoinit
Forgetting rep:glob semanticsA grant covers far more or less than intendedAbsent = everything below; "" = the node only

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:

  1. Are they in another group with a deny at that level? Deny wins, and adding a group can't override it.
  2. Is there a deny on an ancestor closer than the grant? The closest ACL wins.
  3. Did they log out and back in? Group membership is resolved into the session.
  4. Is the grant on the right path and the right privilege? jcr:read when they need rep:write is 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.

ToolAnswers
Tools → Security → Users → ImpersonateWhat can this user actually do
CRXDE → Access Control tabThe effective policy on a node, including inherited
Tools → Security → PermissionsGrant and review permissions
/home/users/system in CRXDEDoes the service user exist here
Startup logsRepoinit failures
Private window on publish directlyAnonymous behaviour, without the dispatcher

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

PrivilegeAllows
jcr:readRead nodes and properties
jcr:modifyPropertiesChange values
jcr:addChildNodesCreate children
jcr:removeNode / jcr:removeChildNodesDelete
jcr:readAccessControl / jcr:modifyAccessControlSee / change ACLs
jcr:versionManagementVersions
crx:replicateActivate and deactivate — AEM-specific
rep:writeAggregate of the four write privileges
jcr:allEverything

The three evaluation rules

RuleMeaning
1. Deny by defaultNothing unless granted
2. Closest winsA deeper ACL overrides an ancestor
3. Deny wins at the same levelConflicting group grants → denied

rep:glob

ValueApplies to
(absent)The node and everything below
""The node itself only
*Everything below
*/jcr:content*Page content nodes

Author vs Publish

AuthorPublish
UserAuthenticatedanonymous
Reads /appsUsually yesNo
Reads unpublished contentYesIt isn't there
Symptom when it differs—"Works on author, blank on publish"

Which resolver runs as whom

SourceRuns asClose it?
request.getResourceResolver()The current userNo — Sling owns it
getServiceResourceResolver(...)The service userYes — you opened it
getAdministrativeResourceResolver()EverythingDeprecated, blocked on Cloud Service

Groups worth naming

GroupFor
administratorsFull access
contributorsBase authoring
dam-usersAssets
workflow-usersWorkflow participation
everyoneEveryone, implicitly — including anonymous

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.