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#
PermissionResolver::decide()returnsALLOW,ALLOW_IF_OWNERorDENY. An unconditional grant wins even when a conditional one exists next to it.- On
ALLOW_IF_OWNERthe gate callsPermissionResolver::conditionalGrantHolds($userId, $key, $checks). For each conditional role that allows the key (conditional = 1,allowed = 1) it runs that role's check from the$checksmap (key = role code). One passing is enough. - A role without a check in
$checksnever passes — an unknown conditional role allows nothing. - The checks are lazy closures: only the one whose role allows the key runs, so
ownernever 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$userassociation (Cart, Order, Complaint) use the traitUserAssociationOwnerTrait, 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 namedreceiver_id; what is uniform is the function name, not the column.nullmeans "unknown or anonymous" and the check then returnsfalse.
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 |
- Put the trait on the concrete presenter, never on
Api\BasePresenter. ThereRbacCheckTraitalready declaresverifyOwnership()and two traits with the same method on one class are a PHP fatal error; on a descendant it legitimately overrides the parent method. - Decide with a query in the Api manager (
isOwnedBy($id, $userId),canUserWrite(…)), not with SQL in the presenter. isOwner()must not call the helpers. All of them go throughgetRequestParameters(), and on the internal pathgetRequest()throwsNette\InvalidStateExceptionbecausePresenter::run()never ran. The exception ends in the guard as a denial.- Add the presenter to
OwnershipFallbackCoverageTest::FALLBACK_REVIEWEDwith a reason:fallback= the trait's genericisOwner()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#
- Decide who the receiver is and where that fact lives. If it is a column or association
of the entity, implement
ReceiverAwareInterfaceon the entity (in the shared base entity so it applies in all layers). If not, overrideisReceiver()andverifyReceiver()in the Api presenter, decide by a query through the manager and respect the rules below. - Write the grant to the database. The key
:Api:<Module>:<Entity>:<action>hasallowed = 1for the rolereceiver; switch it off forguestandregistrated, otherwise the condition never takes hold (an unconditional grant wins). The rolereceivermust have a row in the relation for every key (allowed = 0is the default). Pattern:sql/2026-10-02_12_response_receiver_role.sql. - An action for an external client goes into
action*(); an internal action (for the site only) is left withoutaction*()— then nobody calls it from outside. Both are guarded byFrontApiSurfacePolicyTest::testInternalOnlyKeysHaveNoActionCounterpart(). - Check the support in the matrix.
ConditionalRoleSupport::supports($role, $key)must returntrue, otherwise the administration does not offer the role on the row (see below). - Write the test from the contract, not from the implementation: receiver ✓, sender ✗,
stranger ✗, anonymous ✗, record without a receiver ✗; patterns
ResponseReceiverTestandConditionalRoleGateTest. - 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 |