CoolCMS API

ApiPermissionGuardTrait

Transport-independent RBAC guard for Api actions.

Unlike RbacCheckTrait, which is a gate in startup() protecting only the external HTTP path (ApiBridge::internalCall() never runs run()), this guard sits directly in internalAction*(), where every path to the data of an Api presenter passes:

(1) external HTTP: run() -> startup() -> checkApiPermission() -> action*() -> internalAction*() -> requireApiPermission()
(2) internal bridge: createPresenter() -> propagateAuthToPresenter() -> internalAction*() -> requireApiPermission()

On the external path it is therefore evaluated twice, which is intended: the cost is zero (PermissionResolver keeps a per-request key map, the second call is an isset()) and anyone who bypasses startup() still hits the guard in the method.

Caller contract:

if (($denied = $this->requireApiPermission('getAll')) !== null) { return $denied; }

null means continue. An array must be returned immediately as the method result (Result::failure(PERMISSION_DENIED); the internal path has nowhere to send a 403). The call must be the first statement of the method, before any DB query.

Rules that must not be broken:

  1. The transport is detected from framework state, never from data (self::isInternalTransport()). $meta['__external'] can be sent over the wire, so it must not decide the transport.
  2. The action is a literal parameter. On the internal path getAction() is '' and getName() is null, which would build the key '::'. The presenter is derived from static::class (self::resolveApiPresenterName()). Deriving the action from debug_backtrace() is forbidden (fragile, slow, different name for traits). IMPORTANT: the literal must be camelCase as in the seed (updateColumns, not update-columns); otherwise ApiPermissionCoverageTest fails.
  3. Identity comes only from getJwtPayload(). internalAction* methods may take userId (internalActionSummary, internalActionMyOrders, internalActionRepeatItems), but it is the caller's claim, not identity, and the guard must not touch it.
  4. Only PermissionResolver decides; no own SQL or own decision logic is added here.

The trait belongs on BasePresenter, which also uses RbacCheckTrait ($apiRbacConfig, $permissionResolver), JwtAuthTrait (getJwtPayload(), isPublicAction()) and ApiClientKeyTrait (getApiClient(), for logging only). It deliberately declares no #[Inject] property of its own, because the same property name from two traits on one class is a needless risk.

IMPORTANT: the method is named isOwner(), not verifyOwnership(); RbacCheckTrait already declares the latter and two traits with the same method name on one class are a PHP fatal error. Both coexist on purpose: verifyOwnership() reads the request (external path), isOwner() receives the entity (internal path), where getRequestParameters() throws InvalidStateException, so the OwnershipCheckTrait helpers would blow up there.

Table of Contents

Constants

