Skip to content
V
For developers
Architecture, conventions, the core and security
Security / Conditional roles

Conditional roles: owner and receiver#

Roles that cannot be assigned to a user because they apply only over one record: owner ("the record is mine") and receiver ("the record came to me"). It follows on from RBAC in the API; this chapter describes how the second role is decided and how to wire it into a new area. For operators the same is in Roles and privileges.

What it is#

Both roles carry the flag conditional = 1 in cms_system_user_roles. A grant of a conditional role allows nothing by itself — it only returns ALLOW_IF_OWNER, and the outcome is decided by the presenter's check for that role:

Role Question External path Internal path
owner is the caller the creator / sender of the record? verifyOwnership(int $userId) isOwner(?object $entity, int $userId, array $data)
receiver is the caller the one the record came to? verifyReceiver(?int $userId) isReceiver(?object $entity, ?int $userId, array $data)

The receiver may be null

For receiver $userId is optional: the receiver can also be an anonymous editor with the advert password (a capability), or a holder of a key from an email link. For owner without an identity there is nothing to own.

How it is decided#

  1. PermissionResolver::decide() returns ALLOW, ALLOW_IF_OWNER or DENY. An unconditional grant wins even when a conditional one exists next to it.
  2. On ALLOW_IF_OWNER the gate calls PermissionResolver::conditionalGrantHolds($userId, $key, $checks). For each conditional role that allows the key (conditional = 1, allowed = 1) it runs that role's check from the $checks map (key = role code). One passing is enough.
  3. A role without a check in $checks never passes — an unknown conditional role allows nothing.
  4. The checks are lazy closures: only the one whose role allows the key runs, so owner never pays for the receiver query and vice versa. An exception from a check returns to the caller from the resolver and both gates catch it as a denial.

The role codes are the constants PermissionResolver::ROLE_OWNER and ROLE_RECEIVER. The gate for the internal path is ApiPermissionGuardTrait::requireApiPermission(), for the external RbacCheckTrait::checkApiPermission().

isOwner() and isReceiver() must never be changed to return true

A conditional grant without a working check would turn from conditional to unconditional. The default implementations are fail-closed and tests guard it.

Entity interfaces#

The general basis stands on two interfaces in app/Core/Base/Shared/Shared/Models/Entities/Interfaces/. An entity that implements them has the roles evaluated without code in the presenter.

Interface Method Held today by
OwnerAwareInterface getOwnerUserId(): ?int 23 classes in the shared base entity (Comment, Rating, RatingVote, Response, Advert, Watchdog, Reservation, Cart, Order, Complaint, …)
ReceiverAwareInterface getReceiverUserId(): ?int Notification
  • getOwnerUserId() only calls an existing getter (getUserId(), getInsertUserId()); columns are not renamed. Entities with a $user association (Cart, Order, Complaint) use the trait UserAssociationOwnerTrait, which takes the ID from the reference without initialising the lazy proxy.
  • getReceiverUserId() must not trigger lazy loading or a query. The receiver is today everywhere a relation (advert → owner, thread → participant); no table has a column named receiver_id; what is uniform is the function name, not the column.
  • null means "unknown or anonymous" and the check then returns false.

The default checks:

Method What it does
RbacCheckTrait::verifyOwnership() the record ID from the request (id, for save data.id; a disagreeing pair = false), manager->find(), passes only an OwnerAwareInterface with a positive matching getOwnerUserId(); an INSERT without ID, an anonymous caller and an entity without the interface = false
RbacCheckTrait::verifyReceiver() the same over ReceiverAwareInterface, without the key from the URL
ApiPermissionGuardTrait::isOwner() / isReceiver() fail-closed; an entity with the interface and a matching positive ID passes
OwnershipCheckTrait::isOwner() getOwnerUserId(), otherwise the original getUserId()

All 24 live owner keys belong to 11 presenters and each defines verifyOwnership() in its own file — the default check does not touch them (a test guards that).

Procedure: your own owner check in a presenter#

