RBAC in the API#
The decision whether this caller may perform this action on this record —
and which fields of it they will see at all. The core is app/Core/Security/Rbac/, the
guard app/Core/Traits/Api/Presenters/.
Four layers on top of each other#
This is not one check but four independent ones. Each can stop the request on its own.
| Layer | Asks | Where |
|---|---|---|
| 1. Identity | who is calling — the user from the JWT, or a guest | JwtAuthTrait::checkJwtAuth() |
| 2. Action | does the caller hold a privilege for this key | PermissionResolver::decide() |
| 3. Ownership | is that particular record theirs | isOwner() in the guard |
| 4. Field audience | which fields may reach the response | FieldAccess + FieldAudienceContext |
Layer 2 alone is not enough: an ALLOW_IF_OWNER decision only becomes valid after
layer 3.
The application key is not an RBAC layer
On the external path checkClientKey() runs before layer 1 — "may this application call
the Api?". It adds no rights; a client may only narrow them with the
role ceiling. Details in
Authentication → Application key.
The decision has three values, not two#
PermissionResolver is a pure database lookup — no tree, no inheritance, no
fallback:
| Result | When it arises |
|---|---|
ALLOW |
at least one of the caller's roles has allowed = 1 and conditional = 0 |
ALLOW_IF_OWNER |
a grant exists, but only from a role with conditional = 1 (today owner and receiver, see Conditional roles) |
DENY |
no role grants the key — or the key does not exist in the database at all |
An unconditional grant wins even when a conditional one sits next to it.
There is more than one conditional role
conditionalGrantHolds() calls the check of each conditional role that allows the key:
owner → verifyOwnership() / isOwner(), receiver → verifyReceiver() /
isReceiver(). An unknown conditional role does not pass. In detail
Conditional roles: owner and receiver.
A missing key = DENY, not “unprotected”
A key that does not exist in cms_system_user_role_privileges behaves exactly like
a denied one. That is fail-closed and deliberate, but it means a new Api action
without a database row simply does not work — and it will not report a missing
privilege, only "Permission denied".
Who is privileged and who is an owner is a database row, not a name in the code
The cms_system_user_roles.privileged and .conditional columns decide. A hardcoded
admin must never appear anywhere in the decision path — adding a role with
privileged = 1 (a moderator, say) would then require a sweep through the whole
codebase.
Privilege groups have no place in the decision
cms_system_privilege_groups is a presentation-only concept for the administration
UI — a family of actions, not a key. It must not appear in the resolver's SQL; a test
that reads that class's own source enforces it.
An empty identity is a guest#
JwtPayload::getUserId() returns an int defaulting to 0, not null. The resolver therefore
turns 0 and negative ids into null in rowsForKey() (normalizeIdentity()) before it picks
the query branch:
userId |
Branch | Roles it sees |
|---|---|---|
| a positive id | SELECT_FOR_AUTHENTICATED |
implicit = 'authenticated' + roles assigned to the user |
null, 0, negative |
SELECT_FOR_ANONYMOUS |
implicit = 'anonymous' + all conditional = 1 |
The anonymous branch sees conditional roles too because of the
capability: an anonymous editor holding the
advert password (userId = 0) must reach the same isOwner() question as a signed-in owner. It
allows nothing by itself — a conditional row gives at most ALLOW_IF_OWNER, and without a token
(userId is null) the guard never calls the owner check.
Normalization belongs in the resolver, not with the caller
Without it a token with userId = 0 would take the signed-in branch: it would lose the
guest grants and at the same time get into the owner branch. Whether that turned into an
escalation would then depend only on every isOwner() implementation checking > 0 on its
own. The callers (RbacCheckTrait, ApiPermissionGuardTrait) pass getUserId() unchanged —
a new caller must neither bypass the normalization nor repeat it differently.
The application role ceiling#
An external Api client (application key) has a list of allowed roles
(cms_system_api_client_roles). PermissionResolver::restrictToClientRoles() drops, from the
rows of every key, the roles the client does not allow — before anything is decided. The
request's client is held by ApiClientContext; without a client (website, administration,
cron, #[NoClientKey]) there is no ceiling. Only checkClientKey() in startup() sets it, but
it applies to every decision of the request — including a nested internal call the Api
presenter makes through the bridge during the external request; it is still the same caller.
| What the ceiling affects | How |
|---|---|
explicit roles (admin…) |
an admin through a client without admin is decided as a regular user |
implicit roles (guest, registrated) |
a client without guest passes no action without a token and no #[PublicAction] (sign-in, refresh) |
conditional roles (owner, receiver) |
a client without owner = the user cannot edit even their own record through it |
| field tier and personal data | FieldAccess Privileged, #[ExternalAdminOnly] and PersonalDataPolicy all run off isGrantedByPrivilegedRole(), so the cut applies there too |
The filter sits in rowsForKey(), the single funnel of all public methods (decide(),
grantingRoleCodes(), isGrantedByPrivilegedRole()). The branch cache keeps the unrestricted
rows; the ceiling is a cheap filter on read. hasKey() ignores the ceiling — it asks about the
policy, not about the caller.
Default roles of a new client: the non-privileged roles that are implicit or conditional
(ApiClientManager::isDefaultRole()), today guest, registrated, owner, receiver.
Two independent switches#
This is the most important thing in the whole chapter. The external and the internal path have a mode each, because their blast radius is not comparable.
apiRbac:
mode: enforce # the EXTERNAL HTTP path
internalMode: enforce # the INTERNAL path = ApiBridge (admin, front end, CLI)
| Value | Behaviour |
|---|---|
off |
the gate does not run at all — no extra queries, no log lines |
log |
evaluates and writes API_RBAC … into the auth channel, denies nothing |
enforce |
DENY → HTTP 403 (externally) / Result::failure(PERMISSION_DENIED) (internally) |
| Switch | Governs | Where it is evaluated |
|---|---|---|
mode |
external HTTP requests | RbacCheckTrait::checkApiPermission() in startup() |
internalMode |
the administration, the front end and CLI via ApiBridge |
ApiPermissionGuardTrait::requireApiPermission() in internalAction*() |
mode does NOT govern the administration or the front end
On the internal path startup() never runs at all — ApiBridge only builds the
presenter and calls internalAction*(). Anyone switching mode: off expecting to unlock
the administration changes nothing. The kill switch for the administration and the
front end is internalMode.
A check written only in the Api presenter does not apply to both paths equally
The guard in internalAction*() is the single place every path to the data goes through.
A check in startup() protects only external HTTP. A rule that must always hold
belongs in the guard or in the manager — not in action*().
After changing either switch, delete temp/cache
It is a compile-time parameter. Without clearing it the old compiled DI container is kept and it looks as if the switch did not work.
A typo in the value falls back to a neutral mode, not to enforce
An invalid mode falls back to log, an invalid internalMode to off — and the
fact that the configuration is broken is loudly written into the auth channel as
API_RBAC_CONFIG invalid. The internal fallback is off on purpose: log means one
RBAC lookup on every bridge call, and a single anonymous page view evaluates
dozens of them.
The guard's contract#
The whole body of every internalAction<Action>() is wrapped in operation() (the operation
boundary, see Identity map and operation boundary), and the guard call
must be the first statement inside it, before any database query:
public function internalActionGetAll(array $filters = [], …): array
{
return $this->operation(__FUNCTION__, function () use ($filters, …) {
if (($denied = $this->requireApiPermission('getAll')) !== null) { return $denied; }
…
});
}
One internalAction*() must not call another internalAction*() (through $this, self,
static or parent); ApiOperationBoundaryTest checks both the wrap and this ban.
null means "you may continue". An array means you must return it immediately as
your result — on the internal path there is nowhere to send a 403.
On the external path the decision is evaluated twice (in startup() and in the
method) and that is correct: the second call is just an isset() over the map loaded by
the first, and anyone bypassing startup() runs into the guard in the method.
The action is written into the guard as a LITERAL
On the internal path getAction() is an empty string and getName() is null — a
key derived from them would come out as '::'. The guard derives the presenter from
static::class; the action has to arrive as a parameter. Deriving it from
debug_backtrace() is forbidden.
The transport is recognised from framework state, never from data
$meta['__external'] can be sent over the wire. If decisions were based on it, the
caller could choose which rules applied to them.
Procedure: adding a new Api action#
- Write
internalAction<Action>(), wrap its body inoperation(__FUNCTION__, …)and putrequireApiPermission('<action>')on the first line inside it. - Create the key in the database in the form
:Api:<Module>:<Entity>:<action>and assign it to the roles that should hold it. Copy the keys from the nearest sibling, not from memory:
- Mark a public action
#[PublicAction]— without the attribute it is denied in enforcing mode. There are 17 of them today. - Mark nothing because of the application key — the key is verified the same way for
every external action and rights are still decided by roles. Only an action called by a
third party that cannot send our key (a gateway, aggregator or provider callback) gets
#[NoClientKey]and an entry with a reason intests/Unit/Security/ApiClientKeyExemptionTest.php. - When an action should work for the owner only, pass the guard a lazy entity
loader and implement
isOwner(). - Verify both paths — external (HTTP with a token) and internal (through the administration or the front end).
The keys are named differently in the Api and in the administration
| Branch | Shape |
|---|---|
| Api | :Api:<Module>:<Entity>:{get, getAll, getBy, save, updateColumns, delete, deleteAll} |
| Admin | :Admin:<Module>:<Entity>:{show, add, edit, del} |
When an admin-style key is created for the Api (:Api:System:Unit:show), the
resolver fail-closed rejects getAll and the admin listing is empty with no error
at all — "Permission denied" stays inside ApiBridge and never reaches the page.
It looks like broken hydration or missing data. You can verify it by calling the
bridge directly: $bridge->call('api/<module>/<entity>/get-all', []).
A presenter outside the shape App\UI\Api\<Module>\Presenters\<Name>Presenter has no key
The guard builds the key from the class name (resolveApiPresenterName()) and the module
may be only one segment. A presenter in a module subfolder or outside Presenters\ throws
LogicException; the guard logs it as API_RBAC_ERROR … key=(unresolved):<action> and in
enforcing mode denies the action. The guard deliberately does not add more segments: the
Nette mapping Api\*\ turns multi-level names into a different class, so it would build a
key that is not in the DB.
Which actions a presenter gets
CrudTrait combines get, getBy, getAll, save, updateColumns, delete and
deleteAll. Tree actions (TreeTrait) and counters (RegisterEventTrait) are added
separately, only where they make sense — each of them needs its own key. The exception
is reordering children (reorder-children), which shares the moveSubtreeTo key, see below.
Action literal and a shared key#
The literal in the guard is the method name without the internalAction prefix, with a
lower-case first letter: internalActionUpdateColumns() → updateColumns. It is the inverse of the rule ApiBridge::internalCall() uses to build the
method name ('internalAction' . ucfirst($action)). Any other form ('update-columns',
'UpdateColumns') gives a key that is not in the database, and the action ends in a silent
denial that looks like "no permission".
| Method | What the guard may contain | Example |
|---|---|---|
public internalAction*() |
only the method name; a shared key only with an entry in ALIASED_GUARD_ACTIONS in the test |
ProductCategoryPresenter::internalActionProductCounts() → getAll |
protected/private internalAction*() (internal bridge only) |
any readable literal, typically the key of a related action | TreeTrait::internalActionReorderChildren() → moveSubtreeTo |
| any | never a dynamic argument | — |
A non-public method has no external route, so no key belongs to it by name. The bridge calls
it by reflection, which is why ApiPermissionCoverageTest scans it too — it must have a guard.
Procedure: a bridge-only action that takes over an existing policy#
- Write the method as
protectedand do not add anaction*()for it; without one, Nette rejects it from outside with a 404. - Put the literal of the key whose policy fits the action into the guard — not a new key that would need a seed for every presenter.
- Run
ApiPermissionCoverageTest.
A shared key also shares the roles
Whoever has moveSubtreeTo may also reorder children; you cannot revoke one without the
other. When the action needs a different policy than its sibling, a shared key is a mistake
and it needs its own.
Creating and editing: insert and update instead of save#
One key carries one policy, but creating and editing are two different sentences about the same
role: an advert may be created by anyone, even anonymously, but edited only by the owner or an
administrator. SaveTrait::refineSaveAction() therefore refines the save action:
| Payload | Key, when it is in the policy | Otherwise |
|---|---|---|
without data.id (OwnershipPayload::entityId() === null) |
:Api:<Module>:<Entity>:insert |
:Api:<Module>:<Entity>:save |
with data.id |
:Api:<Module>:<Entity>:update |
:Api:<Module>:<Entity>:save |
Creating is recognised by the same implementation as the $owned closure and the external
verifyOwnership(), so the branches cannot diverge. Whether the key exists is answered by
PermissionResolver::hasKey() from the per-request cache the first decide() filled — it costs
no extra query.
Procedure: refining the action of an entity#
- Create the
…:insert(or…:update) key and assign it to roles — for a model,SELECT `key` FROM cms_system_user_role_privileges WHERE `key` LIKE '%:insert';. - Keep the
…:savekey: it stays the fallback for the other branch and for the time between deploying the code and running the SQL. - Check an external
savewithout and withidin theAPI_RBACline (thekeyfield).
A typo in the key silently falls back to save
A refining key that does not exist means today's behaviour of the save key — nothing breaks,
but nothing opens either. Whoever expects a stricter update and misspells the key stays on
the save policy.
Procedure: your own internalActionSave() with a post-save step#
When a presenter has to do something after saving (create a notification in the bell), it
overrides internalActionSave(), but takes the guard and the save itself from SaveTrait — see
ContactMessagePresenter, PaymentPresenter, UserPresenter.
public function internalActionSave(array $data, array $meta = [], array $skipFields = []): array
{
return $this->operation(__FUNCTION__, function () use ($data, $meta, $skipFields) {
if (($denied = $this->requireApiPermission($this->refineSaveAction($data), owned: $this->ownedEntityLoader($data), ownedData: $data)) !== null) { return $denied; }
$response = $this->saveEntityData($data, $meta, $skipFields);
// … post-save step
return $response;
});
}
- Guard on the first line inside
operation()in the same shape as in the trait:refineSaveAction()keeps the refinement toinsert/update,ownedEntityLoader()the owner, andownedDatathe payload for creating under a parent. - Save through
saveEntityData()— it contains all field strips, hydration and the entity manager cleanup after an error. It has no guard of its own; the bridge never calls it because it is not aninternalAction*(). - Run the post-save step only on success (
$response['success']).
Do not call saveEntityData() from anywhere else
Without a guard in front of it, it is a write with no permission check.
ApiPermissionCoverageTest watches only internalAction*() methods, so it will not catch another
call site.
Ownership#
The owner is determined by a SELECT on the id, never by a value from the request
OwnershipPayload may extract only the identifier from the payload — "which
record are we talking about". It must not take userId, insertUserId or user.id
from it: a check over a value supplied by the same request verifies nothing. Who the
owner is, is decided solely by the manager querying the database.
ALLOW_IF_OWNER without an implemented check must behave as DENY
A conditional grant permits nothing on its own. If isOwner() were not called, a
conditional grant would become an unconditional one — exactly the mistake this model
exists to rule out.
A missing id in the payload means CREATING
Zero, a negative number, an empty string or a non-numeric value all count as "no id" ⇒ INSERT. This is fail-closed: the insert branch is stricter (it verifies the parent and lets the server fill in the owner), whereas the update branch would end up finding nothing on an invalid id anyway.
The parent id is read by OwnershipPayload::parentId($data, 'order') from every shape a ToOne
association arrives in: data[order][id]=5, data[order]=5, data[orderId]=5 and
data[order_id]=5. The value is only the client's wish of which parent to attach to; that the
parent belongs to it is verified by the manager with a database query.
The key is looked up regardless of letter case and underscores
findValue() normalizes the same way as the hydrator (normalizePropertyName()). If the
gate looked only at the exact key id, a client would send ID or _id: the gate would see
a create (a different branch) while hydration would process it as an edit, because PHP
dispatches both setID() and set_id() to setId(). New code reading an id from the payload
therefore goes through OwnershipPayload, not $data['id'].
Which actions a conditional grant can apply to#
On the internal path the owner check gets the entity from the lazy loader $owned, and only
actions over one record pass it:
| Action | What isOwner() receives |
|---|---|
get |
the record loaded from the DB by the id parameter |
save with data.id |
the record loaded by data.id (ownedEntityLoader()) |
save without data.id (creating) |
null and the payload in $data; the presenter may take only the parent id from it |
delete with one id |
the record loaded by id |
delete with an array of ids |
null ⇒ DENY |
updateColumns |
only when the presenter overrides ownedForUpdateColumns(); the default null ⇒ DENY |
getAll, getBy, search |
nothing, owner = 1 always means DENY here |
Bulk delete has no owner on purpose: a set of records has no single owner, and deleting "only mine" would be worse than refusing. A collection of your own records is therefore not expressed by a grant but by an action that injects the owner filter itself.
Listing only my records: OwnedListTrait and OwnedOneTrait#
| Trait | Action | Returns | Without identity |
|---|---|---|---|
OwnedListTrait |
myList |
a list shaped like getAll |
an empty list, no DB query |
OwnedOneTrait |
myOne |
one record shaped like getBy |
[], i.e. the same as "not found" |
The server adds EqualFilter(<owner field>, jwt.userId) to the caller's filters. Filters are
combined with andWhere(), so the caller can only narrow the result: userId = 999 in the
request gives the intersection with their own id, i.e. nothing. Today they are used by
watchdogs (Adverts, Company catalogue), membership payments, advert following (myOne) and
private message participants.
| Hook | Default | Purpose |
|---|---|---|
ownedListUserField() / ownedOneUserField() |
userId |
the owner is in a column with a different name |
ownedListExtraFilters() |
none | another server condition the caller cannot omit (private messages hide removed participants) |
Procedure:
- Add
use OwnedListTrait;(orOwnedOneTrait) to the Api presenter. - Create the key
:Api:<Module>:<Entity>:myListwith the grantregistrated = 1. It is an unconditional grant — ownership is held by the filter, not by anisOwner()check. - The front end calls
<endpoint>/my-list(my-one) through the bridge instead ofget-all(get-by). - Verify that an external
GET …/my-listreturns 404 even with an administrator token.
Neither myList nor myOne may get an action*()
Without it Nette rejects an external request with 404 before anything is loaded. With it
the registrated grant would open the action over HTTP to anyone who registers.
Writing your own record: a dedicated action#
The conditional owner grant cannot serve two common writes of a logged-in user. Creating
(save without data.id) gives the check null and the generic isOwner() of the trait
returns false. A bulk delete (deleteAll) has no owner at all (table above). Where a user
creates and deletes only their own rows, the presenter gets its own internal actions in which
the server sets the owner. The pattern is following an advert (AdvertFollowerPresenter):
| Action | Input from the caller | What the server does |
|---|---|---|
myFollow |
advertId |
creates the record only from advert = advertId and user = jwt.userId; the caller's payload is not used |
myUnfollow |
advertId |
deleteAll() with the filters advert = advertId and userId = jwt.userId |
myOne |
filters | the button state, see OwnedOneTrait above |
Procedure:
- Write an
internalAction<Action>()method withoutaction*(), so the action answers 404 externally. - Wrap the body in
operation(__FUNCTION__, …)and putrequireApiPermission('<action>')on its first line. - Take
userIdonly fromgetJwtPayload(). Without an identity (userId <= 0) returnPERMISSION_DENIEDwithout a database query. - Add the server owner filter to the caller's filter, so the scope can only be narrowed.
- Create the key
:Api:<Module>:<Entity>:<action>with the grantregistrated = 1(unconditional, the action itself holds ownership). Leave the genericsave,delete,deleteAllandgetByunchanged. - Check that an external call returns 404 and that the action writes or deletes only the
caller's row even when the data carry a foreign
userId.
Keep deleting your own rows set-based
myFollow does not deduplicate and the table cms_mod_bazaar_advert_followers has no
unique index (the index is called unique_index, but it is not unique). If myUnfollow
deleted through delete(id), one of two rows of the same pair would go and the button
would keep showing "following".
A listing bound to one advert: AdvertScopedListTrait#
The Responses and Statistics tabs on the advert edit page are open even to an anonymous
visitor who knows the advert password. A listing by userId could not serve them, so
Response and AdvertStatistics (Adverts and the Company catalogue) have their own myList
action, where "mine" means "belonging to the advert the caller proved access to". The only
decision is AdvertScopeGuardTrait::advertScopeAllows(), cheapest check first:
- privileged role (
PermissionResolver::isGrantedByPrivilegedRole(), an in-memory map, no query), - owner:
insertUserIdof the advert loaded from the DB equalsjwt.userId(an anonymous advert withinsertUserId0 belongs to nobody), - capability in the token for this advert, with the password fingerprint compared to the hash in the DB (see Advert password).
| Input | What the server adds to the filters |
|---|---|
advertId passed the check |
advertId = <verified id> |
advertId missing, the presenter has advertScopeOwnerField() |
<field> = jwt.userId (responses to all my adverts) |
| anything else | nothing; it returns an empty collection without a query |
advertId from the request is only a claim of which advert is meant; a foreign id yields an
empty collection. The caller's filters may only narrow the result. AdvertStatistics has no
second mode: a statistic carries only a scalar advertId without an association to the
advert, so "my adverts" cannot be expressed by a filter.
myList must not get an actionMyList()
The action needs a guest = 1 grant because of the anonymous editor. Without action*()
Nette rejects it from outside with 404; with it that grant would open the listing to anyone
over HTTP. Guarded by FrontApiSurfacePolicyTest::testInternalOnlyKeysHaveNoActionCounterpart().
Procedure: a field not everyone should see#
Protecting the endpoint is not enough — as soon as the entity appears as a nested
relation under another, legitimately open endpoint, a dedicated action is out of the
game. #[FieldAccess] protects the field itself, whichever path leads to it.
#[FieldAccess(read: Audience::Privileged, insert: Audience::Privileged, update: Audience::None)]
protected ?string $settings = null;
| Audience | Corresponds to a role that has… |
|---|---|
All |
anything — the field passed by virtue of the caller getting the action |
Authenticated |
implicit = 'authenticated' |
Owner |
conditional = 1 ⇒ isOwner() decides on the specific record; on the read axis not supported yet and evaluated more strictly, as Privileged (FieldAudienceContextTest checks that no field declares read: Owner) |
Privileged |
privileged = 1 |
None |
none — the value is determined solely by the server |
Internal |
only for the read axis: only server code through ApiBridge gets the value, never external HTTP — see below |
- All three axes are mandatory.
read(who sees it),insert(who may fill it in when creating),update(who may change it on an existing record,updateColumnincluded). - A field without the attribute behaves as
all/all/all— the attribute closes nothing retroactively. - The audience is monotonic:
Privilegedsees whateverOwnersees, who sees whateverAuthenticatedsees. The opposite condition ("visible to anyone except an admin") does not exist. A masked phone number or a shortened value is not a hidden field but a derived value — it belongs in a dedicated action (the pattern is:Api:System:User:publicProfileandshowPhone). - The audience does not replace the action key. The key decides whether the caller
reaches the action at all; the audience only what they see in the response. A field with
Allon an admin-only action is still unreachable for everyone else.
Defaults would turn the attribute into a riddle
That is why All and None are named values, not the absence of a value.
Without that, nobody would tell #[FieldAccess(read: Audience::Privileged)] from
"the remaining axes were forgotten" half a year later.
Outside a request the fields are NOT filtered
Cron, CLI, generators and emails run with no audience — and the filter deliberately
does not apply there. It is not a hole: the filter protects the transfer to the
caller, and when there is no caller there is nothing to protect — while there is
plenty to break (an activation email needs activationKey, an invoice its
signature). On the Api path the audience is always set, because the guard sets it.
The audience is filled from the guard's decision, never from the request
Otherwise the caller could "buy" their audience in the payload. That is why
actionSave() strips __externalTier from the payload.
On an external save the insert and update axes are evaluated by the caller's audience —
who may write what and how to lock a field against the owner is described in
Column write attributes.
Procedure: only server code needs the value#
Some values must be known to the website inside the server but must not leave over the wire —
not even to an administrator with a token. Typically an advert's editing password (the front
verifies it itself and keeps the hash in the session), an advert activation key or a
response's readKey (confirmation links). None on the read axis does not work here: the
front would not get the value and writing the whole entity would overwrite it in the DB with
an empty one.
#[FieldAccess(read: Audience::Internal, insert: Audience::All, update: Audience::All)]
protected string $password = '',
Audience::Internalonly on thereadaxis. Only an internal call throughApiBridge(front, administration) gets the value — the transport decides, recognised from the framework state, never from the data. On theinsert/updateaxesFieldAccessCoverageTestdoes not let it through.- Decide the write axes separately — for an advert password the form may send the value
(
All), for a token only the server (None). - Today it is carried by the passwords and activation keys of adverts (Bazaar, Comcat),
the discussion post password, the newsletter activation key, the responses'
readKey, the cart key, the SMTP password and the hashes of refresh tokens and API keys.
The audience is part of the query cache key too
The front fills the cache with a payload carrying Internal fields; if the key did not
carry the audience, an external caller would get the same payload from the cache. That is
why the query cache key includes an audience component. The same applies to a nested call:
ApiBridge saves the audience before the call and restores it afterwards
(snapshot()/restore()), otherwise the outer response would leave unfiltered.
Personal data: #[PersonalData]#
FieldAccess decides by role; personal data additionally by whose record it is. The
owner and a privileged caller see everything; for everyone else
App\Core\Security\PersonalData\PersonalDataPolicy adjusts it according to the setting
1/security.personalDataExposure (sanitized = default, full, empty).
#[PersonalDataOwner('insertUserId')] // on the class: who owns the row
class Advert
{
#[PersonalData(PersonalDataKind::Email)] // mask pet***@p***.cz
protected ?string $email = null;
#[PersonalData(PersonalDataKind::Hidden)] // empty / dropped for others
protected ?string $insertIp = null;
}
| Kind | In sanitized mode |
In empty mode |
|---|---|---|
Email |
masked | empty string |
Hidden, text field |
empty string | empty string |
Hidden, non-text field (credits, discount) |
the key is dropped from the row | the key is dropped |
- The policy runs on the finished payload — in
BasePresenter::sendJson()and inApiBridge, not in the hydrator. If the hydrator masked, the query cache would freeze the first caller's result (an owner would cache their full data for everyone). - It walks the whole payload: rows, every
_referencesand embedded collections. #[PersonalDataOwner]determines the owner — a field of the row, or via the parent (#[PersonalDataOwner('advert', via: 'insertUserId')]on an advert parameter).- A hidden non-text field is dropped, because a consumer would push
''into anintsetter. A missing key means "keep the default". - Writes: a non-privileged UPDATE of someone else's record drops personal data from the
payload (
stripForeignUpdate()) — otherwise a front that received the mask would save the mask back into the DB.
Sign-in flows and an advert password holder are exceptions
A sign-in response carries the caller's account, but at that moment the audience does not
know their id yet (isPersonalDataPolicyExempt()). Whoever proved an advert password gets
the data of that one advert unmasked (exemptPersonalDataForCapabilityHolder()).
A marked field cannot be used in a filter or ordering
QueryPathPolicy (below) denies paths to #[PersonalData] for a non-privileged caller.
That is why, for instance, a user's city is not marked — people search by it.
Another user on the front end: the public profile#
Front-end code that shows someone other than the logged-in user (profile, comment author,
review, discussion) must not touch :Api:System:User:get, getAll or getBy. Those are
admin-only and return the whole entity including password, salt, email and ip. For a
guest under enforce they return nothing and the template then dereferences null (500
inside the page).
| I need | Front UserManager |
Api key | Returns |
|---|---|---|---|
| one profile | findPublicProfile($id) |
:Api:System:User:publicProfile |
PublicProfile or null |
| profiles of a listing's authors | findPublicProfiles($ids) |
the same key, parameter ids |
array<userId, PublicProfile>; whoever did not arrive is absent from the map |
| the phone on click | revealPhone($id) |
:Api:System:User:showPhone (guard + per-IP limit) |
the number, or '' when refused |
The publicProfile response is a positive allowlist (UserPresenter::PUBLIC_PROFILE_FIELDS)
and the mapper converts it by hand into PublicProfile, not User. That class has no
getters for e-mail, phone, address or password, so {$profile->getEmail()} fails right during
development instead of silently printing nothing. getEmailMasked() and getPhoneMasked() carry
finished strings from the Api (the e-mail per 1/security.personalDataExposure); do not mask them again in the template.
Procedure: showing another user's data
- In a listing collect the
userIds and callfindPublicProfiles()once, notfind()in a loop. - In the template allow for a missing key (
?? null): a deleted account or a refusal is absent from the map. - Add a datum missing from
PublicProfiletoPUBLIC_PROFILE_FIELDS,buildPublicProfile(), the mapper and the DTO. A raw personal datum does not belong there, only a derived value. - Fetch the full phone only in an AJAX signal (
#[Requires(sameOrigin: true, ajax: true)]) throughrevealPhone()and leave an empty result on the masked value.
Do not simplify back to find($id) or findAll(InFilter)
On a public page that returns nothing and crashes the template, and if someone then gave the
guest a grant on get, it would hand out the personal data of every account. Do not hydrate
the profile into User: the hydrator would give missing fields default values and the
difference "has no phone" × "we did not transfer the phone" would be invisible.
The full phone does not belong in render*()
If revealPhone() were called while rendering, the number would be in the HTML of every
profile and could be harvested without the guard or the limit. In the presenter set the
signal's template variables with ??= in render*(), otherwise a plain assignment in the
same request overwrites them with empty values.
Presenter read filter: filterReadPayload()#
When the shape of the response depends on the caller and a field attribute cannot express it
(whole rows, not columns), override the hook filterReadPayload(array $payload, string $action)
in the Api presenter. The default is identity; it is called from get, getBy, getAll and
search with the action name.
| Presenter | What the filter does |
|---|---|
UserPresenter |
an owner via the owner grant does not get password, salt, activationKey |
CustomAssetPresenter, PageSectionPresenter |
an unprivileged caller gets only active, non-deleted records without internal fields (InactiveRecordsReadFilterTrait) |
Procedure: a public read must not return drafts#
use InactiveRecordsReadFilterTraitin the Api presenter.- Provide
inactiveFilterEntityClass()(the presenter's Api entity) andinactiveFilterHiddenFields()(fields an unprivileged caller does not get even for a live record). - In
filterReadPayload()return$this->filterInactiveRecords($payload, $action). - Check the public
get-allwithout logging in, and the same from the admin.
The filter runs after the query cache, not inside it
The result depends on the caller. Filtering inside the cache would freeze the response shape of the first caller for everyone else.
Filters, ordering and columns: QueryPathPolicy#
A filter is as good a channel as a response: a character-by-character like over
user.password reconstructs the hash, ordering by a hidden field reveals the order.
App\Core\Security\Rbac\QueryPathPolicy therefore lets through, in filters (nested
groups included), orderBys and columns, only segments that are a mapped field or
association and that the caller would also get in the output.
| Caller | Behaviour |
|---|---|
| non-privileged | a segment with FieldAccess(read: …) the audience does not allow, or with #[PersonalData] → denied |
| unknown field, external path | denied (otherwise it would end in a Doctrine exception = 500) |
| unknown field, internal path | passes as before |
| privileged, cron, CLI | not checked |
- It is called right after the guard through
BasePresenter::rejectForbiddenQueryPaths()in the actionsgetAll,getBy,search,deleteAlland in the "my" listings (myList,myOne). - The external response is HTTP 400 with the code
VALIDATION_FAILEDand the messageField path "…" is not allowed in filters, orderBys or columns. - Finished
Filterobjects are not checked. Only an array ever comes from the wire; an object is built by server code, which picks the field itself (the front looks up an advert byactivationKey). columnsfrom outside may only be a bare field name of the root entity — and onget-allthey are not supported from outside at all.
A new read action with caller-supplied filters
Call rejectForbiddenQueryPaths($filters, $orderBys, $columns) right after
requireApiPermission() and return its result if it is not null. The pattern is
GetAllTrait.
Procedure: add a new secret field#
A field that only the server may set (a user's password and salt, a conversation token, a
reservation-cancellation token) is marked Audience::None on all three axes:
#[FieldAccess(read: Audience::None, insert: Audience::None, update: Audience::None)]
protected ?string $accessToken = null,
- Mark the field with the attribute above. The value is then set only by the Api manager
on the entity (
$entity->setAccessToken(…)inbeforeSave()), or passed through$meta(the password pattern: the form sends$meta['password']andUserManager::beforeSave()hashes it). - Do not read the field from the payload. The Api presenter's
SaveTraitcallsOrmEntityHydrator::stripNobodyWritableFields()right at the start — a key with that name is removed from the payload on every path, external and internal (admin, web, bridge). Theinsertaxis decides on creation, theupdateaxis on an edit (creation = the payload has noid). - Do not rely on reading.
read: Nonemeans an entity loaded through the Api carries the default value ('',null), not the stored one. Any code that loads an entity, edits it and sends it back whole would otherwise overwrite the stored secret with an empty one. - Add a test that the field disappears from the payload — the pattern is
tests/Unit/Hydrators/OrmHydratorNobodyWritableFieldsTest.php(password, salt, tokens; also with different letter case and insnake_case).
Why it exists: saving a user in the administration erased the password
Reading does not return the password (read: None), so the user entity in the
administration carried the default password = ''. On save the form sent it back whole and
the Api overwrote the stored password with an empty string — the account could no longer
sign in. Fields with
Audience::None are therefore never taken from the payload. Changing the password through
the form still works, because it travels outside the column, in $meta['password'].
The filter covers only the root of the payload and only through an Api presenter
Nested graphs do not carry these fields and only top-level keys are removed (the comparison
ignores letter case and underscores, so access_token and accessToken fall alike). A write
straight from manager code ($entity->setPassword(…)) bypasses the filter — and that is
intended: this is how the server sets the secret field.
The axes are chosen for each field separately
The account deletion state (deletionRequestedAt, deletedAt) is read: Privileged,
insert: None, update: None: the administration sees the state, but nobody writes it from a
payload — only the manager changes it. A value an administrator should also change does not
get None on the update axis.
Procedure: an action is denied and I do not know why#
- Look for the
API_RBACline in theauthchannel. It carriesmode,effect,decision,key,userId,path(internal/external) and the caller — that is, everything you need. - Read the key in that line and check whether such a key exists in the database at
all. A non-existent key looks exactly like a denied one; the
key_exists=nofield tells them apart (a typo in the literal, a forgotten seed):grep 'API_RBAC.*key_exists=no' auth.log. On the internal path the line also carriescaller=— the first module file (repository, presenter, component) that asked for the data; the Api layer andApiBridgeare skipped. - Check the shape of the key against the table above — the Api branch has different actions than the administration.
- Look at
path.internalmeansinternalModedecides, notmode. On the external pathclient=shows the client id androles=its allowed roles — if the role that would allow the action is missing there, the role ceiling denied it. - For CLI, verify the identity. A script going through
ApiBridgehas no session, so in enforcing mode it runs asguest. It has to supply an identity:ApiBridge::setInternalAuthToken($jwtManager->generateToken(…)). - Cron is not affected — it injects the Api managers directly, not through the bridge.
A CLI harness without a token returns SILENT EMPTINESS
It does not return a permission error — it returns an empty result, because "Permission denied" stays inside the bridge. The test then passes over empty data and looks green.
A database error in the resolver is DENY, not a pass
The whole class is fail-closed: every DBAL exception is logged into the auth
channel and resolved as a denial. A database outage therefore looks like a blanket
access denial.
An API_COLTIER line is not a denial
It is a measurement and filters nothing. With internalMode log or enforce it records,
for an internal save and updateColumns of an unprivileged caller, which fields a
role-based strip would take away if it applied on the internal path too (would_strip=…
for save, would_drop=… for updateColumns; - = nothing). A privileged caller gets
no line; an identical line is written only once per request.
Writing columns#
The special case — who may overwrite a specific column from outside — is covered by column write attributes.
User roles: who may change them#
The form sends the account's role set in meta.roleIds of :Api:System:User:save. Before it
is stored, App\UI\Api\System\Models\Helpers\UserRolePolicy decides (a pure function,
UserManager supplies the data). The right to edit the other account fields was already
checked by the save action gate; roles have their own key :Api:System:User:setRoles.
| Rule | Result |
|---|---|
a caller without setRoles sends a set different from the account's (adding and removing alike) |
USER_ROLE_ASSIGNMENT_DENIED with roleIds |
the same set, or meta.roleIds missing |
passes even without setRoles |
a role with assignable = 0 (guest, owner, receiver) or an unknown id |
nobody assigns it, neither the admin nor internal code; removing a mistaken link is allowed (with setRoles) |
| creating an account | the baseline is the roles with set_after_registration = 1; without setRoles only those, or nothing, may be sent |
a manager called without a caller (cron, CLI; FieldAudienceContext not resolved) |
setRoles is not checked, assignable is |
A role with protect_last_holder = 1 (currently admin) must not lose its last active
holder. Active = an activated, non-banned, non-cancelled account without a pending cancellation
request (counted by UserRelationManager). It applies to every caller including internal code:
| Attempt | Result |
|---|---|
| remove the role from its last active holder | USER_ROLE_LAST_HOLDER_BLOCKED |
deactivate, ban or cancel that account (save and bulk updateColumns) |
USER_DEACTIVATE_LAST_HOLDER_BLOCKED |
| delete the last active holder (also in bulk when all would disappear) | USER_DELETE_LAST_HOLDER_BLOCKED |
| delete the caller's own account | USER_DELETE_SELF_BLOCKED |
Procedure: a new role
- A row in
cms_system_user_rolesthrough a migration.assignable = 1only for a role that a person should assign by hand; implicit and conditional roles (implicit,conditional) have0. set_after_registration = 1only for a role every new account should get.protect_last_holder = 1only for a role without which nobody gets into the administration.- Check in the user administration: an account without
setRolescannot change the role, one with it can.
Grant setRoles only to a role that may hand out admin
Whoever holds setRoles assigns any role with assignable = 1, admin included, even to
themselves. The migration grants it only to the admin role; another role with this key is
a path to full rights.
An empty roleIds on creation is not “no roles”
Without setRoles, [] is refused because it would drop the new account's default roles. A
form without a role field should not send meta.roleIds at all.
Tests#
tests/Unit/Security/ — 76 files today. Besides the ordinary decision tests, several
are structural and read the sources:
| Test | What it guards |
|---|---|
PermissionResolverTest |
that the resolver's SQL contains no privilege groups |
ApiPermissionCoverageTest |
that the guard is called in every internalAction*() |
FieldAccessCoverageTest |
that a field whose name looks like a secret has either #[FieldAccess] or an entry in the exception list with a reason; a wider sieve (…Key, ip, …Ip, token, hash) over all shared entities requires a restricted read audience or #[PersonalData]; Internal only on the read axis |
OwnerWritableFieldsTest |
what an owner writes through an external save — against an allowlist, in both directions |
QueryPathPolicyTest |
that no filter, ordering or column reaches a hidden field or personal data |
PersonalDataPolicyTest |
masking and dropping personal data by owner and mode |
ExternalReadonlyCoverageTest, ExternalAdminOnlyCoverageTest |
the same for the column-write attributes |
A structural test catches what a runtime test does not
A forgotten requireApiPermission() in one new method breaks nothing — the hole just
stays there. A test walking the sources finds it immediately.
Coverage by field name is a sieve, not a protection
FieldAccessCoverageTest recognises password or secret. A field with an innocent
name that still carries a secret (meta, config, note) has to be annotated by a
human.
Where to look#
| I want | Where |
|---|---|
| the decision core | app/Core/Security/Rbac/PermissionResolver.php |
| the modes and their semantics | app/Core/Security/Rbac/ApiRbacConfig.php + config/Shared/api.neon |
the guard in internalAction*() |
app/Core/Traits/Api/Presenters/ApiPermissionGuardTrait.php |
| the gate on the external path | app/Core/Traits/Api/Presenters/RbacCheckTrait.php |
| reading ids from the payload | app/Core/Security/Rbac/OwnershipPayload.php |
| listing only my records | app/Core/Traits/Api/Presenters/{OwnedListTrait,OwnedOneTrait}.php |
| conditional roles and the matrix | app/Core/Security/Rbac/ConditionalRoleSupport.php + Conditional roles |
| the field audience | app/Core/Security/Rbac/FieldAudienceContext.php, app/Core/Attributes/Hydration/{FieldAccess,Audience}.php |
| personal data | app/Core/Security/PersonalData/PersonalDataPolicy.php, app/Core/Attributes/Hydration/{PersonalData,PersonalDataOwner,PersonalDataKind}.php |
| paths in filters | app/Core/Security/Rbac/QueryPathPolicy.php |
| the action attributes | app/Core/Attributes/{PublicAction,NoClientKey}.php |
| the application key and role ceiling | app/Core/Traits/Api/Presenters/ApiClientKeyTrait.php, app/Core/Security/ApiClient/ |
| the tests | tests/Unit/Security/ |
Follow-up chapters: Authentication and authorisation · Conditional roles · A link with a key · CORS, rate limiting and audit · Column write attributes · Calling the API