Skip to content

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:

  1. 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.*.
  2. Modules: each module's Resources/config/capabilities.yaml file.
  3. Resources: the shorthands in #[CpResource(capabilities: ['create', 'edit', ...])] expand to full names like vehicle.create; you do not need to write them by hand in code.
  4. 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.

Be careful when granting an unscoped capability

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:

  1. Registry: if the capability is undefined, abstain (never permit).
  2. User exception: deny. A per-user deny comes before everything and wins.
  3. Role capabilities: does one of the user's roles contain the capability?
  4. User exception: grant. A per-user grant.
  5. .own check: if the record names its owner through OwnableInterface, is the owner this user?
  6. Record-level permission: is there a permission given for this single record to the user, to a role or to everyone?
  7. 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.*.yaml files (Portal and Showcase do this).
  • cp:doctor warns if an undefined capability is left in roles.
Last updated: 13.10.2026