CoolCMS API

RatingPresenter extends BasePresenter
in package
uses GetTrait, GetByTrait, GetAllTrait, DeleteTrait, SaveTrait, OwnershipCheckTrait

FinalYes

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_BACKTRACE_LIMIT

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

private int CALLER_BACKTRACE_LIMIT = 14

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

CALLER_SKIPPED_PATHS

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

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

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

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

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

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

Properties

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

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

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

$personalDataPolicy

Personal data policy applied to the output (see {@see self::sendJson()}).

public PersonalDataPolicy $personalDataPolicy
Attributes
#[Inject]

$queryPathPolicy

Policy for field paths in filters and ordering (see {@see self::rejectForbiddenQueryPaths()}).

public QueryPathPolicy $queryPathPolicy
Attributes
#[Inject]

$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.

$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

Methods

actionGet()

public actionGet([int|null $id = null ]) : void
Parameters
$id : int|null = null

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

getPersonalDataCapabilityRows()

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 => null and an insert without a response tests false without an extra SELECT;
  • admin moderation (RatingThread): admin has an unconditional saveReply grant;
  • the author editing their own review, as long as response is 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
bool

sendJson()

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

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() calls internalActionSave() directly): $meta['__external'] is not set. Front end advert creation fills insertUserId from the session itself; if the internal path got here, creating adverts as an anonymous user (advert password) would break.
  • on UPDATE (data.id filled): 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
throws
AbortException

in enforce mode on a deny (403 + Result::failure)

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

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

getOwnedParentId()

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

See OwnershipPayload::parentId().

Parameters
$property : string

name of the to-one property on the entity (camelCase, e.g. order)

Return values
int|null

getOwnedRequestId()

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

getRbacKey()

protected getRbacKey() : string|null
Return values
string|null

getRequestParameters()

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
string

getSessionSection()

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
see
getSessionKey()
Return values
SessionSection

isCallerTierProbeActive()

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

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

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

Return values
bool

isClientKeyExempt()

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
bool

isOwner()

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
bool

isOwnershipInsert()

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
bool

isPublicAction()

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
bool

isQueryCacheEnabled()

protected isQueryCacheEnabled() : bool
Tags
see

$queryCacheEnabled

Return values
bool

isReceiver()

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
bool

isSameSiteRequest()

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
bool

moveGraphKeysToMeta()

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: array, 1: array}

operation()

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
template
Return values
T

ownedEntityLoader()

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(): Array

ownedForUpdateColumns()

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

ownsByCapability()

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
bool

readBodyParameter()

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

refineSaveAction()

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

reportCallerTierGap()

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

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

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

Parameters
$kind : string

save or updateColumns

$action : string

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

$tier : string
$label : string

field name in the log: would_strip or would_drop

$fields : array<int, string>

requireApiPermission()

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

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

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

$owned : Closure|null = null

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

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

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

Tags
throws
AbortException

propagated unchanged (see the catch below)

Return values
ApiPermissionDeniedShape|null —

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

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
Manager

resolveApiPresenterName()

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

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

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

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

Parameters
$class : class-string
Tags
throws
LogicException

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

Return values
string

resolveCallerFrame()

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

protected static resolveCallerFrame() : string

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

Return values
string

resolveCallerTier()

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

protected resolveCallerTier(string $action) : string

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

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

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

Parameters
$action : string

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

Return values
string

resolveExternalFieldTier()

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 is off or 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
string

saveEntityData()

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
bool

stripDefaultSkipFields()

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
bool

verifyReceiver()

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
bool

apiPermissionTokenHint()

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

private apiPermissionTokenHint() : string
Return values
string

buildApiPermissionKey()

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
string

extractBearerToken()

private extractBearerToken() : string|null
Return values
string|null

fieldAudience()

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

firstColumnWithoutPrivilegeFreeGrant()

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:

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

getOwnershipPayload()

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

initializeCurrency()

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
bool

localizationCurrencyKey()

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

logApiPermissionDecision()

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

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

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

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

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

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

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

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

permissionDeniedPayload()

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

private static permissionDeniedPayload() : ApiPermissionDeniedShape
Return values
ApiPermissionDeniedShape

probeCallerTierForSave()

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
string

refuseClientKey()

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

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
string

resultEnvelope()

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
Return values
ApiPermissionDeniedShape
On this page

Search results