Skip to content

Row-level access and tenants

In CPalius the question "can this user see this record?" is turned into SQL before the query runs. List screens do not ask for permission row by row; the WHERE condition already fetches only the visible rows. Three mechanisms work together.

1. .own / .any scope: QueryScopeApplier

Capabilities are built with two suffixes: node.post.edit.any (anyone's post) and node.post.edit.own (only your own post). QueryScopeApplier::apply() adds the scope to a query:

What the user has Condition added
.any No condition added (all rows)
only .own owner field = me
neither 1 = 0 (empty result)

The rule is: an unauthorized user never sees an unfiltered table. There is no result where no condition is applied; if there is no permission, the result is empty.

$qb = $repository->createQueryBuilder('p');
$this->scope->apply($qb, 'p', 'node.post.edit', ownerField: 'author');

The third parameter is the capability root (without a suffix), ownerField is the owner column.

2. Permission for a single record: EntityAccessManager

Sometimes you need "let this user edit only this record". EntityAccessManager keeps record-level permission records (grants); the capability namespace is the same, there is no parallel system:

$access->grantToUser('node', $id, 'node.post.edit', $userId);
$access->grantToRole('node', $id, 'node.post.edit', 'editor');
$access->grantToAnyone('node', $id, 'node.post.view');
$access->revokeFromUser('node', $id, 'node.post.edit', $userId);

Two properties are important:

  • There is no rebuild step. Every permission row is correct on its own the moment it is written; a module writes the permission the moment visibility changes.
  • It folds into the query. buildScopeCondition() adds the permissions to the caller's query as a correlated EXISTS subquery. QueryScopeApplier takes record permissions into account this way too: a user who has no .own capability but has permission on this record still sees the record.
No UI in the browser

The core does not offer a UI for sharing a single record with someone else; it provides only the mechanism. The Notes module is an example: it applies the same idea with its own small sharing table and a NoteAccess service.

3. Multi-tenant data: TenantFilter

tenant_id = :tenant_id is added automatically to every query of an entity carrying #[CpResource(multiTenant: true)]. This is a Doctrine SQLFilter:

  • It applies only to entities marked multiTenant: true; it does not touch others.
  • If no parameter is given, it adds no condition. The filter is off until it is enabled and a tenant id is defined; so it costs nothing on a single-tenant site.
  • The tenant condition is not rewritten in every query in your code; forgetting it is not possible.

Which one when

Need Tool
Listing "my content / everyone's" QueryScopeApplier (.own/.any)
Opening a record to a certain person or role EntityAccessManager
Data isolated per customer multiTenant: true + TenantFilter
Module-specific sharing (note, document) Your own table + an access class that turns into a query (the Notes example)

Security notes

  • Check access on every action: for a user who guesses an id, return 404 as if the record were not found (403 gives away its existence).
  • Turning scope into SQL in a list is both safer and faster than filtering with is_granted in the template; filtered rows also do not break pagination.
  • Hiding from the menu does not close access; see Menu and permissions.
Last updated: 13.10.2026