Skip to content
V
For developers
Architecture, conventions, the core and security
Security / RBAC in the API

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#

  1. Write internalAction<Action>(), wrap its body in operation(__FUNCTION__, …) and put requireApiPermission('<action>') on the first line inside it.
  2. 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:
SELECT `key` FROM cms_system_user_role_privileges WHERE `key` LIKE '%NotificationType%';
  1. Mark a public action #[PublicAction] — without the attribute it is denied in enforcing mode. There are 17 of them today.
  2. 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 in tests/Unit/Security/ApiClientKeyExemptionTest.php.
  3. When an action should work for the owner only, pass the guard a lazy entity loader and implement isOwner().
  4. 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#

  1. Write the method as protected and do not add an action*() for it; without one, Nette rejects it from outside with a 404.
  2. 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.
  3. 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#

  1. 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';.
  2. Keep the …:save key: it stays the fallback for the other branch and for the time between deploying the code and running the SQL.
  3. Check an external save without and with id in the API_RBAC line (the key field).

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;
    });
}
  1. Guard on the first line inside operation() in the same shape as in the trait: refineSaveAction() keeps the refinement to insert/update, ownedEntityLoader() the owner, and ownedData the payload for creating under a parent.
  2. 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 an internalAction*().
  3. 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:

  1. Add use OwnedListTrait; (or OwnedOneTrait) to the Api presenter.
  2. Create the key :Api:<Module>:<Entity>:myList with the grant registrated = 1. It is an unconditional grant — ownership is held by the filter, not by an isOwner() check.
  3. The front end calls <endpoint>/my-list (my-one) through the bridge instead of get-all (get-by).
  4. Verify that an external GET …/my-list returns 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:

  1. Write an internalAction<Action>() method without action*(), so the action answers 404 externally.
  2. Wrap the body in operation(__FUNCTION__, …) and put requireApiPermission('<action>') on its first line.
  3. Take userId only from getJwtPayload(). Without an identity (userId <= 0) return PERMISSION_DENIED without a database query.
  4. Add the server owner filter to the caller's filter, so the scope can only be narrowed.
  5. Create the key :Api:<Module>:<Entity>:<action> with the grant registrated = 1 (unconditional, the action itself holds ownership). Leave the generic save, delete, deleteAll and getBy unchanged.
  6. 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:

  1. privileged role (PermissionResolver::isGrantedByPrivilegedRole(), an in-memory map, no query),
  2. owner: insertUserId of the advert loaded from the DB equals jwt.userId (an anonymous advert with insertUserId 0 belongs to nobody),
  3. 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
  1. 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, updateColumn included).
  2. A field without the attribute behaves as all/all/all — the attribute closes nothing retroactively.
  3. The audience is monotonic: Privileged sees whatever Owner sees, who sees whatever Authenticated sees. 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:publicProfile and showPhone).
  4. 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 All on 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 = '',
  1. Audience::Internal only on the read axis. Only an internal call through ApiBridge (front, administration) gets the value — the transport decides, recognised from the framework state, never from the data. On the insert/update axes FieldAccessCoverageTest does not let it through.
  2. Decide the write axes separately — for an advert password the form may send the value (All), for a token only the server (None).
  3. 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
  1. The policy runs on the finished payload — in BasePresenter::sendJson() and in ApiBridge, 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).
  2. It walks the whole payload: rows, every _references and embedded collections.
  3. #[PersonalDataOwner] determines the owner — a field of the row, or via the parent (#[PersonalDataOwner('advert', via: 'insertUserId')] on an advert parameter).
  4. A hidden non-text field is dropped, because a consumer would push '' into an int setter. A missing key means "keep the default".
  5. 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.

{var $author = $users[$userId] ?? null}
{if $author}{$author->getUsername()}{/if}

Procedure: showing another user's data

  1. In a listing collect the userIds and call findPublicProfiles() once, not find() in a loop.
  2. In the template allow for a missing key (?? null): a deleted account or a refusal is absent from the map.
  3. Add a datum missing from PublicProfile to PUBLIC_PROFILE_FIELDS, buildPublicProfile(), the mapper and the DTO. A raw personal datum does not belong there, only a derived value.
  4. Fetch the full phone only in an AJAX signal (#[Requires(sameOrigin: true, ajax: true)]) through revealPhone() 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#

  1. use InactiveRecordsReadFilterTrait in the Api presenter.
  2. Provide inactiveFilterEntityClass() (the presenter's Api entity) and inactiveFilterHiddenFields() (fields an unprivileged caller does not get even for a live record).
  3. In filterReadPayload() return $this->filterInactiveRecords($payload, $action).
  4. Check the public get-all without 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
  1. It is called right after the guard through BasePresenter::rejectForbiddenQueryPaths() in the actions getAll, getBy, search, deleteAll and in the "my" listings (myList, myOne).
  2. The external response is HTTP 400 with the code VALIDATION_FAILED and the message Field path "…" is not allowed in filters, orderBys or columns.
  3. Finished Filter objects 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 by activationKey).
  4. columns from outside may only be a bare field name of the root entity — and on get-all they 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,
  1. Mark the field with the attribute above. The value is then set only by the Api manager on the entity ($entity->setAccessToken(…) in beforeSave()), or passed through $meta (the password pattern: the form sends $meta['password'] and UserManager::beforeSave() hashes it).
  2. Do not read the field from the payload. The Api presenter's SaveTrait calls OrmEntityHydrator::stripNobodyWritableFields() right at the start — a key with that name is removed from the payload on every path, external and internal (admin, web, bridge). The insert axis decides on creation, the update axis on an edit (creation = the payload has no id).
  3. Do not rely on reading. read: None means 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.
  4. 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 in snake_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#

  1. Look for the API_RBAC line in the auth channel. It carries mode, effect, decision, key, userId, path (internal/external) and the caller — that is, everything you need.
  2. 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=no field 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 carries caller= — the first module file (repository, presenter, component) that asked for the data; the Api layer and ApiBridge are skipped.
  3. Check the shape of the key against the table above — the Api branch has different actions than the administration.
  4. Look at path. internal means internalMode decides, not mode. On the external path client= shows the client id and roles= its allowed roles — if the role that would allow the action is missing there, the role ceiling denied it.
  5. For CLI, verify the identity. A script going through ApiBridge has no session, so in enforcing mode it runs as guest. It has to supply an identity: ApiBridge::setInternalAuthToken($jwtManager->generateToken(…)).
  6. 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

  1. A row in cms_system_user_roles through a migration. assignable = 1 only for a role that a person should assign by hand; implicit and conditional roles (implicit, conditional) have 0.
  2. set_after_registration = 1 only for a role every new account should get.
  3. protect_last_holder = 1 only for a role without which nobody gets into the administration.
  4. Check in the user administration: an account without setRoles cannot 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

Cookie settings