RatingPresenter
extends BasePresenter
in package
uses
GetTrait, GetByTrait, GetAllTrait, DeleteTrait, SaveTrait, OwnershipCheckTrait
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'
- SESSION_NAMESPACE : mixed = 'api'
- 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
- $accessTokenValidator : AccessTokenValidator
- The rule "signature + expiry + `jti` deny-list" has a single implementation ({@see AccessTokenValidator}), shared by this guard and the admin/front `checkAuthentication()`.
- $allowedMethods : array<string|int, mixed>
- $apiClientContext : ApiClientContext
- IMPORTANT: access only through {@see self::apiClientContext()}. A presenter built outside DI (unit tests) lacks it, and reading an uninitialized typed property throws `Error`.
- $apiClientKeyConfig : ApiClientKeyConfig
- $apiClientManager : ApiClientManager
- $apiClientRateLimiter : ApiClientRateLimiter
- $apiRbacConfig : ApiRbacConfig
- $authLogManager : AuthLogManager
- $corsConfig : CorsConfig
- $currencyContext : CurrencyContext
- $currencyManager : CurrencyManager
- $fieldAudienceContext : FieldAudienceContext
- Audience of the caller for the emit filter ({@see \App\Core\Attributes\Hydration\FieldAccess}).
- $jtiDenyList : JtiDenyList
- $jwtGuardConfig : JwtGuardConfig
- $jwtManager : JwtManager
- $lang : int|string|null
- $languageContext : LanguageContext
- $languageManager : LanguageManager
- $localizationContext : LocalizationContext
- $localizationManager : LocalizationManager
- $localizationPaymentRegionManager : PaymentRegionManager
- NOTE: deliberately named differently; presenters often have `$paymentRegionManager` in the constructor (property collision).
- $operationScope : OperationScope
- Boundary of one Api operation (see {@see self::operation()}).
- $pageManager : PageManager
- $permissionResolver : PermissionResolver
- $personalDataPolicy : PersonalDataPolicy
- Personal data policy applied to the output (see {@see self::sendJson()}).
- $presenterContext : PresenterContext
- $queryCache : QueryCache
- $queryPathPolicy : QueryPathPolicy
- Policy for field paths in filters and ordering (see {@see self::rejectForbiddenQueryPaths()}).
- $securityConfig : SecurityConfig
- $settingContext : SettingContext
- $settingManager : SettingManager
- $debugMode : bool
- $manager : Manager|null
- $productionMode : bool
- $anonymousDenyReported : array<string, true>
- $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.
- $hydrator : OrmEntityHydrator
- $jwtPayload : JwtPayload|null
- $ratingManager : RatingManager
- $ratingVoteManager : RatingVoteManager
- $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
- __construct() : mixed
- actionDelete() : void
- actionDeleteAll() : void
- actionGet() : void
- actionGetAll() : void
- actionGetBy() : void
- actionSave() : void
- actionSaveReply() : void
- External counterpart of {@see self::internalActionSaveReply()}.
- actionUpdateColumns() : void
- collectWriteAudiences() : array<string, Audience>
- Returns the `insert`/`update` axis audience for every entity field with `#[FieldAccess]`; the key is the name without underscores, lowercase.
- getApiClient() : ApiClientIdentity|null
- Returns the client whose key came with this request; `null` means no key (web or admin through the bridge, a `#[NoClientKey]` exception, or `required: false` without a key).
- getCurrencyContext() : CurrencyContext
- getJwtPayload() : JwtPayload|null
- getLanguageContext() : LanguageContext
- getPersonalDataCapabilityRows() : array<string, array<int, int>>
- Returns the records whose personal data the caller receives in full thanks to a capability (advert password).
- internalActionCanRespondTo() : array<string|int, mixed>
- Returns whether the caller may respond to the given review.
- internalActionDelete() : array<string, mixed>
- internalActionDeleteAll() : array<string, mixed>
- internalActionGet() : array<string, mixed>
- internalActionGetAll() : array<string, mixed>
- internalActionGetBy() : array<string, mixed>
- internalActionMyVotes() : array<string|int, mixed>
- Returns how this visitor voted on the displayed ratings (one batch for the whole page), so the UI can highlight the choice and not offer it again.
- internalActionReport() : array<string|int, mixed>
- Reports an inappropriate review.
- internalActionSave() : array<string, int|string|false|null>|array<string, mixed>
- Wraps the generic `save` action and closes the back door to responses.
- internalActionSaveReply() : array<string|int, mixed>
- Saves the owner's response to a rating.
- internalActionUpdateColumns() : array<string, mixed>
- internalActionVote() : array<string|int, mixed>
- Registers a vote on the usefulness of one rating ("was this review helpful?").
- isPersonalDataPolicyExempt() : bool
- Returns whether the response of the action skips the personal data policy; the default is no action.
- sendJson() : void
- Sends the JSON response after personal data of other users passed through {@see PersonalDataPolicy}.
- setApiCallSource() : void
- Sets the call source; only {@see \App\Core\Bridge\ApiBridge::internalCall()} calls it (same pattern as `setQueryCacheEnabled()`).
- setAuthenticatedPayload() : void
- Pre-populate the authenticated payload from a trusted caller (ApiBridge internal mode). Internal calls skip startup() and therefore the Bearer middleware — this setter is how ApiBridge propagates the calling Front user's identity into the Api presenter so internalAction* methods can see it via getJwtPayload().
- setConfigParameters() : void
- setQueryCacheEnabled() : void
- Sets the query cache flag; only {@see \App\Core\Bridge\ApiBridge::internalCall()} calls it, before `internalAction*()` (same pattern as `setAuthenticatedPayload()`).
- startup() : void
- applyCors() : void
- Applies the CORS headers and short-circuits a preflight (OPTIONS) with a 204 response.
- applyExternalOwner() : array<string, mixed>
- On the external path when creating, sets the record owner from the caller JWT identity.
- applyExternalRemoteIp() : array<string, mixed>
- On the external path when creating, sets the caller IP from the server ({@see \App\Core\Base\Api\Models\Managers\Manager::getExternalRemoteIpData()}).
- checkApiPermission() : void
- Gate entry point; call in `startup()` right after `checkJwtAuth()`.
- checkClientCallerLimit() : void
- Step 4 of an external request: limit of one caller under the key. A logged-in caller is counted by `userId` from the JWT (never from the request), an anonymous one by address.
- checkClientKey() : void
- Step 2 of an external request, see the trait docblock. A rejection ends in `sendJson()` (`AbortException`); otherwise the client is set in the request context.
- checkJwtAuth() : void
- columnsOutsideExternalAllowlist() : array<int, string>
- Returns the columns from `$values` that are not in `manager->getExternalUpdateColumnsAllowlist()`, exactly the ones {@see filterExternalUpdateColumns()} drops on the external path.
- exemptPersonalDataForCapabilityHolder() : void
- IMPORTANT: exempts the capability holder's own record from personal data masking.
- filterExternalMeta() : array<string, mixed>
- Keeps in `$meta` only the keys from `manager->getExternalMetaAllowlist()`; the rest is dropped and logged so a lost external key is visible. The default allowlist is `[]`, so without a per-manager override the whole `$meta` is dropped (privileged flags such as `roleIds`, `groupIds`, `skipNotifications`, `rating.*`, `recalcTopedUntil` have no external way in).
- filterExternalUpdateColumns() : array<string, mixed>
- Keeps in `$values` only the columns from `manager->getExternalUpdateColumnsAllowlist()` (default `[]` rejects external `updateColumns` entirely); the rest is dropped and logged.
- filterReadPayload() : array<string, mixed>
- Read field tier: the last payload filter before the response is sent to the caller.
- getOwnedEntityId() : int|null
- Returns the id of the edited record from `data` (save path), or null for an INSERT.
- getOwnedParentId() : int|null
- Returns the parent id from `data`, e.g. `order` for a complaint or `advert` for a response to an advert.
- getOwnedRequestId() : int|null
- Returns the record id from the request parameters (delete path, `?id=5`), or null.
- 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.
- getRequestParameters() : array<string, mixed>
- Returns the parameters of the current request.
- getSessionKey() : string
- Returns a session key string, e.g. 'front.auth.step1'.
- getSessionSection() : SessionSection
- Returns a Nette\SessionSection instance using a dynamically built session key.
- initializePresenterContext() : void
- isCallerTierProbeActive() : bool
- Returns whether the `API_COLTIER` measuring probe is active. All three conditions are needed:
- isClientKeyExempt() : bool
- Returns whether the current action is exempt from the key (`#[NoClientKey]` on its `action*()` method). An unknown action is not exempt (fail-closed, like {@see JwtAuthTrait::isPublicAction()}).
- isOwner() : bool
- Role `owner`, internal path: the caller is the review author.
- isOwnershipInsert() : bool
- Tells whether the request creates a record (no `id` in `data`).
- isPublicAction() : bool
- Tells whether the current action carries `#[PublicAction]`.
- isQueryCacheEnabled() : bool
- isReceiver() : bool
- Role `receiver`, internal path: the receiver of a rating is the owner of the rated profile.
- isSameSiteRequest() : bool
- Tells whether the request comes from the own site (Nette SameSite cookie); protects GET actions with a side effect.
-
moveGraphKeysToMeta()
: array{0: array
, 1: array } - Moves the keys from `manager->getGraphDataKeys()` from `$data` to `$meta`, so the existing afterSave/beforeSave manager sync traits read them unchanged (same mechanism as the internal `meta`, only from another source).
- operation() : T
- Runs the body of one `internalAction*()` method as one operation (one Api call = one action).
- ownedEntityLoader() : callable(): Array
- Returns a lazy entity loader for `ALLOW_IF_OWNER` (see {@see internalActionSave()}); from `$data` only the identifier is taken. Shared by the trait `internalActionSave()` and by presenters that override `internalActionSave()`.
- ownedForUpdateColumns() : Closure|null
- Returns a lazy entity loader for `ALLOW_IF_OWNER` on `updateColumns`.
- ownsByCapability() : bool
- Returns whether the caller is the owner of the record by a capability in the JWT (not by identity).
- readBodyParameter() : string|null
- Returns a value from the request body: `application/x-www-form-urlencoded` or `multipart` (POST fields) and `application/json`.
- refineSaveAction() : "save"|"insert"|"update"
- Refines the `save` action to `insert` or `update`.
- rejectForbiddenQueryPaths() : array<string|int, mixed>|null
- IMPORTANT: rejects filters, ordering and `columns` that reach fields the caller would not get in the output.
- 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.
- requireManager() : Manager
- Returns the manager for the shared CRUD/tree actions.
- 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`.
- resolveExternalFieldTier() : string
- Resolves the field tier of the caller (`#[ExternalAdminOnly]`).
- saveEntityData() : array<string, mixed>
- Saves the entity without a guard. Call only from `internalActionSave()` after `requireApiPermission()` (the trait and presenters that override `internalActionSave()` for post-save orchestration, e.g. a bell notification). `ApiPermissionCoverageTest` checks that every `internalAction*()` has a guard; this method is not `internalAction*` and the bridge never calls it.
- sendInvalidRequestParameter() : void
- shouldProduceNotifications() : bool
- Returns whether notifications in the bell should be created for this operation.
- stripDefaultSkipFields() : array<string|int, mixed>
- Removes from `$data` the keys listed in `manager->getDefaultSkipFields()` before hydration.
- stripExternalAdminOnlyFields() : array<string|int, mixed>
- Removes from `$data` the keys marked with {@see \App\Core\Attributes\Hydration\ExternalAdminOnly} on the entity `$entityClass`.
- stripExternalReadonlyFields() : array<string|int, mixed>
- Removes from `$data` the keys marked with {@see \App\Core\Attributes\Hydration\ExternalReadonly} on the entity `$entityClass`.
- stripExternalUploadPaths() : array<string, mixed>
- Strips the `path` key from all nested `meta` items on the external boundary.
- stripFieldsOutsideWriteAudience() : array<string|int, mixed>
- Removes from the `$data` root the fields whose `#[FieldAccess]` axis for the operation (`insert` when creating, `update` when editing) does not admit the caller audience.
- verifyOwnership() : bool
- Ownership check for the `:Api:Base:Rating:save` key; the `owner` role is the review author.
- verifyReceiver() : bool
- Role `receiver`, external path (key `:Api:Base:Rating:saveReply`): is the caller the owner of the rated profile?
- apiClientContext() : ApiClientContext|null
- 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 `-`.
- 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.
- extractBearerToken() : string|null
- fieldAudience() : FieldAudienceContext|null
- Returns the audience context, or `null` when the presenter did not get it through DI.
- firstColumnWithoutPrivilegeFreeGrant() : string|null
- Second stage of `updateColumns`: attributes on columns.
- getOwnershipPayload() : array<string|int, mixed>
- Returns the request `data` as an array; an empty array means nothing to verify (the caller then typically returns false, fail-closed).
- 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.
- initializeCurrency() : void
- initializeLanguage() : void
- isInternalTransport() : bool
- Returns true when `Presenter::run()` did not run, i.e. the call is on the internal (bridge) path.
- loadLocalization() : Localization|null
- localizationCurrencyKey() : string|null
- Returns the currency key of the localization's payment region.
- 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.
- logPrivilegeFreeUpdate() : void
- Writes the `API_COLATTR` line for the case where the guard denied but the attributes allowed the write.
- 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.
- normalizeDeleteId() : int|array<int, int>|null
- Normalizes the id: `'5'` to `5`; `['1','2']` to `[1, 2]`; `'abc'`, `''`, `[]`, `0` and `null` to `null`.
- 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.
- probeCallerTierForSave() : void
- Measures the caller tier on the internal path for `save`; changes nothing.
- probeCallerTierForUpdateColumns() : void
- Measures the caller tier on the internal path for `updateColumns`.
- rbacTokenHint() : string
- Returns the first 12 characters of the Bearer token, in the same shape as `JWT_GUARD ... token=`.
- refuseClientKey() : void
- Sends one response for a missing, unknown, disabled, revoked and expired key, so the caller cannot tell which key exists. The reason, client id and key id (`cms_system_api_client_keys`, not the key itself) go only to the log; the key or any part of it never does.
- refuseClientLimit() : void
- Sends 429 with `Retry-After`. Only the first refusal in the window goes to the auth log, so a flood does not flood the log too.
- reportJwtAuthFailure() : void
- 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.
- resultEnvelope() : array<string|int, mixed>
- Converts a {@see Result} to its array envelope.
- updateColumnsDeniedPayload() : ApiPermissionDeniedShape
- Builds the `updateColumns` denial naming the column that caused it.
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'
SESSION_NAMESPACE
protected
mixed
SESSION_NAMESPACE
= 'api'
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
$accessTokenValidator
The rule "signature + expiry + `jti` deny-list" has a single implementation ({@see AccessTokenValidator}), shared by this guard and the admin/front `checkAuthentication()`.
public
AccessTokenValidator
$accessTokenValidator
Attributes
- #[Inject]
$allowedMethods
public
array<string|int, mixed>
$allowedMethods
= ['GET', 'POST', 'HEAD', 'PUT', 'DELETE', 'PATCH', 'OPTIONS']
$apiClientContext
IMPORTANT: access only through {@see self::apiClientContext()}. A presenter built outside DI (unit tests) lacks it, and reading an uninitialized typed property throws `Error`.
public
ApiClientContext
$apiClientContext
Attributes
- #[Inject]
$apiClientKeyConfig
public
ApiClientKeyConfig
$apiClientKeyConfig
Attributes
- #[Inject]
$apiClientManager
public
ApiClientManager
$apiClientManager
Attributes
- #[Inject]
$apiClientRateLimiter
public
ApiClientRateLimiter
$apiClientRateLimiter
Attributes
- #[Inject]
$apiRbacConfig
public
ApiRbacConfig
$apiRbacConfig
Attributes
- #[Inject]
$authLogManager
public
AuthLogManager
$authLogManager
Attributes
- #[Inject]
$corsConfig
public
CorsConfig
$corsConfig
Attributes
- #[Inject]
$currencyContext
public
CurrencyContext
$currencyContext
Attributes
- #[Inject]
$currencyManager
public
CurrencyManager
$currencyManager
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]
$jtiDenyList
public
JtiDenyList
$jtiDenyList
Attributes
- #[Inject]
$jwtGuardConfig
public
JwtGuardConfig
$jwtGuardConfig
Attributes
- #[Inject]
$jwtManager
public
JwtManager
$jwtManager
Attributes
- #[Inject]
$lang
public
int|string|null
$lang
= NULL
$languageContext
public
LanguageContext
$languageContext
Attributes
- #[Inject]
$languageManager
public
LanguageManager
$languageManager
Attributes
- #[Inject]
$localizationContext
public
LocalizationContext
$localizationContext
Attributes
- #[Inject]
$localizationManager
public
LocalizationManager
$localizationManager
Attributes
- #[Inject]
$localizationPaymentRegionManager
NOTE: deliberately named differently; presenters often have `$paymentRegionManager` in the constructor (property collision).
public
PaymentRegionManager
$localizationPaymentRegionManager
Attributes
- #[Inject]
$operationScope
Boundary of one Api operation (see {@see self::operation()}).
public
OperationScope
$operationScope
Attributes
- #[Inject]
$pageManager
public
PageManager
$pageManager
Attributes
- #[Inject]
$permissionResolver
public
PermissionResolver
$permissionResolver
Attributes
- #[Inject]
$personalDataPolicy
Personal data policy applied to the output (see {@see self::sendJson()}).
public
PersonalDataPolicy
$personalDataPolicy
Attributes
- #[Inject]
$presenterContext
public
PresenterContext
$presenterContext
Attributes
- #[Inject]
$queryCache
public
QueryCache
$queryCache
Attributes
- #[Inject]
$queryPathPolicy
Policy for field paths in filters and ordering (see {@see self::rejectForbiddenQueryPaths()}).
public
QueryPathPolicy
$queryPathPolicy
Attributes
- #[Inject]
$securityConfig
public
SecurityConfig
$securityConfig
Attributes
- #[Inject]
$settingContext
public
SettingContext
$settingContext
Attributes
- #[Inject]
$settingManager
public
SettingManager
$settingManager
Attributes
- #[Inject]
$debugMode
protected
bool
$debugMode
= false
$manager read-only
protected
Manager|null
$manager
= null
$productionMode
protected
bool
$productionMode
= false
$anonymousDenyReported
private
static array<string, true>
$anonymousDenyReported
= []
keys of denied guest reads already logged in this request
$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.
$hydrator read-only
private
OrmEntityHydrator
$hydrator
$jwtPayload
private
JwtPayload|null
$jwtPayload
= null
$ratingManager read-only
private
RatingManager
$ratingManager
$ratingVoteManager read-only
private
RatingVoteManager
$ratingVoteManager
$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
__construct()
public
__construct(RatingManager $ratingManager, OrmEntityHydrator $hydrator, RatingVoteManager $ratingVoteManager) : mixed
Parameters
- $ratingManager : RatingManager
- $hydrator : OrmEntityHydrator
- $ratingVoteManager : RatingVoteManager
actionDelete()
public
actionDelete() : void
actionDeleteAll()
public
actionDeleteAll() : void
actionGet()
public
actionGet([int|null $id = null ]) : void
Parameters
- $id : int|null = null
actionGetAll()
public
actionGetAll() : void
actionGetBy()
public
actionGetBy() : void
actionSave()
public
actionSave() : void
actionSaveReply()
External counterpart of {@see self::internalActionSaveReply()}.
public
actionSaveReply() : void
The action only extracts parameters from the request and sends the result of the
internalAction* method as JSON (see AdvertPresenter::actionRegisterClick()). The
external path passes the startup() gate (RbacCheckTrait::checkApiPermission()) with
the same key :Api:Base:Rating:saveReply; the guard in internalAction* is a second,
intentional evaluation.
actionUpdateColumns()
public
actionUpdateColumns() : void
collectWriteAudiences()
Returns the `insert`/`update` axis audience for every entity field with `#[FieldAccess]`; the key is the name without underscores, lowercase.
public
static collectWriteAudiences(class-string $entityClass, bool $isInsert) : array<string, Audience>
The hierarchy is walked from the leaf and the first declaration wins (like
OrmEntityHydrator::collectAllProperties()): a leaf that redeclares a property must carry
the attribute itself. Fields writable by nobody (Audience::None) are already trimmed by
OrmEntityHydrator::stripNobodyWritableFields(); they are
returned here too so the result is complete for the guard test.
Parameters
- $entityClass : class-string
- $isInsert : bool
Return values
array<string, Audience>getApiClient()
Returns the client whose key came with this request; `null` means no key (web or admin through the bridge, a `#[NoClientKey]` exception, or `required: false` without a key).
public
getApiClient() : ApiClientIdentity|null
Return values
ApiClientIdentity|nullgetCurrencyContext()
public
getCurrencyContext() : CurrencyContext
Return values
CurrencyContextgetJwtPayload()
public
getJwtPayload() : JwtPayload|null
Return values
JwtPayload|nullgetLanguageContext()
public
getLanguageContext() : LanguageContext
Return values
LanguageContextgetPersonalDataCapabilityRows()
Returns the records whose personal data the caller receives in full thanks to a capability (advert password).
public
getPersonalDataCapabilityRows(string $action) : array<string, array<int, int>>
The shape is [entity class => [id]] for PersonalDataPolicy::apply(). Only get
returns rows; other actions (lists, getBy) return none. Filled by
self::exemptPersonalDataForCapabilityHolder().
Parameters
- $action : string
Return values
array<string, array<int, int>>internalActionCanRespondTo()
Returns whether the caller may respond to the given review.
public
internalActionCanRespondTo(int $ratingId, int $userId) : array<string|int, mixed>
The front end calls it through ApiBridge::internalCall() (reflection, bypasses
startup()). userId must come from the caller's identity (front end: session, external:
JWT), never from the URL or data, see
FormTrait::onSubmitReply().
The payload is in the data.canRespond subfield of the standard Result envelope,
so the permission denial has a place to go. The caller
(RatingRepository::canRespondTo())
reads data.canRespond and returns false for any other shape (fail-closed).
Parameters
- $ratingId : int
- $userId : int
Return values
array<string|int, mixed>internalActionDelete()
public
internalActionDelete(int|array<string|int, mixed> $id) : array<string, mixed>
Parameters
- $id : int|array<string|int, mixed>
-
one id or an array of ids (bulk delete)
Return values
array<string, mixed> —response envelope
internalActionDeleteAll()
public
internalActionDeleteAll([array<string|int, Filter|array<string, mixed>> $filters = [] ]) : array<string, mixed>
Parameters
- $filters : array<string|int, Filter|array<string, mixed>> = []
Return values
array<string, mixed> —response envelope
internalActionGet()
public
internalActionGet(int|null $id, string|array<string|int, mixed> $includes) : array<string, mixed>
Parameters
- $id : int|null
- $includes : string|array<string|int, mixed>
Return values
array<string, mixed> —response envelope
internalActionGetAll()
public
internalActionGetAll([array<string|int, Filter|array<string, mixed>> $filters = [] ][, array<string|int, mixed>|null $orderBys = null ][, int|null $offset = null ][, int|null $limit = null ][, array<string|int, mixed>|null $columns = null ][, bool $computeTotalCount = false ][, string|array<string|int, mixed> $includes = [] ]) : array<string, mixed>
Parameters
- $filters : array<string|int, Filter|array<string, mixed>> = []
-
filter definitions (shape checked by FilterBuilder::isBuildable())
- $orderBys : array<string|int, mixed>|null = null
- $offset : int|null = null
- $limit : int|null = null
- $columns : array<string|int, mixed>|null = null
- $computeTotalCount : bool = false
- $includes : string|array<string|int, mixed> = []
Return values
array<string, mixed> —response envelope (success, code, message, data, ...)
internalActionGetBy()
public
internalActionGetBy([array<string|int, Filter|array<string, mixed>> $filters = [] ][, string|array<string|int, mixed> $includes = [] ]) : array<string, mixed>
Parameters
- $filters : array<string|int, Filter|array<string, mixed>> = []
- $includes : string|array<string|int, mixed> = []
Return values
array<string, mixed> —response envelope
internalActionMyVotes()
Returns how this visitor voted on the displayed ratings (one batch for the whole page), so the UI can highlight the choice and not offer it again.
public
internalActionMyVotes([string $ratingIds = '' ][, int|null $userId = null ][, string $ip = '' ]) : array<string|int, mixed>
NOTE: internal (bridge-only), same as self::internalActionVote(). The caller sets
userId/ip from the server-side identity; passing a foreign id would return foreign votes.
Parameters
- $ratingIds : string = ''
- $userId : int|null = null
- $ip : string = ''
Return values
array<string|int, mixed>internalActionReport()
Reports an inappropriate review.
public
internalActionReport(int $ratingId[, int|null $userId = null ][, string $ip = '' ]) : array<string|int, mixed>
NOTE: internal (bridge-only), same as vote. A report neither deletes nor hides the
review; it only raises the reports count the admin uses to find it. Only the admin
decides on deletion.
Parameters
- $ratingId : int
- $userId : int|null = null
- $ip : string = ''
Return values
array<string|int, mixed>internalActionSave()
Wraps the generic `save` action and closes the back door to responses.
public
internalActionSave(array<string|int, mixed> $data[, array<string|int, mixed> $meta = [] ][, array<int, string> $skipFields = [] ]) : array<string, int|string|false|null>|array<string, mixed>
Without the wrapper the saveReply key would be decoration: the review author has the
owner grant on :Api:Base:Rating:save and could write a seller response into their own
review, impersonating the rated party.
Rule: when the payload changes the response compared to the DB
(RatingManager::payloadChangesReply(); compared with the DB state, not with another
payload value), the caller must additionally pass the saveReply guard with the same
$owned closure as self::internalActionSaveReply().
Not affected:
- inserting new ratings (also anonymous): the front end sends
response => nulland an insert without a response testsfalsewithout an extra SELECT; - admin moderation (RatingThread):
admin has an unconditional
saveReplygrant; - the author editing their own review, as long as
responseis untouched.
IMPORTANT: the guard order matters. The first guard in the body must be
refineSaveAction($data) in the exact shape ApiPermissionCoverageTest reads as save.
The second evaluation of the same key inside self::genericSaveData() is free
(per-request map in PermissionResolver) and intended.
Parameters
- $data : array<string|int, mixed>
- $meta : array<string|int, mixed> = []
- $skipFields : array<int, string> = []
Return values
array<string, int|string|false|null>|array<string, mixed>internalActionSaveReply()
Saves the owner's response to a rating.
public
internalActionSaveReply(int $ratingId, string|null $response) : array<string|int, mixed>
A response has its own action, key :Api:Base:Rating:saveReply and rule. It is allowed
to the receiver role: the owner of the rated profile, not the review author. The check
is self::isReceiver() (internal path) / self::verifyReceiver() (external).
It must not go through save: the review author has the owner grant on
:Api:Base:Rating:save (RatingManager::isOwnedBy()), but the person allowed to
respond is the owner of the rated object (RatingManager::findRespondableOwner()),
the opposite person.
The $owned closure lazily (only when the resolver returns ALLOW_IF_OWNER) loads from
the DB the User of the rated profile; self::isReceiver() compares its id with
the JWT identity. $ratingId is only the record address, not proof, and $response is
content; identity is never taken from parameters.
IMPORTANT: the privilege row for saveReply must exist in the DB. Without it behavior is
fail-closed (not a 500): with internalMode: off the guard returns null before asking
the resolver, but on the external path (mode: enforce) a missing row denies everyone,
admin included. Apply the privilege SQL before switching internalMode to log/enforce,
otherwise legitimate responses are denied.
Parameters
- $ratingId : int
- $response : string|null
Return values
array<string|int, mixed>internalActionUpdateColumns()
public
internalActionUpdateColumns(array<string|int, mixed> $ids, array<string, mixed> $values) : array<string, mixed>
Parameters
- $ids : array<string|int, mixed>
- $values : array<string, mixed>
Return values
array<string, mixed> —response envelope
internalActionVote()
Registers a vote on the usefulness of one rating ("was this review helpful?").
public
internalActionVote(int $ratingId, bool $up[, int|null $userId = null ][, string $ip = '' ]) : array<string|int, mixed>
IMPORTANT: internal (bridge-only) action; it must not get an action*()/render*()
counterpart. There is deliberately no CRUD over RatingVote: a generic save would
let a caller send a foreign userId and any ip and so bypass deduplication. This is
the only path, and it takes both from the server-side identity (front end: session and
getRemoteAddress()).
Whether anonymous users may vote, and deduplication, are handled by
RatingVoteManager::vote() according to the setting 2/rating.allowAnonymousReviewVote.
Parameters
- $ratingId : int
- $up : bool
- $userId : int|null = null
- $ip : string = ''
Return values
array<string|int, mixed>isPersonalDataPolicyExempt()
Returns whether the response of the action skips the personal data policy; the default is no action.
public
isPersonalDataPolicyExempt(string $action) : bool
Only a presenter that returns exclusively the caller's own data and has no identity in the audience context at response time may override it. Typically login: it returns the account just verified, but the JWT is only being created, so without the exemption users would get their own e-mail masked.
Parameters
- $action : string
Return values
boolsendJson()
Sends the JSON response after personal data of other users passed through {@see PersonalDataPolicy}.
public
sendJson(mixed $data) : void
The policy runs right before sending, after the query cache, where the caller is known. The internal path is handled the same way in ApiBridge.
Parameters
- $data : mixed
setApiCallSource()
Sets the call source; only {@see \App\Core\Bridge\ApiBridge::internalCall()} calls it (same pattern as `setQueryCacheEnabled()`).
public
setApiCallSource(string|null $source) : void
Parameters
- $source : string|null
setAuthenticatedPayload()
Pre-populate the authenticated payload from a trusted caller (ApiBridge internal mode). Internal calls skip startup() and therefore the Bearer middleware — this setter is how ApiBridge propagates the calling Front user's identity into the Api presenter so internalAction* methods can see it via getJwtPayload().
public
setAuthenticatedPayload(JwtPayload|null $payload) : void
Parameters
- $payload : JwtPayload|null
setConfigParameters()
public
setConfigParameters([bool $debugMode = false ][, bool $productionMode = false ]) : void
Parameters
- $debugMode : bool = false
- $productionMode : bool = false
setQueryCacheEnabled()
Sets the query cache flag; only {@see \App\Core\Bridge\ApiBridge::internalCall()} calls it, before `internalAction*()` (same pattern as `setAuthenticatedPayload()`).
public
setQueryCacheEnabled(bool $enabled) : void
A presenter is created anew for every internal call
(PresenterFactoryCallback calls createService(),
not getService()), so the value has nowhere to carry over; the bridge still sets it on
every call and resets it to the default in finally.
Parameters
- $enabled : bool
startup()
public
startup() : void
applyCors()
Applies the CORS headers and short-circuits a preflight (OPTIONS) with a 204 response.
protected
applyCors() : void
IMPORTANT: it must be called in startup() before checkJwtAuth(); a preflight sends no
Authorization, so it would otherwise end with 401.
applyExternalOwner()
On the external path when creating, sets the record owner from the caller JWT identity.
protected
applyExternalOwner(array<string, mixed> $data) : array<string, mixed>
It lives here and not in the manager because the manager does not know the caller identity
(a transport detail). This is the same boundary where #[ExternalReadonly] is stripped and
the __external marker is set: the only place in the chain with the token and certainty that
it is a real external HTTP request. The ownership check, in contrast, is not in the presenter
but in the manager (OwnershipCheckTrait).
It does not run:
- on the internal path (
ApiBridge::internalCall()callsinternalActionSave()directly):$meta['__external']is not set. Front end advert creation fillsinsertUserIdfrom the session itself; if the internal path got here, creating adverts as an anonymous user (advert password) would break. - on UPDATE (
data.idfilled): setting the owner on edit would mean "whoever saves a foreign record owns it", the opposite of#[ExternalReadonly]. - without a token (anonymous or X-API-Key client): there is nobody to set. The record is created without an owner; whether to allow it is a gate decision.
Parameters
- $data : array<string, mixed>
Return values
array<string, mixed>applyExternalRemoteIp()
On the external path when creating, sets the caller IP from the server ({@see \App\Core\Base\Api\Models\Managers\Manager::getExternalRemoteIpData()}).
protected
applyExternalRemoteIp(array<string, mixed> $data) : array<string, mixed>
The IP is IRequest::getRemoteAddress(), a transport value from the HTTP boundary, never a
payload value (that one is already stripped as #[ExternalReadonly]). Same conditions as
applyExternalOwner(): INSERT only; UPDATE keeps the IP from the database.
Parameters
- $data : array<string, mixed>
Return values
array<string, mixed>checkApiPermission()
Gate entry point; call in `startup()` right after `checkJwtAuth()`.
protected
checkApiPermission() : void
Tags
checkClientCallerLimit()
Step 4 of an external request: limit of one caller under the key. A logged-in caller is counted by `userId` from the JWT (never from the request), an anonymous one by address.
protected
checkClientCallerLimit() : void
checkClientKey()
Step 2 of an external request, see the trait docblock. A rejection ends in `sendJson()` (`AbortException`); otherwise the client is set in the request context.
protected
checkClientKey() : void
checkJwtAuth()
protected
checkJwtAuth() : void
columnsOutsideExternalAllowlist()
Returns the columns from `$values` that are not in `manager->getExternalUpdateColumnsAllowlist()`, exactly the ones {@see filterExternalUpdateColumns()} drops on the external path.
protected
columnsOutsideExternalAllowlist(array<string|int, mixed> $values) : array<int, string>
The single implementation of that rule: used by the filter and by the measurement probe
probeCallerTierForUpdateColumns(), so would_drop in the log cannot diverge from what
enforcement would drop. A numeric key ($values[0]) cannot be in the allowlist, so it is
always outside.
Parameters
- $values : array<string|int, mixed>
Return values
array<int, string>exemptPersonalDataForCapabilityHolder()
IMPORTANT: exempts the capability holder's own record from personal data masking.
protected
exemptPersonalDataForCapabilityHolder(int|null $id) : void
An anonymous editor holding the advert password (capability) is an owner for isOwner(),
but PersonalDataPolicy recognizes an owner only by identity (userId). Without this
exemption the anonymous editor would get a masked e-mail (and masked e-mails in parameter
texts) in the edit form, and saving would fail validation or write the mask back.
Called from internalActionGet() after the guard. Without a capability in the token it costs
no queries; with one it costs a single find(). The caller is an owner only for the
response of this get, only when the capability matches this record (scope, id, expiry,
password fingerprint from the DB) and only for the rows of that record (advert and its
parameters). The user from includes, reactions or another advert in the same response
stay masked.
The query cache is unaffected: it stores the hydrator output before the policy, and both the mask and the exemption apply only to the outgoing copy (see PersonalDataPolicy).
Parameters
- $id : int|null
filterExternalMeta()
Keeps in `$meta` only the keys from `manager->getExternalMetaAllowlist()`; the rest is dropped and logged so a lost external key is visible. The default allowlist is `[]`, so without a per-manager override the whole `$meta` is dropped (privileged flags such as `roleIds`, `groupIds`, `skipNotifications`, `rating.*`, `recalcTopedUntil` have no external way in).
protected
filterExternalMeta(array<string|int, mixed> $meta) : array<string, mixed>
Parameters
- $meta : array<string|int, mixed>
Return values
array<string, mixed>filterExternalUpdateColumns()
Keeps in `$values` only the columns from `manager->getExternalUpdateColumnsAllowlist()` (default `[]` rejects external `updateColumns` entirely); the rest is dropped and logged.
protected
filterExternalUpdateColumns(array<string, mixed> $values) : array<string, mixed>
Parameters
- $values : array<string, mixed>
Return values
array<string, mixed>filterReadPayload()
Read field tier: the last payload filter before the response is sent to the caller.
protected
filterReadPayload(array<string, mixed> $payload, string $action) : array<string, mixed>
The default is identity. There is no blanket read tier (#[ExternalReadonly] and
#[ExternalAdminOnly] strip only writes); this hook gives a presenter a place to
implement one when its data requires it. Overridden by
UserPresenter (the owner grant on
:Api:System:User:get would otherwise let an owner read their own password, salt and
activationKey over external HTTP) and by CustomAssetPresenter and PageSectionPresenter
(an unprivileged caller gets active records only, see InactiveRecordsReadFilterTrait).
IMPORTANT: it runs after the cache (QueryCache), because the result depends on the caller,
not on the query. Filtering inside the cache would freeze the payload shape of the first
caller for everyone else.
It is called from get (GetTrait), getBy
(GetByTrait), getAll
(GetAllTrait) and search
(SearchTrait), each with its action name.
Because the default is identity, only presenters that override the hook are affected.
Parameters
- $payload : array<string, mixed>
- $action : string
Return values
array<string, mixed>getOwnedEntityId()
Returns the id of the edited record from `data` (save path), or null for an INSERT.
protected
getOwnedEntityId() : int|null
See OwnershipPayload::entityId(), which tolerates data[ID]/data[_id]/'5'.
Return values
int|nullgetOwnedParentId()
Returns the parent id from `data`, e.g. `order` for a complaint or `advert` for a response to an advert.
protected
getOwnedParentId(string $property) : int|null
Parameters
- $property : string
-
name of the to-one property on the entity (camelCase, e.g.
order)
Return values
int|nullgetOwnedRequestId()
Returns the record id from the request parameters (delete path, `?id=5`), or null.
protected
getOwnedRequestId() : int|null
NOTE: the router passes parameters as strings ('5'), hence the normalization. id can
also be an array (bulk delete, DeleteTrait::internalActionDelete() takes int|array); then
it returns null because "owner of one record" makes no sense there and the fail-closed
deny is the right answer.
Return values
int|nullgetRbacDecision()
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|nullgetRequestParameters()
Returns the parameters of the current request.
protected
getRequestParameters() : array<string, mixed>
The request is always set during action*(); the helper only turns the nullable
getRequest() into an always-array.
Return values
array<string, mixed>getSessionKey()
Returns a session key string, e.g. 'front.auth.step1'.
protected
getSessionKey([string|null $namespace = null ][, string|null $subsection = null ], string ...$extra) : string
Parameters
- $namespace : string|null = null
-
Base namespace (module prefix). Uses SESSION_NAMESPACE if null.
- $subsection : string|null = null
-
Subsection under the namespace. Uses SESSION_SUBSECTION constant if null.
- $extra : string
-
Additional parts to append (e.g. 'step1', 'wizard', etc.).
Return values
stringgetSessionSection()
Returns a Nette\SessionSection instance using a dynamically built session key.
protected
getSessionSection([string|null $namespace = null ][, string|null $subsection = null ], string ...$extra) : SessionSection
Parameters
- $namespace : string|null = null
- $subsection : string|null = null
- $extra : string
Tags
Return values
SessionSectioninitializePresenterContext()
protected
initializePresenterContext() : void
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
boolisClientKeyExempt()
Returns whether the current action is exempt from the key (`#[NoClientKey]` on its `action*()` method). An unknown action is not exempt (fail-closed, like {@see JwtAuthTrait::isPublicAction()}).
protected
isClientKeyExempt() : bool
Return values
boolisOwner()
Role `owner`, internal path: the caller is the review author.
protected
isOwner(object|null $entity, int $userId[, array<string|int, mixed> $data = [] ]) : bool
A rating has two different owners: the review author (RatingManager::isOwnedBy())
and the owner of the rated object (RatingManager::findRespondableOwner()); the
latter is the receiver role (self::isReceiver()), so isOwner() handles the
author only. When the closure returns a Rating (action save), the rule is
rating.userId === jwt.userId; anything else (including the User from
saveReply) or null is false (fail-closed).
The generic fallback OwnershipCheckTrait::isOwner() is overridden on purpose: it
does not know the action, so every owner-granted action of this presenter would inherit
its getUserId() rule.
IMPORTANT: a new action with an owner grant needs its own $owned closure and a branch
here; otherwise it is denied.
Parameters
- $entity : object|null
- $userId : int
- $data : array<string|int, mixed> = []
-
write payload; on insert the only source of identifiers (parent); ownership is never read from it
Return values
boolisOwnershipInsert()
Tells whether the request creates a record (no `id` in `data`).
protected
isOwnershipInsert() : bool
Ownership is then verified through the parent's ownership or through a server-set owner.
Return values
boolisPublicAction()
Tells whether the current action carries `#[PublicAction]`.
protected
isPublicAction() : bool
IMPORTANT: the visibility is protected (not private) because RbacCheckTrait
uses it too: the RBAC gate must evaluate #[PublicAction] exactly like the JWT guard,
otherwise the two layers diverge. Single source of truth.
An empty action returns false (fail-closed): "action not recognized" must not mean
"public", otherwise a presenter without an action* method would pass without
authentication and authorization. On the external HTTP path the action is never empty
because ApiRouteFactory defaults the route to 'action' => 'show'; the empty case
occurs only in-process or if someone removes that default, and then it ends in
401 (guard) / 403 (gate) instead of a bypass.
Return values
boolisQueryCacheEnabled()
protected
isQueryCacheEnabled() : bool
Tags
Return values
boolisReceiver()
Role `receiver`, internal path: the receiver of a rating is the owner of the rated profile.
protected
isReceiver(object|null $entity, int|null $userId[, array<string|int, mixed> $data = [] ]) : bool
The $owned closure of saveReply returns the User loaded from the DB
(rating, thread, profile; RatingManager::findRespondableOwner()); its id is compared
with the caller's identity (DB against JWT, never payload against payload). Returns false
without an identity, without a profile (null, anonymous or other thread type) and for a
foreign entity type.
Parameters
- $entity : object|null
- $userId : int|null
- $data : array<string|int, mixed> = []
-
unused; the receiver is never read from request data
Return values
boolisSameSiteRequest()
Tells whether the request comes from the own site (Nette SameSite cookie); protects GET actions with a side effect.
protected
isSameSiteRequest() : bool
getHttpRequest() returns the IRequest interface, while isSameSite() exists only on
Nette\Http\Request.
Return values
boolmoveGraphKeysToMeta()
Moves the keys from `manager->getGraphDataKeys()` from `$data` to `$meta`, so the existing afterSave/beforeSave manager sync traits read them unchanged (same mechanism as the internal `meta`, only from another source).
protected
moveGraphKeysToMeta(array<string, mixed> $data, array<string, mixed> $meta) : array{0: array, 1: array}
Explicit meta wins: when a client sends the same key in data and meta (typically admin,
which sends categoryRels in meta as flat rows while hydrator references from the form clone
also land in data), the meta value stays and the data copy is dropped. If data won,
it would be replaced by references and the sync trait would read categoryId=0 from them,
giving delete-all instead of update.
The key is always unset from $data, even when meta won: that is the real hydration
protection, not the move itself (same principle as self::stripDefaultSkipFields()).
Parameters
- $data : array<string, mixed>
- $meta : array<string, mixed>
Return values
array{0: arrayoperation()
Runs the body of one `internalAction*()` method as one operation (one Api call = one action).
protected
operation(string $method, callable(): T $body) : T
Every internalAction*() consists of return $this->operation(__FUNCTION__, function () { ... });;
both transports call it (HTTP from action*(), ApiBridge by reflection), so this is the single
place where an operation begins and ends. The presenter only reports the transport and the
outcome; what a store does about it is up to the participants of OperationScope.
Observation off (the default): only the body runs. On: an operation started inside another one
only joins it. The outcome comes from the returned envelope: success=true succeeded,
success=false rejected (failed for a technical ResultCode), anything else unknown.
An exception is recorded as failed and rethrown unchanged; the result is never changed. The only
exception of its own is OperationContractViolationException after a
body that did not throw (scoped mode, outside production by default): the result is then dropped.
Parameters
- $method : string
- $body : callable(): T
Tags
Return values
TownedEntityLoader()
Returns a lazy entity loader for `ALLOW_IF_OWNER` (see {@see internalActionSave()}); from `$data` only the identifier is taken. Shared by the trait `internalActionSave()` and by presenters that override `internalActionSave()`.
protected
ownedEntityLoader(array<string, mixed> $data) : callable(): Array
Parameters
- $data : array<string, mixed>
Return values
callable(): ArrayownedForUpdateColumns()
Returns a lazy entity loader for `ALLOW_IF_OWNER` on `updateColumns`.
protected
ownedForUpdateColumns(array<string|int, mixed>|int $ids) : Closure|null
Fail-closed default null: without a closure a conditional grant ends as deny, so this hook
changes nothing for any CRUD presenter until it is overridden.
IMPORTANT: override it only in a presenter that knows what the bulk action changes.
updateColumns is a generic column setter, so "owner" makes sense only for one record; for
several ids the implementation returns null (a collection has no owner).
Parameters
- $ids : array<string|int, mixed>|int
-
id or array of ids from the call
Return values
Closure|nullownsByCapability()
Returns whether the caller is the owner of the record by a capability in the JWT (not by identity).
protected
ownsByCapability(object $entity) : bool
Default false; overridden by a presenter whose records can be edited with a password
(Bazaar/Comcat advert). The decision is made on the entity loaded from the DB, never on
request data.
Parameters
- $entity : object
Return values
boolreadBodyParameter()
Returns a value from the request body: `application/x-www-form-urlencoded` or `multipart` (POST fields) and `application/json`.
protected
readBodyParameter(string $name) : string|null
Use it for secret inputs (passwords, reset and activation keys, tokens) that must not end
up in the URL: the query string is written to web server and proxy access logs and to the
client history. Nette takes action*() parameters only from the URL, so these actions
have no parameters in the signature and read them here.
Returns null when the value is missing from the body or is not a scalar.
Parameters
- $name : string
Return values
string|nullrefineSaveAction()
Refines the `save` action to `insert` or `update`.
protected
refineSaveAction(array<string|int, mixed> $data) : "save"|"insert"|"update"
One key carries one policy, but creating and editing are two different sentences about the
same role: an advert may be created by anyone, even anonymous, but edited only by the owner or
an administrator. That cannot be written on :Api:...:save, hence two refining keys.
Insert is recognized by OwnershipPayload::entityId($data) === null (missing data.id), the
same implementation as the $owned closure and the external verifyOwnership(), so the two
branches cannot diverge.
IMPORTANT: the refining key is used only when it exists in the policy
(PermissionResolver::hasKey()); otherwise 'save' is returned,
i.e. the previous behavior. Without it every presenter would deny even an administrator
between deploying the code and running the SQL. This does not weaken fail-closed: the
fallback is a restrictive seeded key, not a bypass, and a typo in the literal ends at save.
NOTE: the hasKey() probe is free; it reads the per-request branchCache filled by the
first decide() in the guard.
Parameters
- $data : array<string|int, mixed>
Return values
"save"|"insert"|"update"rejectForbiddenQueryPaths()
IMPORTANT: rejects filters, ordering and `columns` that reach fields the caller would not get in the output.
protected
rejectForbiddenQueryPaths(array<string|int, mixed> $filters[, array<string|int, mixed>|null $orderBys = null ][, array<string|int, mixed>|null $columns = null ]) : array<string|int, mixed>|null
Fields hidden by FieldAccess read or PersonalData must not be usable in a query,
otherwise the presence of a record in the response is an oracle (blind extraction of a
password hash through filters[...][field]=user.password with like). The rule is held
by QueryPathPolicy.
Call it in internalAction*() right after the guard (so the audience is set) and before the
first query; it is the single place for getAll, getBy, search, deleteAll,
myList, myOne and the advert lists. Same contract as the guard: null means continue,
an array is the response to return. On the external path it sets HTTP 400 (see
RequestShapeGuardTrait); nothing is silently ignored.
Parameters
- $filters : array<string|int, mixed>
- $orderBys : array<string|int, mixed>|null = null
- $columns : array<string|int, mixed>|null = null
Return values
array<string|int, mixed>|nullreportCallerTierGap()
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
requireManager()
Returns the manager for the shared CRUD/tree actions.
protected
requireManager() : Manager
Presenters without a manager (login, payment callbacks) do not have these actions. If one
is reached anyway, it throws LogicException naming the presenter instead of an
unhelpful "call on null".
Return values
ManagerresolveApiPresenterName()
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
stringresolveExternalFieldTier()
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
stringsaveEntityData()
Saves the entity without a guard. Call only from `internalActionSave()` after `requireApiPermission()` (the trait and presenters that override `internalActionSave()` for post-save orchestration, e.g. a bell notification). `ApiPermissionCoverageTest` checks that every `internalAction*()` has a guard; this method is not `internalAction*` and the bridge never calls it.
protected
saveEntityData(array<string, mixed> $data[, array<string, mixed> $meta = [] ][, array<int, string> $skipFields = [] ]) : array<string, mixed>
Parameters
- $data : array<string, mixed>
- $meta : array<string, mixed> = []
- $skipFields : array<int, string> = []
Return values
array<string, mixed>sendInvalidRequestParameter()
protected
sendInvalidRequestParameter(string $name) : void
Parameters
- $name : string
shouldProduceNotifications()
Returns whether notifications in the bell should be created for this operation.
protected
shouldProduceNotifications() : bool
Admin does not create them; an unknown source does, as a safety net.
Return values
boolstripDefaultSkipFields()
Removes from `$data` the keys listed in `manager->getDefaultSkipFields()` before hydration.
protected
stripDefaultSkipFields(array<string|int, mixed> $data) : array<string|int, mixed>
Name normalization copies the hydrator (OrmEntityHydrator/EntityHydrator::normalizePropertyName,
both private): extract emits keys 1:1 with the property name (camelCase), but a client may
also send the snake_case form of the same field and the strip must not miss it. Extracted
into its own method so it can be unit-tested without DI.
Parameters
- $data : array<string|int, mixed>
Return values
array<string|int, mixed>stripExternalAdminOnlyFields()
Removes from `$data` the keys marked with {@see \App\Core\Attributes\Hydration\ExternalAdminOnly} on the entity `$entityClass`.
protected
stripExternalAdminOnlyFields(array<string|int, mixed> $data, class-string $entityClass) : array<string|int, mixed>
Sibling of stripExternalReadonlyFields(), called only when the caller is not
privileged (see the gate in internalActionSave(), step (0a)). Recursion into owned
ToOne/ToMany associations is the same as for #[ExternalReadonly]:
OrmEntityHydrator::stripExternalAdminOnlyFieldsDeep().
Parameters
- $data : array<string|int, mixed>
- $entityClass : class-string
Return values
array<string|int, mixed>stripExternalReadonlyFields()
Removes from `$data` the keys marked with {@see \App\Core\Attributes\Hydration\ExternalReadonly} on the entity `$entityClass`.
protected
stripExternalReadonlyFields(array<string|int, mixed> $data, class-string $entityClass) : array<string|int, mixed>
Called only on the external HTTP path ($meta['__external'] === true, see
self::internalActionSave()). Protects server-controlled fields (payment, moderation,
topping: typically paid, banned, approvedAt) that a client could otherwise set directly
(privilege escalation). Name normalization copies self::stripDefaultSkipFields():
extract emits keys 1:1 with the property name (camelCase), but a client may also send the
snake_case form of the same field.
The recursion (OrmEntityHydrator::stripExternalReadonlyFieldsDeep())
also trims owned cascade-persist collections and ToOne (items[], shipper, ...). A
top-level-only strip would leave #[ExternalReadonly] on nested entities (OrderItem::$price)
without effect, because OrderCalculator trusts item prices as input.
Extracted into its own method so it can be unit-tested without DI.
Parameters
- $data : array<string|int, mixed>
- $entityClass : class-string
Return values
array<string|int, mixed>stripExternalUploadPaths()
Strips the `path` key from all nested `meta` items on the external boundary.
protected
stripExternalUploadPaths(array<string, mixed> $meta) : array<string, mixed>
IMPORTANT: a reference to a file in the dropzone temp folder ({name, path}) may be sent
only by the internal form through the bridge, because path means "read this file on the
server". It is removed recursively whether the item passed the allowlist or arrived in
data as a graph key (moveGraphKeysToMeta()). A second, independent safeguard is in
the reader (UploadedItem): only an internal call may pass path.
Parameters
- $meta : array<string, mixed>
Return values
array<string, mixed>stripFieldsOutsideWriteAudience()
Removes from the `$data` root the fields whose `#[FieldAccess]` axis for the operation (`insert` when creating, `update` when editing) does not admit the caller audience.
protected
stripFieldsOutsideWriteAudience(array<string|int, mixed> $data, class-string $entityClass, bool $isInsert, array<string, mixed> $meta) : array<string|int, mixed>
Decided by FieldAudienceContext::allows(), the same rule that
filters the emit (read axis). Called only on the external path (step (0a') in
saveEntityData()); the internal bridge (admin, front end, cron) is unchanged.
FieldAccess is used instead of #[ExternalAdminOnly] because the latter cannot tell
creating from editing: Comment::$threadId must be sendable on create but not on edit (a move
into a foreign thread). FieldAccess has those axes and is the declared successor of the old
four attributes; on writes only Audience::None was evaluated before
(OrmEntityHydrator::stripNobodyWritableFields()). This step
also enforces the other audiences, e.g. PaymentGate::$settings and LoginProvider::$clientSecret.
On the external path the audience context is always set (the guard in
requireApiPermission(), called first by internalActionSave(), fills it). If it is not, the
decision is fail-closed by the tier from actionSave(): only Audience::All passes, and for a
privileged caller also Privileged/Owner/Authenticated. Audience::Owner on a write
means the same as on a read, currently as strict as Privileged.
Only the payload root is trimmed (like stripNobodyWritableFields()); keys are compared
ignoring case and underscores, because the hydrator looks up setters just as loosely.
Parameters
- $data : array<string|int, mixed>
- $entityClass : class-string
- $isInsert : bool
- $meta : array<string, mixed>
Return values
array<string|int, mixed>verifyOwnership()
Ownership check for the `:Api:Base:Rating:save` key; the `owner` role is the review author.
protected
verifyOwnership(int $userId) : bool
- Insert: allowed; the server sets the author.
- Update: the rating must belong to the caller as its author (RatingManager::isOwnedBy()).
IMPORTANT: saveReply (response by the owner of the rated object) does not belong here;
it is the receiver role (self::verifyReceiver()). A saveReply branch must not
be added: the owner grant on saveReply is dead, and if restored the review author
would be denied (fail-closed) instead of responding to themselves.
Parameters
- $userId : int
Return values
boolverifyReceiver()
Role `receiver`, external path (key `:Api:Base:Rating:saveReply`): is the caller the owner of the rated profile?
protected
verifyReceiver(int|null $userId) : bool
ratingId is taken from the request only as the record address; who the receiver is is
decided by the SELECT in RatingManager::canRespondTo() and the identity comes from
the JWT. Without an identity always false; other actions have no receiver (fail-closed).
Parameters
- $userId : int|null
Return values
boolapiClientContext()
private
apiClientContext() : ApiClientContext|null
Return values
ApiClientContext|nullapiPermissionTokenHint()
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
stringbuildApiPermissionKey()
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
stringextractBearerToken()
private
extractBearerToken() : string|null
Return values
string|nullfieldAudience()
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|nullfirstColumnWithoutPrivilegeFreeGrant()
Second stage of `updateColumns`: attributes on columns.
private
firstColumnWithoutPrivilegeFreeGrant(int|array<int, mixed> $ids, array<string|int, mixed> $values) : string|null
Returns the name of the first column that a write without the privilege does not allow, or
null when all pass. Called only when the updateColumns guard denied. The whole call is
denied (no partial write), so once one column fails the others need not be checked, and its
name is what whoever debugs the PERMISSION_DENIED needs.
Fate of a column by attribute:
- PubliclyUpdatable: passes;
- ConditionallyUpdatable: the named presenter method is
called for each id; a single
falseblocks the column; - none: blocks.
IMPORTANT: fail-closed on any ambiguity: no manager, no entity class, a missing check method or an exception from it blocks the column. A missing method is a typo in the attribute, guarded by ConditionallyUpdatableContractTest.
Parameters
- $ids : int|array<int, mixed>
- $values : array<string|int, mixed>
Return values
string|nullgetOwnershipPayload()
Returns the request `data` as an array; an empty array means nothing to verify (the caller then typically returns false, fail-closed).
private
getOwnershipPayload() : array<string|int, mixed>
NOTE: on the external HTTP path parameters are filled from the query string; the Nette router does not parse the POST body, so a JSON body is not seen here.
Return values
array<string|int, mixed>getOwnershipRecordId()
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|nullinitializeCurrency()
private
initializeCurrency(int|string|null $defaultCurr) : void
Parameters
- $defaultCurr : int|string|null
initializeLanguage()
private
initializeLanguage(int|string|null $defaultLang) : void
Parameters
- $defaultLang : int|string|null
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
boolloadLocalization()
private
loadLocalization() : Localization|null
Return values
Localization|nulllocalizationCurrencyKey()
Returns the currency key of the localization's payment region.
private
localizationCurrencyKey(object $localization) : string|null
A localization has no currency of its own. Null means a localization without a region or
a region without a currency; callers fall back to eur.
Parameters
- $localization : object
Return values
string|nulllogApiPermissionDecision()
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
logPrivilegeFreeUpdate()
Writes the `API_COLATTR` line for the case where the guard denied but the attributes allowed the write.
private
logPrivilegeFreeUpdate(array<int, string|int> $columns) : void
Without it log/auth.log would hold decision=DENY followed by a successful write, i.e. it
would lie, and later phases read from this log what really happens.
Parameters
- $columns : array<int, string|int>
logRbacDecision()
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
normalizeDeleteId()
Normalizes the id: `'5'` to `5`; `['1','2']` to `[1, 2]`; `'abc'`, `''`, `[]`, `0` and `null` to `null`.
private
normalizeDeleteId(mixed $id) : int|array<int, int>|null
Parameters
- $id : mixed
Return values
int|array<int, int>|nullpermissionDeniedPayload()
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
ApiPermissionDeniedShapeprobeCallerTierForSave()
Measures the caller tier on the internal path for `save`; changes nothing.
private
probeCallerTierForSave(array<string, mixed> $data, class-string $entityClass) : void
Takes $data by value and only writes to the log the answer to: "if the
#[ExternalReadonly]/#[ExternalAdminOnly] strip followed the role instead of the transport,
what would this caller lose?". Active only on the internal path and only when internalMode
is log or enforce (ApiPermissionGuardTrait::isCallerTierProbeActive());
in off it is two comparisons and a return (no queries, no lines).
A privileged caller (currently all of admin) is not logged: the strip would take nothing, so
it would be noise. A tier=privileged line in the measurement would be a bug in tier
resolution, so that branch must return before anything is computed.
Parameters
- $data : array<string, mixed>
- $entityClass : class-string
probeCallerTierForUpdateColumns()
Measures the caller tier on the internal path for `updateColumns`.
private
probeCallerTierForUpdateColumns(array<string|int, mixed> $values) : void
Sibling of probeCallerTierForSave(); would_drop is the per-manager allowlist
(columnsOutsideExternalAllowlist()), not an attribute strip: updateColumns goes
straight to DBAL, bypassing the hydrator, so there are no #[External*] attributes.
Parameters
- $values : array<string|int, mixed>
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.
Return values
stringrefuseClientKey()
Sends one response for a missing, unknown, disabled, revoked and expired key, so the caller cannot tell which key exists. The reason, client id and key id (`cms_system_api_client_keys`, not the key itself) go only to the log; the key or any part of it never does.
private
refuseClientKey(ApiClientKeyStatus $status, int|null $clientId[, int|null $keyId = null ]) : void
Parameters
- $status : ApiClientKeyStatus
- $clientId : int|null
- $keyId : int|null = null
refuseClientLimit()
Sends 429 with `Retry-After`. Only the first refusal in the window goes to the auth log, so a flood does not flood the log too.
private
refuseClientLimit(ApiClientIdentity $client, ApiClientLimitResult $limit) : void
Parameters
- $client : ApiClientIdentity
- $limit : ApiClientLimitResult
reportJwtAuthFailure()
private
reportJwtAuthFailure(string $reason, string|null $token) : void
Parameters
- $reason : string
- $token : string|null
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
stringresultEnvelope()
Converts a {@see Result} to its array envelope.
private
static resultEnvelope(Result $result) : array<string|int, mixed>
Result::toArray() is declared mixed (in practice always an array). This keeps the
method type array, so the caller never gets a non-array where an envelope is expected.
Parameters
- $result : Result
Return values
array<string|int, mixed>updateColumnsDeniedPayload()
Builds the `updateColumns` denial naming the column that caused it.
private
static updateColumnsDeniedPayload(string $column) : ApiPermissionDeniedShape
The shape is bit for bit the same as the guard one
(ApiPermissionGuardTrait::permissionDeniedPayload()), only message differs. The
column name is schema information, not data, and the schema is visible from every get*
response anyway. Without it a denial on a ten-column call would be debugged blind.
Parameters
- $column : string