CALLER_TIER_PRIVILEGED  : string = 'privileged'
Caller tier: `privileged` means the grant for this key came from a role with `privileged = 1`; `user` means anything else (`guest`, `registrated`, `owner`) and is also the fail-closed default of all error branches.
CALLER_TIER_USER  : string = 'user'
CALLER_BACKTRACE_LIMIT  : int = 14
Depth of `debug_backtrace()` when looking for `caller=` (a limit, not the whole stack).
CALLER_SKIPPED_PATHS  : array<string|int, mixed> = [ // The Api layer itself. 'app/Core/Traits/Api...
Path prefixes (relative to the project root) skipped when looking for `caller=`.

Properties

$callerTierReported  : array<string, true>
Dedup of `API_COLTIER` lines within one request. The key is `kind|rbac key|tier|userId|field|caller`, so only a literally identical line is dropped; a different caller, tier or field set is logged separately.

Methods

isCallerTierProbeActive()  : bool
Returns whether the `API_COLTIER` measuring probe is active. All three conditions are needed:
isOwner()  : bool
Default ownership check, fail-closed: without {@see OwnerAwareInterface} on the entity it is always `false`. Presenters with their own semantics (`Bazaar/Comcat Advert` have `getInsertUserId()`; `Base\Rating` has the owner of the rated object, not the author) override it. The generic `getUserId()` fallback with the `> 0` edge lives in `OwnershipCheckTrait`.
isReceiver()  : bool
Default check of the `receiver` role: "is the caller the recipient of this record?" (whom it was sent to, not who created it). Fail-closed: without {@see ReceiverAwareInterface} on the entity it is always `false`. {@see self::requireApiPermission()} calls it only when the resolver returned a conditional `receiver` grant (without an unconditional one). Each domain defines its recipient itself (Response: privileged role, advert owner, password capability or a valid `read_key`).
reportCallerTierGap()  : void
Writes one machine-parsable `API_COLTIER` line to the `auth` channel. The format follows `API_RBAC ...` from {@see logApiPermissionDecision()}, so both parse with one report:
requireApiPermission()  : ApiPermissionDeniedShape|null
Guards one Api action. Returns `null` to continue, or an array to return immediately.
resolveApiPresenterName()  : string
Maps `App\UI\Api\Eshop\Presenters\OrderPresenter` to `Api:Eshop:Order`.
resolveCallerFrame()  : string
Returns e.g. `app/UI/Front/Eshop/Presenters/ProductPresenter.php:88`: the first stack frame outside {@see self::CALLER_SKIPPED_PATHS}, i.e. the first place that really asked for the data.
resolveCallerTier()  : string
Returns the caller tier for this presenter and action: `privileged` or `user`.
apiPermissionTokenHint()  : string
Returns the first 12 characters of the Bearer token, in the same shape as `API_RBAC ... token=` from the external gate. On the internal path the `Authorization` header is usually empty, giving `-`.
isInternalTransport()  : bool
Returns true when `Presenter::run()` did not run, i.e. the call is on the internal (bridge) path.
logApiPermissionDecision()  : void
Writes the `API_RBAC` line to the `auth` channel. The format is identical to {@see RbacCheckTrait::logRbacDecision()} (so both parse with one report), extended with: - `path=internal|external`: tells the two modes apart in one log; - `caller=File.php:123`: makes the report a list of pages instead of a list of keys; - `key_exists=yes|no`: see below.
permissionDeniedPayload()  : ApiPermissionDeniedShape
Returns the denial shape, identical to what the external gate sends ({@see RbacCheckTrait::checkApiPermission()}), but returned instead of `sendJson()` because the internal path has nowhere to send an HTTP status.
resolveGuardMode()  : string
Returns the mode for the transport. Never throws: a missing configuration service means the guard is inert (the pre-guard behavior), not deny.

Constants

CALLER_TIER_PRIVILEGED

Caller tier: `privileged` means the grant for this key came from a role with `privileged = 1`; `user` means anything else (`guest`, `registrated`, `owner`) and is also the fail-closed default of all error branches.

protected string CALLER_TIER_PRIVILEGED = 'privileged'

The values are the same strings as $meta['__externalTier'] on the external path (RbacCheckTrait::resolveExternalFieldTier()); it is the same concept from the same core (PermissionResolver::isGrantedByPrivilegedRole()), detected on the other transport. No second decision path exists.

The constants are protected, not private: SaveTrait is flattened into concrete presenters (descendants of Api\BasePresenter), while this trait sits on Api\BasePresenter, so a private constant of the ancestor would be unreachable.

CALLER_BACKTRACE_LIMIT

Depth of `debug_backtrace()` when looking for `caller=` (a limit, not the whole stack).

private int CALLER_BACKTRACE_LIMIT = 14

12 plus the two frames operation() adds (the call and its body closure).

CALLER_SKIPPED_PATHS

Path prefixes (relative to the project root) skipped when looking for `caller=`.

private array<string|int, mixed> CALLER_SKIPPED_PATHS = [ // The Api layer itself. 'app/Core/Traits/Api/', 'app/UI/Api/', // `operation()` calls the body closure of every `internalAction*()`; without this entry // every `caller=` would be that one line (degenerate by construction, not by measurement). 'app/Core/Base/Api/BasePresenter.php', // Forwarding plumbing, see the docblock above. 'app/Core/Bridge/', 'app/Core/Base/Front/Models/', 'app/Core/Base/Admin/Models/', ]

The Api layer itself is skipped because it is "who called the guard", not "who wanted the data". The bridge and generic front/admin model classes are skipped too: with only the Api prefixes caller= was app/Core/Bridge/ApiBridge.php for nearly every line, because internalCall() calls internalAction* by reflection. A report with a single file is useless. The extra prefixes are pure forwarding plumbing, identical for every internal call:

  • app/Core/Bridge/: the transport itself;
  • app/Core/Base/{Front,Admin}/Models/: generic ApiRepository, ApiMapper, ApiService and SingleQuery shared by most front and admin repositories.

The first non-skipped frame is then the first module file (concrete repository, init trait, presenter, component), the one that really asked for the data.

NOTE: before adding another prefix, measure the caller= breakdown in log mode and show that the field is degenerate without it.

Properties

$callerTierReported

Dedup of `API_COLTIER` lines within one request. The key is `kind|rbac key|tier|userId|field|caller`, so only a literally identical line is dropped; a different caller, tier or field set is logged separately.

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

IMPORTANT: it is static, not an instance property: ApiBridge::internalCall() creates a new presenter instance for every call, so an instance property would deduplicate nothing. Only one class uses this trait (BasePresenter), so the storage is one per request. With internalMode: off nothing is ever written to it.

Methods

isCallerTierProbeActive()

Returns whether the `API_COLTIER` measuring probe is active. All three conditions are needed:

protected isCallerTierProbeActive() : bool
  1. apiRbacConfig exists (a presenter outside DI leaves the probe inert, like the guard);
  2. internalMode is not off: then it is false after two comparisons, so no DB queries, no log lines and no debug_backtrace(), exactly like the guard (requireApiPermission());
  3. the call is on the internal (bridge) path; the external path has its own tier mechanism ($meta['__externalTier']).

The order is deliberate: cheapest first, isInternalTransport() is not called in off.

Return values
bool

isOwner()

Default ownership check, fail-closed: without {@see OwnerAwareInterface} on the entity it is always `false`. Presenters with their own semantics (`Bazaar/Comcat Advert` have `getInsertUserId()`; `Base\Rating` has the owner of the rated object, not the author) override it. The generic `getUserId()` fallback with the `> 0` edge lives in `OwnershipCheckTrait`.

protected isOwner(object $entity, int $userId[, array<string|int, mixed> $data = [] ]) : bool

IMPORTANT: never change this to return true; it would open every endpoint with one line.

Parameters
$entity : object

entity loaded by the $owned closure from the DB by the id from the method parameter, never built from request data

$userId : int

ID from the JWT payload, never from a internalAction* parameter

$data : array<string|int, mixed> = []

write payload; on INSERT the only source of identifiers (parent); ownership is never read from it

Return values
bool

isReceiver()

Default check of the `receiver` role: "is the caller the recipient of this record?" (whom it was sent to, not who created it). Fail-closed: without {@see ReceiverAwareInterface} on the entity it is always `false`. {@see self::requireApiPermission()} calls it only when the resolver returned a conditional `receiver` grant (without an unconditional one). Each domain defines its recipient itself (Response: privileged role, advert owner, password capability or a valid `read_key`).

protected isReceiver(object|null $entity, int|null $userId[, array<string|int, mixed> $data = [] ]) : bool

Unlike self::isOwner(), $userId may be null: the recipient can also be an anonymous editor with a capability or the holder of a key from an e-mail link.

IMPORTANT: never change this to return true; it would let anyone read records addressed to others.

Parameters
$entity : object|null

entity loaded by the $owned closure from the DB by the id from the method parameter, never from request data; null means nothing to judge

$userId : int|null

ID from the JWT payload, null means no identity

$data : array<string|int, mixed> = []

explicit parameters of the internal call supplied by the calling action (e.g. ['readKey' => ...] for markReadByKey); never present on the external path

Return values
bool

reportCallerTierGap()

Writes one machine-parsable `API_COLTIER` line to the `auth` channel. The format follows `API_RBAC ...` from {@see logApiPermissionDecision()}, so both parse with one report:

protected reportCallerTierGap(string $kind, string $action, string $tier, string $label, array<int, string> $fields) : void
API_COLTIER path=internal kind=save key=:Api:Bazaar:Advert:save tier=user \
  userId=11 would_strip=insertUserId,user caller=app/UI/Front/.../EditTrait.php:88

NOTE: the line filters and changes nothing; it only says what a future enforcement would take. An empty set is logged too (would_*=-) as proof that the probe ran there and nothing would break.

Parameters
$kind : string

save or updateColumns

$action : string

camelCase literal of the action (to build the RBAC key)

$tier : string
$label : string

field name in the log: would_strip or would_drop

$fields : array<int, string>

requireApiPermission()

Guards one Api action. Returns `null` to continue, or an array to return immediately.

protected requireApiPermission(string $action[, Closure|null $owned = null ][, array<string|int, mixed> $ownedData = [] ]) : ApiPermissionDeniedShape|null
Parameters
$action : string

camelCase literal of the action (getAll, updateColumns, moveSubtreeTo); never getAction() and never kebab-case from the URL

$owned : Closure|null = null

lazy entity loader for ALLOW_IF_OWNER; called only when the resolver returns a conditional grant, so an admin delete does not pay a DB query for a check that never runs; null means fail-closed DENY

$ownedData : array<string|int, mixed> = []

write payload passed to self::isOwner() for the insert branch ($entity === null), where it is the only source of the parent identifier; empty for all other actions

Tags
throws
AbortException

propagated unchanged (see the catch below)

Return values
ApiPermissionDeniedShape|null —

exact shape of permissionDeniedPayload() instead of a generic array<string, mixed>; guarded methods with their own precise @return array{...} union this type into theirs

resolveApiPresenterName()

Maps `App\UI\Api\Eshop\Presenters\OrderPresenter` to `Api:Eshop:Order`.

protected static resolveApiPresenterName(class-string $class) : string

Inverse of the mapping in config/common.neon (Api: [App\UI, Api\*, Presenters\*Presenter]); ApiPermissionCoverageTest verifies it against the live Nette\Application\PresenterFactory.

IMPORTANT: the module is deliberately only one segment. The Nette mapping Api\*\ behaves oddly for multi-level names (Api:A:B:C maps to App\UI\Api\A\Api\B\Presenters\CPresenter), so "smart" extra segments would silently build a key that does not exist in the DB. Fail-closed: an unknown class shape throws and ends as DENY, no guessing.

Parameters
$class : class-string
Tags
throws
LogicException

when the class does not match the shape of an Api presenter

Return values
string

resolveCallerFrame()

Returns e.g. `app/UI/Front/Eshop/Presenters/ProductPresenter.php:88`: the first stack frame outside {@see self::CALLER_SKIPPED_PATHS}, i.e. the first place that really asked for the data.

protected static resolveCallerFrame() : string

The path is relative to the project root, not a basename: two ProductPresenter.php in two modules would otherwise merge into one report line.

Return values
string

resolveCallerTier()

Returns the caller tier for this presenter and action: `privileged` or `user`.

protected resolveCallerTier(string $action) : string

Decided solely by PermissionResolver::isGrantedByPrivilegedRole(), the same method the external path uses (RbacCheckTrait::resolveExternalFieldTier()), so there is one decision core. Only the key source differs: the external path uses $this->rbacKey; the internal path uses static::class plus the literal $action, because on the bridge getName() is null and getAction() is '', which would build the key '::'.

Cost: isGrantedByPrivilegedRole() reuses grantingRoleCodes(), which runs from the same per-request branchCache as decide() in the guard, so it is an isset(). The only extra query per request is privilegedRoleCodes(), also request-cached. No own cache is added on purpose: a second cache is a second place to forget invalidation.

Fail-closed to user (the stricter tier): a missing resolver (presenter outside DI) and any exception.

Parameters
$action : string

camelCase literal of the action, the same one requireApiPermission() gets

Return values
string

apiPermissionTokenHint()

Returns the first 12 characters of the Bearer token, in the same shape as `API_RBAC ... token=` from the external gate. On the internal path the `Authorization` header is usually empty, giving `-`.

private apiPermissionTokenHint() : string
Return values
string

isInternalTransport()

Returns true when `Presenter::run()` did not run, i.e. the call is on the internal (bridge) path.

private isInternalTransport() : bool

IMPORTANT: do not write it as $this->getRequest() === null. In this nette/application version Presenter::$request is private ?Request $request = null and the final public function getRequest(): Application\Request throws InvalidStateException('Request is not set, presenter is not running.') when no request is set, because its return type is not nullable. A naive null comparison would throw on the internal path.

The catch is narrow (only InvalidStateException) and stands outside the catch-all of the guard; otherwise "no request" would mean DENY instead of "I am internal". The transport is decided by framework state, not by a value from the wire.

Return values
bool

logApiPermissionDecision()

Writes the `API_RBAC` line to the `auth` channel. The format is identical to {@see RbacCheckTrait::logRbacDecision()} (so both parse with one report), extended with: - `path=internal|external`: tells the two modes apart in one log; - `caller=File.php:123`: makes the report a list of pages instead of a list of keys; - `key_exists=yes|no`: see below.

private logApiPermissionDecision(string $mode, bool $internal, string $key, string $presenter, string $action, int|null $userId, PermissionDecision $raw, PermissionDecision $effective, bool $denied) : void

mode= is always the mode of this path (internalMode internally, mode externally), otherwise the report would claim something other than what was just evaluated.

IMPORTANT: key_exists=no separates two cases that decision=DENY merges: "the key exists in the policy and the caller has no right to it" and "the key is not in cms_system_user_role_privileges at all" (a typo in the literal, a forgotten seed). A method that reuses a foreign literal gets ALLOW from decide(), but not for its own policy. It is computed by PermissionResolver::hasKey(), the same probe SaveTrait::refineSaveAction() uses. Radar for this class of mistake: grep 'API_RBAC.*key_exists=no' auth.log.

NOTE: it is not free as in SaveTrait. hasKey() reads the anonymous branch of branchCache (rowsForKey(null, ...)), while decide() reads the caller with their $userId; only anonymous callers share the cache. For a logged-in caller it is one small indexed query, paid only when the line is actually written (log always, enforce only on DENY), the same cost as caller=.

caller= (i.e. debug_backtrace()) is computed only when the line is written, otherwise it would be a needless cost on every request. DEBUG_BACKTRACE_IGNORE_ARGS is required: a backtrace with arguments would put huge objects into the log.

Parameters
$mode : string
$internal : bool
$key : string
$presenter : string
$action : string
$userId : int|null
$raw : PermissionDecision
$effective : PermissionDecision
$denied : bool

permissionDeniedPayload()

Returns the denial shape, identical to what the external gate sends ({@see RbacCheckTrait::checkApiPermission()}), but returned instead of `sendJson()` because the internal path has nowhere to send an HTTP status.

private static permissionDeniedPayload() : ApiPermissionDeniedShape
Return values
ApiPermissionDeniedShape

resolveGuardMode()

Returns the mode for the transport. Never throws: a missing configuration service means the guard is inert (the pre-guard behavior), not deny.

private resolveGuardMode(bool $internal) : string

$apiRbacConfig can be missing only in a presenter built outside DI (new XPresenter() in a harness), where no RBAC is evaluated anyway. isset() on an uninitialized typed property returns false without throwing.

Parameters
$internal : bool
Return values
string
On this page

Search results