CoolCMS API

RbacCheckTrait

RBAC gate over the external Api path.

Called from \App\Core\Base\Api\BasePresenter::startup() right after checkJwtAuth(). The decision itself comes from PermissionResolver; this trait holds no decision logic and no SQL of its own. The order of the startup checks is binding:

  1. applyCors() - an OPTIONS preflight is answered with 204 before reaching the gate (a preflight carries no Authorization header and would otherwise end as 403).
  2. checkClientKey() (ApiClientKeyTrait) - missing or invalid application key gives 401, an exceeded application limit gives 429.
  3. checkJwtAuth() - an invalid token gives 401, not 403. Without a token the caller is a guest (or 401 when api.clientKey.required is false and no key was sent).
  4. checkClientCallerLimit() - the limit of a single caller under one key gives 429.
  5. checkApiPermission() - the authorization decision.

IMPORTANT: ApiBridge::internalCall() invokes internalAction* by reflection and never runs startup(), so admin and front end calls do not pass through this gate. That is by design; a test harness that calls the presenter directly therefore proves nothing about the gate.

IMPORTANT: the consuming class must also use JwtAuthTrait (identity via getJwtPayload(), public-action flag via isPublicAction()) and ApiClientKeyTrait (getApiClient(), logging only). Any other identity source is forbidden.

Evaluation rules:

  • The key is built only from getName() and getAction(). Building it from the URL would turn update-columns into a nonexistent key and deny every dash-separated action.
  • userId comes only from the JWT payload, never from the request.
  • ALLOW_IF_OWNER without a successful verifyOwnership() is a deny.
  • A key missing in the database is a deny; there is no tree and no fallback.
  • #[PublicAction] is evaluated by the normal lookup against the guest role. checkJwtAuth() returns early for public actions before decoding the Bearer token, so the payload is always null there. An action marked public without a guest grant is denied (fail-closed).
  • The guest grants hold one key more than there are #[PublicAction] actions: :Api:System:Auth:loginExternal stays because requireApiPermission('loginExternal') needs it for the bridge call from the front end login component, where the caller is anonymous.
  • Payment gateway callbacks and login are public; do not touch them without live verification.
  • The application key never decides rights. Decisions always follow the JWT identity (or guest); a client may only narrow the roles, which PermissionResolver does itself (from ApiClientContext), including conditional roles and the field tier.

Table of Contents

Properties

$apiRbacConfig  : ApiRbacConfig
$fieldAudienceContext  : FieldAudienceContext
Audience of the caller for the emit filter ({@see \App\Core\Attributes\Hydration\FieldAccess}).
$permissionResolver  : PermissionResolver
$anonymousDenyReported  : array<string, true>
$rbacDecision  : PermissionDecision|null
Decision after the ownership check; the one that allows or denies.
$rbacEnforced  : bool
True only when the gate actually rejected the request (`enforce` + deny).
$rbacKey  : string|null
Key of the last evaluation, e.g. `:Api:Eshop:Order:save`.
$rbacRawDecision  : PermissionDecision|null
Resolver decision before the ownership check (input of the field tier).

Methods

checkApiPermission()  : void
Gate entry point; call in `startup()` right after `checkJwtAuth()`.
getRbacDecision()  : PermissionDecision|null
Decision after the ownership check (null when the gate did not run, i.e. mode `off`).
getRbacKey()  : string|null
getRbacRawDecision()  : PermissionDecision|null
Resolver decision before the ownership check; input of the field tier.
resolveExternalFieldTier()  : string
Resolves the field tier of the caller (`#[ExternalAdminOnly]`).
verifyOwnership()  : bool
Default `owner` check: is the caller the owner of this record? Fail-closed.
verifyReceiver()  : bool
Default `receiver` check: is the caller the receiver of this record (who it was sent to, not who created it)? Fail-closed.
buildApiPermissionKey()  : string
Builds the key such as `:Api:Eshop:Order:updateColumns` from `getName()` + `getAction()`, never from the URL: Nette `Route::$styles['action']` applies `path2action`, so the URL `update-columns` is `updateColumns` inside the presenter, and the optional `<loc>` route segment is not part of the key.
fieldAudience()  : FieldAudienceContext|null
Returns the audience context, or `null` when the presenter did not get it through DI.
getOwnershipRecordId()  : int|null
Returns the id of the decided record, taken only from the request identifiers: the `id` parameter (get/delete) or `data.id` (save UPDATE). Returns `null` when both are given and differ (ambiguous), when `id` is an array (bulk delete) and for an INSERT.
logRbacDecision()  : void
Writes one machine-parsable line to the `auth` channel, in a format that follows `JWT_GUARD ...` from {@see JwtAuthTrait::reportJwtAuthFailure()} so that a report can be built from the log.
rbacTokenHint()  : string
Returns the first 12 characters of the Bearer token, in the same shape as `JWT_GUARD ... token=`.

Properties

$fieldAudienceContext

Audience of the caller for the emit filter ({@see \App\Core\Attributes\Hydration\FieldAccess}).

public FieldAudienceContext $fieldAudienceContext

Filled by the guard from the decision it has just made, never from the request.