When the default check is not enough (the owner is in another column, the record also belongs to a parent), the presenter gets OwnershipCheckTrait and overrides both methods separately — each belongs to one path:

Method Path How it knows which record is meant
verifyOwnership(int $userId) external HTTP from the request through the trait helpers
isOwner(?object $entity, int $userId, array $data) internal (bridge) gets the entity already loaded from the DB; when creating, null and the payload in $data
Helper (only for verifyOwnership()) Returns
getOwnedEntityId() data.id of the edited record, null = creating
isOwnershipInsert() true when data.id is missing
getOwnedRequestId() the id parameter (delete, read); an array of ids = null
getOwnedParentId('order') the parent id from data in every shape of a ToOne relation
  1. Put the trait on the concrete presenter, never on Api\BasePresenter. There RbacCheckTrait already declares verifyOwnership() and two traits with the same method on one class are a PHP fatal error; on a descendant it legitimately overrides the parent method.
  2. Decide with a query in the Api manager (isOwnedBy($id, $userId), canUserWrite(…)), not with SQL in the presenter.
  3. isOwner() must not call the helpers. All of them go through getRequestParameters(), and on the internal path getRequest() throws Nette\InvalidStateException because Presenter::run() never ran. The exception ends in the guard as a denial.
  4. Add the presenter to OwnershipFallbackCoverageTest::FALLBACK_REVIEWED with a reason: fallback = the trait's generic isOwner() answers correctly, custom = the presenter has its own.

The trait's generic isOwner() takes the owner from getUserId() with a > 0 edge

An entity with OwnerAwareInterface gives getOwnerUserId(), otherwise getUserId() is called; an entity without it (Advert has insertUserId) always yields false and the presenter must override. The > 0 edge is required: the watchdog and advert following have userId as an int defaulting to 0 and an anonymous record is allowed, so without it every anonymous visitor would "own" an anonymous record.

Procedure: wire the receiver into a new area#

  1. Decide who the receiver is and where that fact lives. If it is a column or association of the entity, implement ReceiverAwareInterface on the entity (in the shared base entity so it applies in all layers). If not, override isReceiver() and verifyReceiver() in the Api presenter, decide by a query through the manager and respect the rules below.
  2. Write the grant to the database. The key :Api:<Module>:<Entity>:<action> has allowed = 1 for the role receiver; switch it off for guest and registrated, otherwise the condition never takes hold (an unconditional grant wins). The role receiver must have a row in the relation for every key (allowed = 0 is the default). Pattern: sql/2026-10-02_12_response_receiver_role.sql.
  3. An action for an external client goes into action*(); an internal action (for the site only) is left without action*() — then nobody calls it from outside. Both are guarded by FrontApiSurfacePolicyTest::testInternalOnlyKeysHaveNoActionCounterpart().
  4. Check the support in the matrix. ConditionalRoleSupport::supports($role, $key) must return true, otherwise the administration does not offer the role on the row (see below).
  5. Write the test from the contract, not from the implementation: receiver ✓, sender ✗, stranger ✗, anonymous ✗, record without a receiver ✗; patterns ResponseReceiverTest and ConditionalRoleGateTest.
  6. Verify both paths: external (HTTP with a token) and internal (from the administration or the site).

Rules#

Rule Why
The record is read from the DB by id, never from the payload a check over a value sent by the same request verifies nothing
The identity comes from the JWT, a key or capability from an explicit call parameter a userId from the request would be chosen by the caller
receiver never on updateColumns the grant would let any column be written before the column check is reached
Writing one column for the receiver goes through #[ConditionallyUpdatable(check: …)] it calls the same receiver check and lets only that column through
A key from a link only on the internal path read_key is not passed from outside; see Link with a key
An answer for a non-member = the answer for a non-existent record an action must not be an oracle on a record's existence

What in the system uses receiver#

