Permission system
CPalius never asks "is this an administrator?". What it asks is: can this user perform this action? Actions are named with strings (node.post.edit.own, media.upload, system.update.manage) and these strings are called capabilities. There is no ROLE_ADMIN check in application code.
Capability names
The name has the form area.object.action[.scope]:
system.update.manage update the system
node.post.view.own see only your own posts
node.post.edit.any edit anyone's post
media.upload upload media
forum.post.report report a forum message
text_format.full_html.use use the "full HTML" text format
Scope: .own and .any
There are two scopes for records that have an owner:
.own: valid only on your own record.any: valid on any record
A capability without a scope (e.g. node.post.publish) is valid regardless of the record.
Where do capabilities come from?
If a capability is not in the registry (CapabilityRegistry), it is invalid and denied. This way a typo ("node.post.edt") does not silently grant permission, it silently denies it.
Sources:
- Core:
cp-core/config/capabilities.yaml.system.*(module, update, security, backup, cron, queue, API, webhook, fields, users, localization…),taxonomy.manage,content.*,account.*,core.*,text_format.*.use,admin.*. - Modules: each module's
Resources/config/capabilities.yamlfile. - Resources: the shorthands in
#[CpResource(capabilities: ['create', 'edit', ...])]expand to full names likevehicle.create; you do not need to write them by hand in code. - Vocabularies: taxonomy vocabularies register their own capabilities automatically.
The cp:debug command lists all capabilities the registry has found.
Roles
Roles are not in the database but in cp-content/config/sync/user.role.<id>.yaml files. The reason: roles should also be versioned like code, reviewed and moved between environments with Git.
role:
id: editor
label: 'role.label.editor'
capabilities:
- admin.dashboard.view
- node.post.view.own
- node.post.create
- node.post.edit.own
- media.upload
- text_format.basic_html.use
Roles that ship with the core:
| Role | Capabilities |
|---|---|
admin |
'*' (wildcard): all capabilities in the registry. A removed or undefined capability drops out automatically |
editor |
Studio dashboard, own posts and pages (create, edit, delete, publish), comment moderation, media, forum interaction, text formats |
member |
Registered member: forum interaction, profile, avatar, restricted text format. Does not grant content management; registering should not grant content permissions |
A broken role file (invalid YAML, missing id, missing capabilities) skips that role; it never grants extra permissions. The parsed map is cached and invalidated by the file modification time.
An unscoped capability such as forum.post.edit is treated by the voter as general (applying to every record). That is why the member role is given only forum.post.edit.own. If your module defines scoped variants, always give the scoped ones to roles.
Decision flow
Every permission question goes through a single gate: is_granted('capability', $subject). The voter (CPaliusVoter) decides in this order:
- Registry: if the capability is undefined, abstain (never permit).
- User exception: deny. A per-user
denycomes before everything and wins. - Role capabilities: does one of the user's roles contain the capability?
- User exception: grant. A per-user
grant. .owncheck: if the record names its owner throughOwnableInterface, is the owner this user?- Record-level permission: is there a permission given for this single record to the user, to a role or to everyone?
- Access event: modules can influence the decision with a hook (limited).
Per-user exception
A single capability can be granted to or taken from a user regardless of their role (UserCapabilityOverride, grant / deny). To prevent privilege escalation, these exceptions pass through a policy layer.
Record-level permission (row-level)
"This user / this role / everyone can use this capability on this single record." (EntityAccessManager). For example, when a group membership or a shared invitation changes a record's visibility, the module writes the permission at that moment. There is no bulk "rebuild" step: every permission row is correct the moment it is written.
$access->grantToUser('album', $album->getId(), 'album.view', $userId, $grantedBy);
$access->grantToRole('album', $album->getId(), 'album.view', 'member');
$access->grantToAnyone('album', $album->getId(), 'album.view');
$access->revokeAllForEntity('album', $album->getId());
In lists: permission is turned into SQL
Running the voter for every row in a 100-row list would create a serious N+1 problem. CPalius solves this with QueryScopeApplier: the user's capabilities (.any, .own, record permissions) are added directly to the query's WHERE condition.
$qb = $repo->createQueryBuilder('p');
$scope->apply($qb, 'p', 'node.post.view', 'author');
// if .any exists, no filter; if only .own exists AND p.author = :me
// if a record permission exists OR EXISTS(...)
// if none exists 1 = 0 (empty result)
The rule: if there is no permission the result is empty, never an unfiltered table.
Text format capabilities
A rich text field says which filters it passes through with a text format. Which formats a user can choose is determined by capability: text_format.restricted.use, text_format.basic_html.use, text_format.full_html.use, text_format.markdown.use. A member uses only the restricted format; an editor goes up to full HTML.
Multi-tenant (SaaS) use
In resources marked #[CpResource(multiTenant: true)], a Doctrine SQL filter adds AND tenant_id = :active_tenant to all SELECTs, and the tenant is stamped automatically on writes. Developer discipline is not relied upon; isolation is the default.
For the module author
# Resources/config/capabilities.yaml
capabilities:
- mymodule.item.view.own
- mymodule.item.view.any
- mymodule.item.create
- mymodule.item.edit.own
- mymodule.item.edit.any
- mymodule.settings.manage
#[Route('/admin/items/{id}/edit', name: 'admin_item_edit')]
#[IsGranted('mymodule.item.edit')] // if written without a scope, either .own or .any is enough
public function edit(Item $item): Response { /* ... */ }
- In the menu:
#[CpAdminMenu(capability: 'mymodule.item.view.own|mymodule.item.view.any')]:|means "or". - To add new capabilities to roles, the module installer can write to the
user.role.*.yamlfiles (Portal and Showcase do this). cp:doctorwarns if an undefined capability is left in roles.