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:
- 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. - The action is a literal parameter. On the internal path
getAction()is''andgetName()isnull, which would build the key'::'. The presenter is derived fromstatic::class(self::resolveApiPresenterName()). Deriving the action fromdebug_backtrace()is forbidden (fragile, slow, different name for traits). IMPORTANT: the literal must be camelCase as in the seed (updateColumns, notupdate-columns); otherwiseApiPermissionCoverageTestfails. - Identity comes only from
getJwtPayload().internalAction*methods may takeuserId(internalActionSummary,internalActionMyOrders,internalActionRepeatItems), but it is the caller's claim, not identity, and the guard must not touch it. - 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_TIER_USER
protected
string
CALLER_TIER_USER
= 'user'
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/: genericApiRepository,ApiMapper,ApiServiceandSingleQueryshared 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
apiRbacConfigexists (a presenter outside DI leaves the probe inert, like the guard);internalModeis notoff: then it isfalseafter two comparisons, so no DB queries, no log lines and nodebug_backtrace(), exactly like the guard (requireApiPermission());- 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
boolisOwner()
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
$ownedclosure 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
boolisReceiver()
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
$ownedclosure from the DB by the id from the method parameter, never from request data;nullmeans nothing to judge - $userId : int|null
-
ID from the JWT payload,
nullmeans no identity - $data : array<string|int, mixed> = []
-
explicit parameters of the internal call supplied by the calling action (e.g.
['readKey' => ...]formarkReadByKey); never present on the external path
Return values
boolreportCallerTierGap()
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
-
saveorupdateColumns - $action : string
-
camelCase literal of the action (to build the RBAC key)
- $tier : string
- $label : string
-
field name in the log:
would_striporwould_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); nevergetAction()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 admindeletedoes not pay a DB query for a check that never runs;nullmeans 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
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
Return values
stringresolveCallerFrame()
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
stringresolveCallerTier()
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
stringapiPermissionTokenHint()
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
stringisInternalTransport()
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
boollogApiPermissionDecision()
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
ApiPermissionDeniedShaperesolveGuardMode()
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