Area How the receiver is determined Grant
Response to an advert (Bazaar, Comcat) — ResponseReceiverTrait privileged role ∨ the advert's owner (advert.insertUserId from the DB) ∨ the advert password (capability) ∨ a valid read_key (internal only); not the sender Response:get, Response:markReadByKey
Reply to a rating (Rating:saveReply) the owner of the rated profile (canRespondTo against the DB); Rating:save is owner (the author) Rating:saveReply
Private messages — MessageThreadRolesTrait roles are derived from the authors of individual messages, see Private messages myThreadMessages, myThreadReply, myThreadRead, myThreadHide, myThreadParticipants
Notifications Notification implements ReceiverAwareInterface; the matrix offers the role, no grant is switched on —

A response deliberately does not implement the interface: the receiver is advert.insertUserId, which is not reliably available from the entity without lazy loading, so the manager decides by SELECT. isOwner() and verifyOwnership() on a response (sender or the advert's owner) stayed unchanged.

The permission matrix in the administration#

The matrix (a privilege group) takes the conditional roles from the DB and offers them only where they have an effect.

Part What it does
ConditionalRoleSupport (app/Core/Security/Rbac/) finds out by reflection whether the key's presenter can evaluate the role: owner = the presenter overrides verifyOwnership() ∨ the entity is OwnerAwareInterface; receiver = the entity is ReceiverAwareInterface ∨ the presenter overrides isReceiver(). The presenter's entity = the constant ENTITY_CLASS, otherwise App\UI\Api\<Module>\Models\Entities\<Presenter>. An unknown conditional role counts as supported
OwnershipOverrideChecker a thin facade over ConditionalRoleSupport for the administration
PrivilegeGroup/edit.latte the conditional-role checkbox is disabled outside :Api rows and on :Api rows without support; an already stored dead grant stays ticked with a warning icon and can be unticked
UserRolePrivilegeMatrixManager::saveGroupPrivileges() before writing it refuses a new grant without support (CONDITIONAL_ROLE_NOT_SUPPORTED:<role>:<key>); a grant already in the DB passes silently. Outside the :Api branch the conditional role is forced to 0 for all conditional roles
RoleLabelTrait the single place for a role's human name and description: admin.system.rbacRoles.<code>.{name,description}; a role without a translation (an operator's own role) keeps its DB name

The checkbox is drawn through getControlPart()

getControl() on a Multiplier returns an unnamed <label> wrapper and the attribute disabled was never rendered on it. The earlier "disabled" cells outside :Api were therefore actually active and only the server protected them. Now getControlPart() (the <input> itself) is drawn.

Support is judged per presenter, not per action

The grant receiver can be switched on for an action where the presenter does not actually use it (say Response:getAll). It opens nothing — a check without a record denies — it only confuses.

A new conditional role needs: a row in cms_system_user_roles (conditional = 1, assignable = 0), rows allowed = 0 for all existing keys, translations admin.system.rbacRoles.<code>, a constant in PermissionResolver, a check in the conditionalGrantHolds() map in both gates, and a branch in ConditionalRoleSupport.

Tests#

Test What it guards
ConditionalRoleGateTest the gate of both roles; the surface of receiver grants
ConditionalRoleSupportTest the support reflection
ConditionalGrantGuardTest the server refuses a new grant without support, an existing one passes
DefaultOwnerVerificationTest, OwnerAwareEntityTest the default verifyOwnership(), getOwnerUserId() matching the getter on every class
ResponseReceiverTest, RatingSaveReplyReceiverGateTest, MessageThreadRolesTest the domains
RbacRoleTranslationsTest role names in three languages
OwnershipFallbackCoverageTest every presenter with an owner grant has a check

Where to look#

I want Where
the core of the decision app/Core/Security/Rbac/PermissionResolver.php (conditionalGrantHolds)
the gates app/Core/Traits/Api/Presenters/{RbacCheckTrait,ApiPermissionGuardTrait}.php
entity interfaces app/Core/Base/Shared/Shared/Models/Entities/Interfaces/
responses app/Core/Traits/Api/Presenters/ResponseReceiverTrait.php
message threads app/Core/Traits/Api/Presenters/MessageThreadRolesTrait.php
the matrix in the administration app/Core/Security/Rbac/ConditionalRoleSupport.php, UserRolePrivilegeMatrixManager

Cookie settings