CoolCMS API

PermissionResolver
in package

FinalYes

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'

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

$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 = []

$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

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
bool

decide()

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
PermissionDecision

grantingRoleCodes()

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
bool

isGrantedByPrivilegedRole()

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
bool

branchPrefix()

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
string

fetchBranchMap()

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|null

privilegedRoleCodes()

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}>|null

rowsForKey()

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)

On this page

Search results