PermissionResolver
in package
RBAC v2 decision core.
Set-based, DBAL only (no ORM), and fail-closed: every Exception is logged to the
auth channel and resolved as a denial.
IMPORTANT: this class is a pure lookup, with no tree, inheritance or fallback. A privilege key
with no row for the caller's roles is denied, exactly like a key that does not exist. Do not add
parent-key walking (rtrim); that model was removed on purpose.
IMPORTANT: privilege groups (cms_system_privilege_groups) are an admin UI concept and must
never appear in this class' SQL; PermissionResolverTest checks the
source of this file.
Role ceiling of the application key: an external request made with an application key
(X-API-Key) may use only the roles that client allows (ApiClientContext). The ceiling
is applied to the rows every public method reads (rowsForKey()), so it covers explicit,
implicit (guest, registrated) and conditional (owner, receiver) roles, and the privileged
field tier (isGrantedByPrivilegedRole()) follows it: an admin calling through a client that
does not allow admin is decided and filtered like a plain registered user. No context (web,
administration, cron) means no ceiling. hasKey() ignores the ceiling; it asks whether the
key exists in the policy, not whether this caller may use it.
Table of Contents
Constants
- ROLE_OWNER : string = 'owner'
- Codes of the conditional roles (`conditional = 1`). A conditional grant allows nothing by itself; the presenter check of that role must confirm it ({@see self::conditionalGrantHolds()}): `owner` uses `verifyOwnership()`/`isOwner()` ("my record"), `receiver` uses `verifyReceiver()`/`isReceiver()` ("addressed to me"). An unknown conditional role never passes.
- ROLE_RECEIVER : string = 'receiver'
- SELECT_FOR_ANONYMOUS : string = <<<'SQL' SELECT priv.`key` AS `key`, role.`code...
- The anonymous branch also sees `conditional = 1` roles, not only `implicit = 'anonymous'`.
- SELECT_FOR_AUTHENTICATED : string = <<<'SQL' SELECT priv.`key` AS `key`, role.`code...
Properties
- $apiClientContext : ApiClientContext|null
- $branchCache : array<string, array<string, array<int, array{roleCode: string, conditional: bool, allowed: bool}>>>
- Per-request cache keyed by `"<userId|anon>|<branchPrefix>"`, holding the decision-relevant rows keyed by exact privilege `key`. One query per distinct (userId, branchPrefix) combination, never per key.
- $connection : Connection
- $privilegedRoleCodesCache : array<int, string>|null
- Role codes (`cms_system_user_roles.code`) with `privileged = 1`, today only `admin`. No role name is hardcoded in this decision path. Loaded once per request and used by the field tier ({@see isGrantedByPrivilegedRole()}). `null` means not loaded yet; `[]` is a valid loaded-empty result (fail-closed on a query error).
Methods
- __construct() : mixed
- conditionalGrantHolds() : bool
- Confirms a conditional grant: for each conditional role of the caller that allows the key (`conditional = 1`, `allowed = 1`), calls that role's check from `$checks` (keyed by role code); one passing check is enough. A role without a check in `$checks` yields `false`, so an unknown conditional role allows nothing.
- decide() : PermissionDecision
- Exact lookup, any of the caller's roles; a missing row is a denial. An unconditional grant (`conditional=0`) always wins over a conditional one on the same key.
- grantingRoleCodes() : array<int, string>
- Returns the role codes among the caller's roles that grant (`allowed=1`) the key, both unconditional and conditional. The field tier uses it to tell an admin-granted key apart from an owner-only one. Unlike `decide()` it does not collapse to "unconditional wins"; the caller gets the raw set.
- hasKey() : bool
- Returns whether any row exists for the key in the policy.
- isGrantedByPrivilegedRole() : bool
- Field tier (`#[ExternalAdminOnly]`): returns whether at least one of the caller's roles granting `$key` (among {@see grantingRoleCodes()}) is a `privileged = 1` role.
- branchPrefix() : string
- Maps `:Api:Eshop:Order:save` to `:Api`. A key with no second colon (e.g. `:Admin`) is its own prefix.
- fetchBranchMap() : array<string, array<int, array{roleCode: string, conditional: bool, allowed: bool}>>
- One query per distinct (userId, branchPrefix) pair. Fail-closed: any {@see DbalException} is logged and resolved as an empty map, so every key under that prefix is denied for the rest of the request (the failure is not cached as success, but not retried either).
- loadBranchMap() : array<string, array<int, array{roleCode: string, conditional: bool, allowed: bool}>>
- normalizeIdentity() : int|null
- Translates an empty identity to anonymous. This is the only place where `?int $userId` becomes a query branch, so it is the only place where that edge may be decided.
- privilegedRoleCodes() : array<int, string>
- restrictToClientRoles() : array<int, array{roleCode: string, conditional: bool, allowed: bool}>|null
- Drops rows of roles the current application key does not allow (see the class docblock).
- rowsForKey() : array<int, array{roleCode: string, conditional: bool, allowed: bool}>|null
Constants
ROLE_OWNER
Codes of the conditional roles (`conditional = 1`). A conditional grant allows nothing by itself; the presenter check of that role must confirm it ({@see self::conditionalGrantHolds()}): `owner` uses `verifyOwnership()`/`isOwner()` ("my record"), `receiver` uses `verifyReceiver()`/`isReceiver()` ("addressed to me"). An unknown conditional role never passes.
public
string
ROLE_OWNER
= 'owner'
ROLE_RECEIVER
public
string
ROLE_RECEIVER
= 'receiver'
SELECT_FOR_ANONYMOUS
The anonymous branch also sees `conditional = 1` roles, not only `implicit = 'anonymous'`.
private
string
SELECT_FOR_ANONYMOUS
= <<<'SQL'
SELECT priv.`key` AS `key`, role.`code` AS role_code, role.`conditional` AS `conditional`, rel.`allowed` AS `allowed`
FROM cms_system_user_role_privileges priv
JOIN cms_system_user_role_privilege_relations rel
ON rel.cms_system_user_role_privileges_user_role_privilege_id = priv.user_role_privilege_id
JOIN cms_system_user_roles role
ON role.user_role_id = rel.cms_system_user_roles_user_role_id
WHERE priv.`key` LIKE :prefix ESCAPE '\\'
AND (role.`implicit` = 'anonymous' OR role.`conditional` = 1)
SQL
Whoever has to prove ownership (by login or by a capability token with userId = 0) must
reach the same isOwner() question by both paths; owner is currently the only such role.
IMPORTANT: this alone allows nothing. A conditional = 1 row yields at most
PermissionDecision::ALLOW_IF_OWNER, never an unconditional ALLOW. The decision is made by
isOwner() in ApiPermissionGuardTrait::requireApiPermission(),
which is not called while userId is null, so an anonymous caller without identity or
capability token gets no answer. Fail-closed is held by the guard, not by this SQL condition.
SELECT_FOR_AUTHENTICATED
private
string
SELECT_FOR_AUTHENTICATED
= <<<'SQL'
SELECT priv.`key` AS `key`, role.`code` AS role_code, role.`conditional` AS `conditional`, rel.`allowed` AS `allowed`
FROM cms_system_user_role_privileges priv
JOIN cms_system_user_role_privilege_relations rel
ON rel.cms_system_user_role_privileges_user_role_privilege_id = priv.user_role_privilege_id
JOIN cms_system_user_roles role
ON role.user_role_id = rel.cms_system_user_roles_user_role_id
WHERE priv.`key` LIKE :prefix ESCAPE '\\'
AND (
role.`implicit` = 'authenticated'
OR role.user_role_id IN (
SELECT userRoleRel.cms_system_user_roles_user_role_id
FROM cms_system_user_role_relations userRoleRel
WHERE userRoleRel.cms_system_users_user_id = :userId
)
)
SQL
Properties
$apiClientContext read-only
private
ApiClientContext|null
$apiClientContext
= null
$branchCache
Per-request cache keyed by `"<userId|anon>|<branchPrefix>"`, holding the decision-relevant rows keyed by exact privilege `key`. One query per distinct (userId, branchPrefix) combination, never per key.
private
array<string, array<string, array<int, array{roleCode: string, conditional: bool, allowed: bool}>>>
$branchCache
= []
$connection read-only
private
Connection
$connection
$privilegedRoleCodesCache
Role codes (`cms_system_user_roles.code`) with `privileged = 1`, today only `admin`. No role name is hardcoded in this decision path. Loaded once per request and used by the field tier ({@see isGrantedByPrivilegedRole()}). `null` means not loaded yet; `[]` is a valid loaded-empty result (fail-closed on a query error).
private
array<int, string>|null
$privilegedRoleCodesCache
= null
Methods
__construct()
public
__construct(Connection $connection[, ApiClientContext|null $apiClientContext = null ]) : mixed
Parameters
- $connection : Connection
- $apiClientContext : ApiClientContext|null = null
conditionalGrantHolds()
Confirms a conditional grant: for each conditional role of the caller that allows the key (`conditional = 1`, `allowed = 1`), calls that role's check from `$checks` (keyed by role code); one passing check is enough. A role without a check in `$checks` yields `false`, so an unknown conditional role allows nothing.
public
conditionalGrantHolds(int|null $userId, string $key, array<string, callable(): bool> $checks) : bool
Call it only after decide() === ALLOW_IF_OWNER, i.e. when no unconditional grant exists.
The checks are lazy closures and only the one of a role that really allows the key runs, so an
owner key never pays for the receiver query and vice versa. An exception from a check
propagates to the caller; both gates catch it and deny (fail-closed).
Parameters
- $userId : int|null
- $key : string
- $checks : array<string, callable(): bool>
-
conditional role code to its check
Return values
booldecide()
Exact lookup, any of the caller's roles; a missing row is a denial. An unconditional grant (`conditional=0`) always wins over a conditional one on the same key.
public
decide(int|null $userId, string $key) : PermissionDecision
Parameters
- $userId : int|null
- $key : string
Return values
PermissionDecisiongrantingRoleCodes()
Returns the role codes among the caller's roles that grant (`allowed=1`) the key, both unconditional and conditional. The field tier uses it to tell an admin-granted key apart from an owner-only one. Unlike `decide()` it does not collapse to "unconditional wins"; the caller gets the raw set.
public
grantingRoleCodes(int|null $userId, string $key) : array<int, string>
Parameters
- $userId : int|null
- $key : string
Return values
array<int, string>hasKey()
Returns whether any row exists for the key in the policy.
public
hasKey(string $key) : bool
It separates two states that self::decide() merges into DENY: the key exists and the
caller has no right to it, versus the key is not in the policy. It is used by the save to
insert/update refinement (SaveTrait::refineSaveAction()):
until the refined key is seeded, the decision falls back to save, i.e. today's policy, not to
a DENY that would break even the administrator.
This does not weaken fail-closed: the fallback save key is itself restrictive and seeded. A
typo in the literal (insrt) ends at save, so it neither breaks nor opens anything.
It reads the same per-request branchCache that the first decide() filled.
Parameters
- $key : string
Return values
boolisGrantedByPrivilegedRole()
Field tier (`#[ExternalAdminOnly]`): returns whether at least one of the caller's roles granting `$key` (among {@see grantingRoleCodes()}) is a `privileged = 1` role.
public
isGrantedByPrivilegedRole(int|null $userId, string $key) : bool
It reuses grantingRoleCodes() (same rows, same cache); the only additional query is the tiny,
request-cached privilegedRoleCodes() lookup.
Parameters
- $userId : int|null
- $key : string
Return values
boolbranchPrefix()
Maps `:Api:Eshop:Order:save` to `:Api`. A key with no second colon (e.g. `:Admin`) is its own prefix.
private
static branchPrefix(string $key) : string
Parameters
- $key : string
Return values
stringfetchBranchMap()
One query per distinct (userId, branchPrefix) pair. Fail-closed: any {@see DbalException} is logged and resolved as an empty map, so every key under that prefix is denied for the rest of the request (the failure is not cached as success, but not retried either).
private
fetchBranchMap(int|null $userId, string $branchPrefix) : array<string, array<int, array{roleCode: string, conditional: bool, allowed: bool}>>
Parameters
- $userId : int|null
- $branchPrefix : string
Return values
array<string, array<int, array{roleCode: string, conditional: bool, allowed: bool}>>loadBranchMap()
private
loadBranchMap(int|null $userId, string $branchPrefix) : array<string, array<int, array{roleCode: string, conditional: bool, allowed: bool}>>
Parameters
- $userId : int|null
- $branchPrefix : string
Return values
array<string, array<int, array{roleCode: string, conditional: bool, allowed: bool}>>normalizeIdentity()
Translates an empty identity to anonymous. This is the only place where `?int $userId` becomes a query branch, so it is the only place where that edge may be decided.
private
static normalizeIdentity(int|null $userId) : int|null
IMPORTANT: JwtPayload::$userId is a non-nullable int defaulting to 0, so a token without
identity arrives as 0, and 0 !== null would send it down self::SELECT_FOR_AUTHENTICATED.
Its where is satisfied by role.implicit = 'authenticated' whatever the user 0 is, so such a
caller would lose the guest grants (functional outage, a capability token would break) and
gain the conditional owner grant, reaching the ownership branch. Today this does not escalate
only because every isOwner()/verifyOwnership()/ownershipMatches() implementation
remembers the > 0 edge on its own; one forgotten edge would be an escalation. The fail-closed
edge belongs here, in the core.
The anonymous branch now also sees conditional = 1 roles (self::SELECT_FOR_ANONYMOUS),
so null on an owner key also gives ALLOW_IF_OWNER. The translation of 0, -1 and
PHP_INT_MIN to null still applies and also protects a future unconditional
implicit = 'authenticated' role, which a misrouted 0 would otherwise see.
The boundary is the resolver, not its callers: a fix here covers paths that do not exist yet
and cannot be bypassed by writing a new caller. The current callers
(RbacCheckTrait,
ApiPermissionGuardTrait) pass
getJwtPayload()?->getUserId() unchanged.
NOTE: it translates to null rather than denying because an empty identity is a legitimate
state: such a caller is guest, not nobody.
Parameters
- $userId : int|null
Return values
int|nullprivilegedRoleCodes()
private
privilegedRoleCodes() : array<int, string>
Return values
array<int, string>restrictToClientRoles()
Drops rows of roles the current application key does not allow (see the class docblock).
private
restrictToClientRoles(array<int, array{roleCode: string, conditional: bool, allowed: bool}>|null $rows) : array<int, array{roleCode: string, conditional: bool, allowed: bool}>|null
The branch cache keeps the unrestricted rows; the ceiling is a cheap filter on read.
Parameters
- $rows : array<int, array{roleCode: string, conditional: bool, allowed: bool}>|null
Return values
array<int, array{roleCode: string, conditional: bool, allowed: bool}>|nullrowsForKey()
private
rowsForKey(int|null $userId, string $key[, bool $applyClientRoles = true ]) : array<int, array{roleCode: string, conditional: bool, allowed: bool}>|null
Parameters
- $userId : int|null
- $key : string
- $applyClientRoles : bool = true
Return values
array<int, array{roleCode: string, conditional: bool, allowed: bool}>|null —null when the key is malformed or not in the branch map (both resolve to a denial)