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:
applyCors()- an OPTIONS preflight is answered with 204 before reaching the gate (a preflight carries noAuthorizationheader and would otherwise end as 403).checkClientKey()(ApiClientKeyTrait) - missing or invalid application key gives 401, an exceeded application limit gives 429.checkJwtAuth()- an invalid token gives 401, not 403. Without a token the caller is a guest (or 401 whenapi.clientKey.requiredis false and no key was sent).checkClientCallerLimit()- the limit of a single caller under one key gives 429.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()andgetAction(). Building it from the URL would turnupdate-columnsinto a nonexistent key and deny every dash-separated action. userIdcomes only from the JWT payload, never from the request.ALLOW_IF_OWNERwithout a successfulverifyOwnership()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 theguestrole.checkJwtAuth()returns early for public actions before decoding the Bearer token, so the payload is alwaysnullthere. An action marked public without aguestgrant is denied (fail-closed).- The
guestgrants hold one key more than there are#[PublicAction]actions::Api:System:Auth:loginExternalstays becauserequireApiPermission('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
$apiRbacConfig
public
ApiRbacConfig
$apiRbacConfig
Attributes
- #[Inject]
$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]
$permissionResolver
public
PermissionResolver
$permissionResolver
Attributes
- #[Inject]
$anonymousDenyReported
private
static array<string, true>
$anonymousDenyReported
= []
keys of denied guest reads already logged in this request
$rbacDecision
Decision after the ownership check; the one that allows or denies.
private
PermissionDecision|null
$rbacDecision
= null
$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
$rbacRawDecision
Resolver decision before the ownership check (input of the field tier).
private
PermissionDecision|null
$rbacRawDecision
= null
Methods
checkApiPermission()
Gate entry point; call in `startup()` right after `checkJwtAuth()`.
protected
checkApiPermission() : void
Tags
getRbacDecision()
Decision after the ownership check (null when the gate did not run, i.e. mode `off`).
protected
getRbacDecision() : PermissionDecision|null
Return values
PermissionDecision|nullgetRbacKey()
protected
getRbacKey() : string|null
Return values
string|nullgetRbacRawDecision()
Resolver decision before the ownership check; input of the field tier.
protected
getRbacRawDecision() : PermissionDecision|null
Return values
PermissionDecision|nullresolveExternalFieldTier()
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 isoffor 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
stringverifyOwnership()
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
boolverifyReceiver()
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,
nullmeans no identity
Return values
boolbuildApiPermissionKey()
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
stringfieldAudience()
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|nullgetOwnershipRecordId()
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|nulllogRbacDecision()
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.