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 — 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#

  1. Write get<Action>Data() and put requireApiPermission('<action>') on its first line.
  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 27 of them today.
  2. 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.
  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', []).

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 = 1isOwner() decides on the specific record
Privileged privileged = 1
None none — the value is determined solely by the server
  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.

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#

  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.
  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.
  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.

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