IMPORTANT: access it only through self::fieldAudience(). An uninitialized typed property throws Error on first access, which lands in the fail-closed catch-all of ApiPermissionGuardTrait::requireApiPermission(); a presenter built outside DI would then be denied everything because of a field filter unrelated to authorization.

NOTE: the type cannot be nullable, InjectExtension rejects it; hence the non-nullable property with an isset() getter.

Attributes
#[Inject]

$anonymousDenyReported

private static array<string, true> $anonymousDenyReported = []

keys of denied guest reads already logged in this request

$rbacEnforced

True only when the gate actually rejected the request (`enforce` + deny).

private bool $rbacEnforced = false

$rbacKey

Key of the last evaluation, e.g. `:Api:Eshop:Order:save`.

private string|null $rbacKey = null

Methods

checkApiPermission()

Gate entry point; call in `startup()` right after `checkJwtAuth()`.

protected checkApiPermission() : void
Tags
throws
AbortException

in enforce mode on a deny (403 + Result::failure)

getRbacKey()

protected getRbacKey() : string|null
Return values
string|null

resolveExternalFieldTier()

Resolves the field tier of the caller (`#[ExternalAdminOnly]`).

protected resolveExternalFieldTier() : string

Returns 'privileged' only when the grant for this request ($this->rbacKey, computed only in checkApiPermission(), never again from the request) comes from a role with privileged = 1 (the flag decides, never the role name). Called by SaveTrait::actionSave(), the same place that sets $meta['__external'].

Fail-closed to 'user':

  • $rbacKey === null: the gate is off or the key could not be built; without a decision nobody can be declared privileged.
  • A caller without a token (guest, even with an application key) cannot hold a privileged role.
  • A client whose application key does not allow a privileged role: the resolver drops it from the caller roles (role ceiling), so the tier falls to 'user'.

In log mode the tier works the same as in enforce: it is an independent second layer of field protection, not part of the URL/action gate (same as #[ExternalReadonly]).

Return values
string

verifyOwnership()

Default `owner` check: is the caller the owner of this record? Fail-closed.

protected verifyOwnership(int $userId) : bool

Same base as self::verifyReceiver(): takes the record id from the request (id for get/delete, data.id for save), loads the record from the database via $this->manager->find() and passes only an entity implementing OwnerAwareInterface whose getOwnerUserId() is positive and equals $userId. Returns false without an id (an INSERT, i.e. save without data.id, has no owner; presenters handle it in their own canUserWrite), without a manager, without a record, without the interface, or with an owner of null/0 (anonymous record).

Concrete Api presenters override it and delegate to their Api manager (OwnershipCheckTrait); this base applies only to a presenter that does not.

IMPORTANT: never change this to return true, it would open every CRUD presenter at once.

Parameters
$userId : int

ID from the JWT payload, never from the request

Return values
bool

verifyReceiver()

Default `receiver` check: is the caller the receiver of this record (who it was sent to, not who created it)? Fail-closed.

protected verifyReceiver(int|null $userId) : bool

External path: reads only the request identifiers (id) and decides against the database. The identity comes from the JWT; $userId = null is legitimate (anonymous editor with a capability). read_key is never taken from outside; that branch exists only on the internal path (ApiPermissionGuardTrait::isReceiver()).

A concrete presenter may extend it (each domain defines its own receiver).

IMPORTANT: never change this to true.

Parameters
$userId : int|null

ID from the JWT payload, null means no identity

Return values
bool

buildApiPermissionKey()

Builds the key such as `:Api:Eshop:Order:updateColumns` from `getName()` + `getAction()`, never from the URL: Nette `Route::$styles['action']` applies `path2action`, so the URL `update-columns` is `updateColumns` inside the presenter, and the optional `<loc>` route segment is not part of the key.

private buildApiPermissionKey() : string
Return values
string

fieldAudience()

Returns the audience context, or `null` when the presenter did not get it through DI.

private fieldAudience() : FieldAudienceContext|null

The audience is only a supplement and must never be able to decide access.

Return values
FieldAudienceContext|null

getOwnershipRecordId()

Returns the id of the decided record, taken only from the request identifiers: the `id` parameter (get/delete) or `data.id` (save UPDATE). Returns `null` when both are given and differ (ambiguous), when `id` is an array (bulk delete) and for an INSERT.

private getOwnershipRecordId() : int|null
Return values
int|null

logRbacDecision()

Writes one machine-parsable line to the `auth` channel, in a format that follows `JWT_GUARD ...` from {@see JwtAuthTrait::reportJwtAuthFailure()} so that a report can be built from the log.

private logRbacDecision(string $key, int|null $userId, PermissionDecision $raw, PermissionDecision $effective) : void

key_exists= has the same field and meaning as ApiPermissionGuardTrait::logApiPermissionDecision(): it distinguishes "key exists, role denies" from "key is missing in cms_system_user_role_privileges". It is computed via PermissionResolver::hasKey() only when a line is actually written.

Parameters
$key : string
$userId : int|null
$raw : PermissionDecision
$effective : PermissionDecision

rbacTokenHint()

Returns the first 12 characters of the Bearer token, in the same shape as `JWT_GUARD ... token=`.

private rbacTokenHint() : string

Read from the header directly so the trait does not depend on private methods of JwtAuthTrait.

Return values
string
On this page

Search results