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 — JWT, an API key, or an anonymous visitor | 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 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) |
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.
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.
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 get*Data() |
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 get*Data(). 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 get*Data() 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 call must be the first statement of the method, before any database query:
public function getGetAllData(array $filters = [], …): array
{
if (($denied = $this->requireApiPermission('getAll')) !== null) { return $denied; }
…
}
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
get<Action>Data()and putrequireApiPermission('<action>')on its first line. - 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 27 of them today. - Mark an action for an external client (API key)
#[RequiresScope]— a client is not a user, has no roles, and authorises by scopes, not privileges. Without the attribute it is a DENY for the client. - 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', []).
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.
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 |
Privileged |
privileged = 1 |
None |
none — the value is determined solely by the server |
- 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.
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.
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.
- Check the shape of the key against the table above — the Api branch has different actions than the administration.
- Look at
path.internalmeansinternalModedecides, notmode. - 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.
Writing columns#
The special case — who may overwrite a specific column from outside — is covered by column write attributes.
Tests#
tests/Unit/Security/ — 24 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 get*Data() |
FieldAccessCoverageTest |
that a field whose name looks like a secret has either #[FieldAccess] or an entry in the exception list with a reason |
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 get*Data() |
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 |
| the field audience | app/Core/Security/Rbac/FieldAudienceContext.php, app/Core/Attributes/Hydration/{FieldAccess,Audience}.php |
| the action attributes | app/Core/Attributes/{PublicAction,RequiresScope}.php |
| the tests | tests/Unit/Security/ |
Follow-up chapters: Authentication and authorisation · CORS, rate limiting and audit · Column write attributes · Calling the API