CoolCMS API

UserPresenter extends BasePresenter
in package
uses CrudTrait, NotificationProducerTrait, OwnershipCheckTrait, SendActivationEmailTrait

FinalYes

Table of Contents

Constants

OWN_PROFILE_HIDDEN_FIELDS  : array<string|int, mixed> = ['password', 'salt', 'activationKey', 'activati...
Fields hidden from a non-privileged caller of `:Api:System:User:get` (typically the account owner).
PUBLIC_PROFILE_FIELDS  : array<string|int, mixed> = [ // identity and display name 'id', 'username'...
Allowlist of the keys that may leave {@see self::internalActionPublicProfile()}.
SESSION_SUBSECTION  : mixed = 'user'
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()`.
$accountDeletionManager  : AccountDeletionManager
Account deletion: request, confirmation, cancel and the admin actions.
$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).
$notificationManager  : NotificationManager
$notificationTypeManager  : NotificationTypeManager
$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
$userRefreshTokenManager  : UserRefreshTokenManager
Issues a fresh token pair after an own password change ({@see self::internalActionUpdatePassword()}); all older refresh tokens of the account are revoked.
$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
$rateLimit  : RateLimit
$rateLimitConfig  : RateLimitConfig
$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).
$userManager  : UserManager
$userRelationManager  : UserRelationManager

Methods

__construct()  : mixed
actionActivate()  : never
Activates an account from the e-mailed link.
actionGenerateNewPassword()  : never
Admin action that returns the generated password in the response.
actionPublicProfile()  : never
External counterpart of {@see self::internalActionPublicProfile()}, including the bulk `ids` parameter, so both transports share one key, one policy and one allowlist.
actionResetPassword()  : never
Public "set a new password" step of the forgotten-password link.
actionSendActivationEmail()  : never
actionSendNewPassword()  : never
Public "forgotten password" flow.
actionShowPhone()  : never
External counterpart of {@see self::internalActionShowPhone()}.
actionUpdatePassword()  : never
Changes the password of the signed-in caller; there is no `userId` parameter.
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).
internalActionActivate()  : array<string, mixed>
Guard: `Result::toArray()` is declared as `mixed`, and without it phpstan reports `should return array but returns mixed`.
internalActionAnonymizeNow()  : array<string, mixed>
Account deletion — administration anonymizes a pending account at once (admin only).
internalActionCancelDeletion()  : array<string, mixed>
Account deletion — cancel by the e-mailed token or by the ticket from a refused sign-in.
internalActionCancelDeletionByAdmin()  : array<string, mixed>
Account deletion — administration cancels the pending deletion (admin only).
internalActionConfirmDeletion()  : array<string, mixed>
Account deletion — the confirmation link e-mailed to an account without a password (OAuth).
internalActionEmailExists()  : array{exists: bool}|ApiPermissionDeniedShape
internalActionGenerateNewPassword()  : array<string, mixed>
internalActionPublicProfile()  : array<string, mixed>|ApiPermissionDeniedShape
Returns the public-safe profile of one user, or of several users with `$ids`.
internalActionReferralOverview()  : array<string, mixed>
Affiliate program for the "My account" page: own referral code (assigned when missing), whether the program is on for the user, and counts of referred registrations, activated accounts and earned credits. Internal bridge only; `$userId` comes from the front session and the owned closure plus {@see self::isOwner()} compare it with the JWT identity (owner grant), like `requestDeletion`.
internalActionRemoveExternalLogin()  : array{success: bool}|ApiPermissionDeniedShape
Unlinks one external login of the user.
internalActionRequestDeletion()  : array<string, mixed>
Account deletion: the signed-in owner requests deletion of their own account.
internalActionResetPassword()  : array<string, mixed>
The token has 256 bits and cannot be guessed; the per-IP limit (values of `sendNewPassword`) only slows a script flooding the action. Throttling returns the same code as an invalid key.
internalActionSave()  : array<string, mixed>
Raises the `REGISTRATION_NEW` bell notification for a newly created user, outside the admin only (front and external registration).
internalActionSendActivationEmail()  : array<string, int|string|false|null>|array<string, mixed>
internalActionSendNewPassword()  : array<string, mixed>
Throttles the forgotten-password flow and returns the same payload as a success.
internalActionShowPhone()  : array{phone: string}|ApiPermissionDeniedShape
Returns the unmasked phone of a public profile ("show phone" call to action).
internalActionUpdatePassword()  : array<string, mixed>
Shared data path of the external and internal password change: - external: {@see self::actionUpdatePassword()} with `$userId` from the JWT, - internal: `ApiBridge::internalCall()` from the front `UserPasswordFormComponent` with `$userId` from the front session, never from the request.
internalActionUsernameExists()  : array{exists: bool}|ApiPermissionDeniedShape
internalActionUserRelations()  : array{roleIds: list, groupIds: list, externalLogins: list}|ApiPermissionDeniedShape
Returns the role and group ids and the external logins of the user (admin prefill and list).
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.
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
exemptPersonalDataForCapabilityHolder()  : void
IMPORTANT: exempts the capability holder's own record from personal data masking.
filterReadPayload()  : array<string, mixed>
Read field tier for `:Api:System:User:get`: strips secrets from the owner's own payload.
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
Ownership of a user entity: identity, not a foreign key.
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
Default check of the `receiver` role: "is the caller the recipient of this record?" (whom it was sent to, not who created it). Fail-closed: without {@see ReceiverAwareInterface} on the entity it is always `false`. {@see self::requireApiPermission()} calls it only when the resolver returned a conditional `receiver` grant (without an unconditional one). Each domain defines its recipient itself (Response: privileged role, advert owner, password capability or a valid `read_key`).
isSameSiteRequest()  : bool
Tells whether the request comes from the own site (Nette SameSite cookie); protects GET actions with a side effect.
operation()  : T
Runs the body of one `internalAction*()` method as one operation (one Api call = one action).
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`.
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]`).
sendInvalidRequestParameter()  : void
shouldProduceNotifications()  : bool
Returns whether notifications in the bell should be created for this operation.
verifyOwnership()  : bool
Ownership check for the self-service actions.
verifyReceiver()  : bool
Default `receiver` check: is the caller the receiver of this record (who it was sent to, not who created it)? Fail-closed.
activationUserKey()  : string
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.
buildDeletedPublicProfile()  : array<string, mixed>
Account deletion: the one place where a deleted (anonymized) author is rendered on the web (comments, reviews, discussion cards and advert details all read the public profile). Same keys as {@see self::buildPublicProfile()}; the name is "Deleted user" (`firstname` too, the cards print "firstname lastname") and everything else is empty.
buildPublicProfile()  : array<string, mixed>
The only place where a public profile is assembled. Key order and key set must match {@see self::PUBLIC_PROFILE_FIELDS}; {@see \Tests\Unit\Security\UserPublicProfileExposureTest} checks it.
buildPublicProfileFor()  : array<string, mixed>
Builds the public profile of one user; the e-mail follows the `1/security.personalDataExposure` setting like other lists. The profile owner and privileged roles get it whole.
buildPublicProfilesFor()  : array<int, array<string, mixed>>
collectUsers()  : array<int, User>
Loads the users for the bulk mode of {@see self::internalActionPublicProfile()}.
createdIdForNotification()  : int|null
Id of a newly created record from the `internalActionSave()` response, or `null` when nothing should be notified: update (`$data` had the id), failure, or a call from the admin.
extractBearerToken()  : string|null
fieldAudience()  : FieldAudienceContext|null
Returns the audience context, or `null` when the presenter did not get it through DI.
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.
issueSessionTokens()  : array{token: string, refreshToken: string, payload: array}|array{}
Issues a new token pair for the session that just changed its password. `updateOwnPassword()` revoked all refresh tokens of the account (including the caller's), so without a new pair the caller would drop out when the access token expires. The shape (`token` / `refreshToken` / `payload`) is the same as for login and refresh ({@see AuthPresenter::internalActionRefresh()}).
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.
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.
passesDeletionKeyRateLimit()  : bool
Same per-IP brake as the password reset link — the tokens cannot be guessed, it only slows a flood.
passesSendActivationEmailRateLimit()  : bool
passesSendNewPasswordRateLimit()  : bool
Checks two independent limits; both must pass. Each covers a different attack: a per-IP limit does not stop a botnet aimed at one account, a per-account limit does not stop one IP trying thousands of names.
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.
produceNotification()  : void
publicProfileEmailPolicy()  : PersonalDataExposure, 1: bool}
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
resolveDeletedUserLabel()  : string
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.
resultArray()  : array<string, mixed>
sendActivationEmailOperation()  : array<string, mixed>

Constants

OWN_PROFILE_HIDDEN_FIELDS

Fields hidden from a non-privileged caller of `:Api:System:User:get` (typically the account owner).

public array<string|int, mixed> OWN_PROFILE_HIDDEN_FIELDS = ['password', 'salt', 'activationKey', 'activationExpires', 'passwordResetHash', 'passwordResetExpires', 'deletionTokenHash', 'deletionTokenExpires']

IMPORTANT: get is externally reachable and open to the owner grant; without this filter an owner could read their own password hash, salt and activation key over HTTP. Privileged roles bypass the filter.

PUBLIC_PROFILE_FIELDS

Allowlist of the keys that may leave {@see self::internalActionPublicProfile()}.

public array<string|int, mixed> PUBLIC_PROFILE_FIELDS = [ // identity and display name 'id', 'username', 'firstname', 'lastname', 'assetPath', // coarse location only; the delivery address fields are deliberately not listed 'city', 'country', // public text written by the user for the profile 'url', 'aboutMe', // public aggregates and status badges 'registrationDatetime', 'memberUntil', 'activated', 'banned', 'ratingThreadId', // IMPORTANT: masked derivatives only; the raw `phone` never leaves the Api layer (it is revealed // only via `showPhone`). `email` follows `1/security.personalDataExposure`: `sanitized` masks // (default), `full` returns it whole, `empty` blanks it; the profile owner and admin get it whole. 'emailMasked', 'phoneMasked', 'hasPhone', ]

IMPORTANT: this is a positive list, not "everything except". The read path has no field tier, so OrmEntityHydrator::extract() would emit every mapped User field (password, salt, email, ip, activationKey, street, phone, credits...). A key added here is exposed to anonymous visitors, and a deny-list would fail open on every new entity column.

Guarded by UserPublicProfileExposureTest.

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]

$accountDeletionManager

Account deletion: request, confirmation, cancel and the admin actions.

public AccountDeletionManager $accountDeletionManager

Property injection for the same reason as above.

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]

$userRefreshTokenManager

Issues a fresh token pair after an own password change ({@see self::internalActionUpdatePassword()}); all older refresh tokens of the account are revoked.

public UserRefreshTokenManager $userRefreshTokenManager

Property injection keeps the constructor stable for the tests that build the presenter.

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

actionActivate()

Activates an account from the e-mailed link.

public actionActivate([string|null $email = null ][, string|null $activationKey = null ]) : never

#[PublicAction] is required: the link is opened by a signed-out person (activation precedes the first sign-in). Security rests on the one-time activation key (compared with hash_equals() and cleared after use, see UserManager::activateUser()), not on authentication.

The response does not distinguish an unknown e-mail from a wrong key (one code, USER_ACTIVATION_KEY_INVALID), so the action is not an oracle for account existence, the same principle as the constant response of self::actionSendNewPassword().

Parameters
$email : string|null = null
$activationKey : string|null = null
Attributes
#[PublicAction]
Return values
never

actionGenerateNewPassword()

Admin action that returns the generated password in the response.

public actionGenerateNewPassword(int $userId) : never

IMPORTANT: this is the administrator regeneration, not the forgotten-password flow (self::actionSendNewPassword()). It has no #[PublicAction]: it changes the password of someone else's account and returns it to the caller, so it must not bypass the JWT guard. Authorization: the key is allowed = 1 for the admin role only.

Parameters
$userId : int
Return values
never

actionPublicProfile()

External counterpart of {@see self::internalActionPublicProfile()}, including the bulk `ids` parameter, so both transports share one key, one policy and one allowlist.

public actionPublicProfile([int|null $id = null ][, string|null $ids = null ]) : never

IMPORTANT: deliberately without #[PublicAction]. guest = 1 is a grant for the internal path (front bridge); external HTTP without a token ends in 401 at the JWT guard. Adding the attribute opens the profile list to unlimited scraping by robots.

Parameters
$id : int|null = null
$ids : string|null = null
Return values
never

actionResetPassword()

Public "set a new password" step of the forgotten-password link.

public actionResetPassword() : never

Takes the e-mail and the one-time key from the link (sent by self::actionSendNewPassword()) and the new password. Verification and write are done by UserManager::resetPassword(); an unknown e-mail and an invalid, expired or used key give one code, USER_PASSWORD_RESET_KEY_INVALID.

The key :Api:System:User:resetPassword has guest = 1 because the link is opened by a signed-out person.

Attributes
#[PublicAction]
Return values
never

actionSendActivationEmail()

public actionSendActivationEmail(string $username) : never
Parameters
$username : string
Attributes
#[PublicAction]
Return values
never

actionSendNewPassword()

Public "forgotten password" flow.

public actionSendNewPassword(string $username) : never

Takes only the username or e-mail (shape shared with self::actionSendActivationEmail()), never a userId. Account lookup, issuing the one-time reset token (only its hash is stored) and sending SY_LOST_PASSWORD with the link are done by UserManager::sendNewPassword(); the password itself does not change until the link is used.

IMPORTANT: the response is constant (success + code 0) whether or not the account exists; otherwise it would be an oracle for registered e-mails. The token is never in the response; the only recipient of the readable token is the e-mail. The key :Api:System:User:sendNewPassword needs guest = 1.

Parameters
$username : string
Attributes
#[PublicAction]
Return values
never

actionShowPhone()

External counterpart of {@see self::internalActionShowPhone()}.

public actionShowPhone([int|null $id = null ]) : never

IMPORTANT: deliberately without #[PublicAction]: anonymous external HTTP without an application key gets 401 before the guard and limit are evaluated. With an application key and no token the caller is a guest (guest = 1), the same as an anonymous web visitor, and is held by the same per-IP limit in internalActionShowPhone().

Parameters
$id : int|null = null
Return values
never

actionUpdatePassword()

Changes the password of the signed-in caller; there is no `userId` parameter.

public actionUpdatePassword() : never

The identity comes only from the JWT payload, so another account's id cannot be injected even by a careless later change. action* methods are called only by external HTTP routing; the internal path (ApiBridge::internalCall()) calls self::internalActionUpdatePassword() directly with a userId the front takes from the session, so that method needs the parameter and this action does not.

Without an identity (API-key client or missing token) the request ends with USER_NOT_LOGGED_IN; in enforce mode the gate rejects it earlier (the key is owner and verifyOwnership() returns false without userId).

IMPORTANT: the action requires currentPassword next to the new password. Without it (or with a wrong one) it returns INVALID_PASSWORD, a short new password gives INVALID_LENGTH. On success all older refresh tokens of the account are revoked and data carries a new token + refreshToken + payload (same shape as login/refresh); the client must store it, otherwise it is signed out when the access token expires. External clients sending only password get INVALID_PASSWORD, not a silent success.

Return values
never

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

internalActionActivate()

Guard: `Result::toArray()` is declared as `mixed`, and without it phpstan reports `should return array but returns mixed`.

public internalActionActivate(string $email, string $activationKey) : array<string, mixed>
Parameters
$email : string
$activationKey : string
Return values
array<string, mixed>

internalActionAnonymizeNow()

Account deletion — administration anonymizes a pending account at once (admin only).

public internalActionAnonymizeNow([int $userId = 0 ]) : array<string, mixed>
Parameters
$userId : int = 0
Return values
array<string, mixed>

internalActionCancelDeletion()

Account deletion — cancel by the e-mailed token or by the ticket from a refused sign-in.

public internalActionCancelDeletion([string $key = '' ]) : array<string, mixed>

Public (guest = 1): the visitor is not signed in (the sign-in is refused while pending).

Parameters
$key : string = ''
Return values
array<string, mixed>

internalActionCancelDeletionByAdmin()

Account deletion — administration cancels the pending deletion (admin only).

public internalActionCancelDeletionByAdmin([int $userId = 0 ]) : array<string, mixed>
Parameters
$userId : int = 0
Return values
array<string, mixed>

internalActionConfirmDeletion()

Account deletion — the confirmation link e-mailed to an account without a password (OAuth).

public internalActionConfirmDeletion([string $key = '' ]) : array<string, mixed>

Public (guest = 1): the link is opened from the mailbox; security is the one-time token.

Parameters
$key : string = ''
Return values
array<string, mixed>

internalActionEmailExists()

public internalActionEmailExists([string $email = '' ][, int|null $excludeId = null ]) : array{exists: bool}|ApiPermissionDeniedShape
Parameters
$email : string = ''
$excludeId : int|null = null
Return values
array{exists: bool}|ApiPermissionDeniedShape

internalActionGenerateNewPassword()

public internalActionGenerateNewPassword(int $userId) : array<string, mixed>
Parameters
$userId : int
Return values
array<string, mixed>

internalActionPublicProfile()

Returns the public-safe profile of one user, or of several users with `$ids`.

public internalActionPublicProfile([int $id = 0 ][, string $ids = '' ]) : array<string, mixed>|ApiPermissionDeniedShape

The anonymous profile page cannot use :Api:System:User:get: that key is admin-only (it returns the whole entity) and must stay so. This action returns only the keys of self::PUBLIC_PROFILE_FIELDS, so it can carry guest = 1 while get/getAll/getBy stay admin-only. It decides what is returned, not who may call.

IMPORTANT: never return the entity or hydrator->extract*() here; both emit everything mapped, which is the fail-open this action exists to avoid. The payload is built by hand, key by key. email and phone are returned masked only (emailMasked, phoneMasked), so the raw value never crosses the Api layer.

An unknown or invalid id returns an empty array (the front end turns it into noprofile), not an exception.

Bulk mode: with $ids (comma-separated) the result is ['items' => [userId => allowlist]] and ids that are not found are absent. Public pages (comments, discussion cards) need many authors and getAll is admin-only. It is a parameter and not a separate action because the guard literal must be the inverse of the method name and a new key needs a new database row; every profile is built by self::buildPublicProfile(), so bulk mode cannot expose more than the single mode.

Endpoint: api/system/user/public-profile.

Parameters
$id : int = 0
$ids : string = ''
Return values
array<string, mixed>|ApiPermissionDeniedShape —

keys of self::PUBLIC_PROFILE_FIELDS, or {items: {userId: keys}} in bulk mode

internalActionReferralOverview()

Affiliate program for the "My account" page: own referral code (assigned when missing), whether the program is on for the user, and counts of referred registrations, activated accounts and earned credits. Internal bridge only; `$userId` comes from the front session and the owned closure plus {@see self::isOwner()} compare it with the JWT identity (owner grant), like `requestDeletion`.

public internalActionReferralOverview(int $userId) : array<string, mixed>
Parameters
$userId : int
Return values
array<string, mixed>

internalActionRemoveExternalLogin()

Unlinks one external login of the user.

public internalActionRemoveExternalLogin([int $userId = 0 ][, string $type = '' ][, string $externalId = '' ]) : array{success: bool}|ApiPermissionDeniedShape

IMPORTANT: the sign-in flow looks the account up by User.externalId + User.type, not by the cms_system_user_external_logins row, so deleting only the row would leave the provider login working. The pair on the user is cleared first (as on provider deauthorization) and the list row only after that succeeds; a failed first step keeps the row, so the admin never sees "unlinked" while the identity still works.

Parameters
$userId : int = 0
$type : string = ''
$externalId : string = ''
Return values
array{success: bool}|ApiPermissionDeniedShape

internalActionRequestDeletion()

Account deletion: the signed-in owner requests deletion of their own account.

public internalActionRequestDeletion(int $userId[, string $password = '' ]) : array<string, mixed>

Internal bridge only (no action*()), called by the front AccountDeletion component with $userId from the session; the owned closure and self::isOwner() compare it with the JWT identity exactly like updatePassword.

Wrong passwords draw from the per-account login limit (a stolen session must not get an oracle). Result: OK + scheduledAt, USER_DELETION_CONFIRMATION_SENT (account without a password), INVALID_PASSWORD or RATE_LIMIT_EXCEEDED.

Parameters
$userId : int
$password : string = ''
Return values
array<string, mixed>

internalActionResetPassword()

The token has 256 bits and cannot be guessed; the per-IP limit (values of `sendNewPassword`) only slows a script flooding the action. Throttling returns the same code as an invalid key.

public internalActionResetPassword(string $email, string $key, string $password) : array<string, mixed>
Parameters
$email : string
$key : string
$password : string
Return values
array<string, mixed>

internalActionSave()

Raises the `REGISTRATION_NEW` bell notification for a newly created user, outside the admin only (front and external registration).

public internalActionSave(array<string|int, mixed> $data[, array<string, mixed> $meta = [] ][, array<int, string> $skipFields = [] ]) : array<string, mixed>

The guard is the first statement, in the same shape as SaveTrait::internalActionSave() (checked by ApiPermissionCoverageTest); the save itself is saveEntityData() of the trait.

Parameters
$data : array<string|int, mixed>
$meta : array<string, mixed> = []
$skipFields : array<int, string> = []
Return values
array<string, mixed>

internalActionSendActivationEmail()

public internalActionSendActivationEmail(string $username) : array<string, int|string|false|null>|array<string, mixed>
Parameters
$username : string
Return values
array<string, int|string|false|null>|array<string, mixed>

internalActionSendNewPassword()

Throttles the forgotten-password flow and returns the same payload as a success.

public internalActionSendNewPassword(string $username) : array<string, mixed>

IMPORTANT: the limit is part of the contract. The action is #[PublicAction] with guest = 1, so it is reachable from the internet without a token, and every pass replaces the pending reset token of someone else's account (the previous link stops working) and sends an e-mail. The limit is therefore evaluated before the manager is called; afterwards the token is already replaced.

IMPORTANT: a denied call returns the same payload as success. Anything else makes the limiter an oracle (the attacker would learn the attempt count and, with a per-account limit, tell an existing account from a missing one). The throttling goes only to the auth log, like delivery failures in UserManager::sendNewPassword().

The limit deliberately ignores securityConfig->rateLimitEnabled (unlike login in AuthPresenter, same as showPhone here): it is the last brake before a write to someone else's account and must not be switched off together with the login brute-force protection.

Parameters
$username : string
Return values
array<string, mixed>

internalActionShowPhone()

Returns the unmasked phone of a public profile ("show phone" call to action).

public internalActionShowPhone([int $id = 0 ]) : array{phone: string}|ApiPermissionDeniedShape

It is a dedicated action and not an extension of publicProfile, because the contract of self::internalActionPublicProfile() is that raw phone and email never leave the Api layer (UserPublicProfileExposureTest scans values, not just key names). A separate action returning only {phone} keeps the risk surface to one narrow, auditable endpoint that can be guarded and rate-limited independently.

IMPORTANT: two layers of protection, neither is enough alone.

  1. The guard (requireApiPermission('showPhone')) decides who may call the internal path (an anonymous front visitor is the guest role).
  2. The per-IP rate limit (ratelimit.showPhone) is the only brake against iterating {id} with a valid token: actionShowPhone() has no #[PublicAction], so anonymous external HTTP gets 401, but a signed-in external client passes the guard and could call this endpoint directly, bypassing the same-origin protection of the front signal UserPresenter::handleShowPhone().

The payload is always {'phone': string}; the phone is an empty string for a missing user or phone and when the guard or limit denies the call (same shape as an invalid id in self::internalActionPublicProfile()). No other field is ever returned here (UserShowPhoneExposureTest).

Parameters
$id : int = 0
Return values
array{phone: string}|ApiPermissionDeniedShape

internalActionUpdatePassword()

Shared data path of the external and internal password change: - external: {@see self::actionUpdatePassword()} with `$userId` from the JWT, - internal: `ApiBridge::internalCall()` from the front `UserPasswordFormComponent` with `$userId` from the front session, never from the request.

public internalActionUpdatePassword(int $userId, string $currentPassword, string $password) : array<string, mixed>

Targets UserManager::updateOwnPassword(); changing the password of another account has its own method (updatePasswordForUser()) and is not reachable through this endpoint.

IMPORTANT: verifying the current password without a brake would give the holder of a stolen session an oracle for guessing the password, so failed attempts draw from the per-account limit (login values, ratelimit.login). Once it is exhausted the password is not verified at all (RATE_LIMIT_EXCEEDED).

Parameters
$userId : int
$currentPassword : string
$password : string
Return values
array<string, mixed>

internalActionUsernameExists()

public internalActionUsernameExists([string $username = '' ][, int|null $excludeId = null ]) : array{exists: bool}|ApiPermissionDeniedShape
Parameters
$username : string = ''
$excludeId : int|null = null
Return values
array{exists: bool}|ApiPermissionDeniedShape

internalActionUserRelations()

Returns the role and group ids and the external logins of the user (admin prefill and list).

public internalActionUserRelations([int $userId = 0 ]) : array{roleIds: list, groupIds: list, externalLogins: list}|ApiPermissionDeniedShape
Parameters
$userId : int = 0
Return values
array{roleIds: list, groupIds: list, externalLogins: list}|ApiPermissionDeniedShape

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.

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

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

filterReadPayload()

Read field tier for `:Api:System:User:get`: strips secrets from the owner's own payload.

protected filterReadPayload(array<string, mixed> $payload, string $action) : array<string, mixed>

get is open to the owner (owner = 1, $owned closure from GetTrait) and, because actionGet exists, reachable externally too. The owner loses password, salt and activationKey.

NOTE: in a request context OrmEntityHydrator::extractFields() already applies FieldAudienceContext, so password/salt (#[FieldAccess(read: Audience::None)]) are never emitted, not even to a privileged role; this filter is defence in depth and the owner-level cut of activationKey (read: Audience::Privileged).

A privileged role (today admin) skips the filter and gets the payload unchanged, including activationKey. The decision is made by PermissionResolver::isGrantedByPrivilegedRole(), the same method the write tier stands on, so the rule has no second copy and no hard-coded role name.

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

Ownership of a user entity: identity, not a foreign key.

protected isOwner(object|null $entity, int $userId[, array<string|int, mixed> $data = [] ]) : bool

The generic OwnershipCheckTrait::isOwner() fallback is unusable here: User has no getUserId() (a user is not a record belonging to a user, it is the subject), so ownership is user.id === jwt.userId (UserManager::isOwnedBy()). The override is explicit on purpose: if User ever gets getUserId(), the fallback would silently answer a different question.

For updatePassword, $entity comes from the $owned closure as userManager->find($userId) with $userId from the method parameter (self::internalActionUpdatePassword(); the front takes it from the session). Comparing it with the JWT user id is a real check: the parameter says whom to change, the JWT says who the caller is, and both must match.

Parameters
$entity : object|null
$userId : int
$data : array<string|int, mixed> = []

write payload; for an 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()

Default check of the `receiver` role: "is the caller the recipient of this record?" (whom it was sent to, not who created it). Fail-closed: without {@see ReceiverAwareInterface} on the entity it is always `false`. {@see self::requireApiPermission()} calls it only when the resolver returned a conditional `receiver` grant (without an unconditional one). Each domain defines its recipient itself (Response: privileged role, advert owner, password capability or a valid `read_key`).

protected isReceiver(object|null $entity, int|null $userId[, array<string|int, mixed> $data = [] ]) : bool

Unlike self::isOwner(), $userId may be null: the recipient can also be an anonymous editor with a capability or the holder of a key from an e-mail link.

IMPORTANT: never change this to return true; it would let anyone read records addressed to others.

Parameters
$entity : object|null

entity loaded by the $owned closure from the DB by the id from the method parameter, never from request data; null means nothing to judge

$userId : int|null

ID from the JWT payload, null means no identity

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

explicit parameters of the internal call supplied by the calling action (e.g. ['readKey' => ...] for markReadByKey); never present on the external path

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

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

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

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

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

verifyOwnership()

Ownership check for the self-service actions.

protected verifyOwnership(int $userId) : bool
  • save: UPDATE of oneself only. Creating a user through the external save stays closed (an INSERT has no data.id, so false); registration is a different path.
  • updatePassword: the target is not taken from the request at all (self::actionUpdatePassword() has no userId and writes to the JWT account), so ownership is guaranteed by construction and only the existence of an identity is verified. This is not a tautological guard: there is no request value to compare. If userId ever returns to the action, the comparison through isOwnedBy() must return too.
Parameters
$userId : int
Return values
bool

verifyReceiver()

Default `receiver` check: is the caller the receiver of this record (who it was sent to, not who created it)? Fail-closed.

protected verifyReceiver(int|null $userId) : bool

External path: reads only the request identifiers (id) and decides against the database. The identity comes from the JWT; $userId = null is legitimate (anonymous editor with a capability). read_key is never taken from outside; that branch exists only on the internal path (ApiPermissionGuardTrait::isReceiver()).

A concrete presenter may extend it (each domain defines its own receiver).

IMPORTANT: never change this to true.

Parameters
$userId : int|null

ID from the JWT payload, null means no identity

Return values
bool

activationUserKey()

private static activationUserKey(string $username) : string
Parameters
$username : string
Return values
string

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

buildDeletedPublicProfile()

Account deletion: the one place where a deleted (anonymized) author is rendered on the web (comments, reviews, discussion cards and advert details all read the public profile). Same keys as {@see self::buildPublicProfile()}; the name is "Deleted user" (`firstname` too, the cards print "firstname lastname") and everything else is empty.

private static buildDeletedPublicProfile(User $user, string $deletedLabel) : array<string, mixed>
Parameters
$user : User
$deletedLabel : string
Return values
array<string, mixed>

buildPublicProfile()

The only place where a public profile is assembled. Key order and key set must match {@see self::PUBLIC_PROFILE_FIELDS}; {@see \Tests\Unit\Security\UserPublicProfileExposureTest} checks it.

private static buildPublicProfile(User $user[, PersonalDataExposure $mode = PersonalDataExposure::Sanitized ][, bool $fullAccess = false ][, string $deletedLabel = '' ]) : array<string, mixed>
Parameters
$user : User
$mode : PersonalDataExposure = PersonalDataExposure::Sanitized

exposure mode from settings; sanitized is the default

$fullAccess : bool = false

profile owner or privileged role: the e-mail is returned whole

$deletedLabel : string = ''
Return values
array<string, mixed>

buildPublicProfileFor()

Builds the public profile of one user; the e-mail follows the `1/security.personalDataExposure` setting like other lists. The profile owner and privileged roles get it whole.

private buildPublicProfileFor(User $user) : array<string, mixed>
Parameters
$user : User
Return values
array<string, mixed>

buildPublicProfilesFor()

private buildPublicProfilesFor(array<int, User> $users) : array<int, array<string, mixed>>
Parameters
$users : array<int, User>
Return values
array<int, array<string, mixed>>

collectUsers()

Loads the users for the bulk mode of {@see self::internalActionPublicProfile()}.

private collectUsers(string $ids) : array<int, User>

IMPORTANT: call it only after the permission guard, never before it.

Parameters
$ids : string
Return values
array<int, User>

createdIdForNotification()

Id of a newly created record from the `internalActionSave()` response, or `null` when nothing should be notified: update (`$data` had the id), failure, or a call from the admin.

private createdIdForNotification(array<string, mixed> $data, array<string, mixed> $response) : int|null
Parameters
$data : array<string, mixed>

payload before saving

$response : array<string, mixed>

response of internalActionSave()

Return values
int|null

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

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

issueSessionTokens()

Issues a new token pair for the session that just changed its password. `updateOwnPassword()` revoked all refresh tokens of the account (including the caller's), so without a new pair the caller would drop out when the access token expires. The shape (`token` / `refreshToken` / `payload`) is the same as for login and refresh ({@see AuthPresenter::internalActionRefresh()}).

private issueSessionTokens(int $userId) : array{token: string, refreshToken: string, payload: array}|array{}
Parameters
$userId : int
Return values
array{token: string, refreshToken: string, payload: array}|array{}

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

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

passesDeletionKeyRateLimit()

Same per-IP brake as the password reset link — the tokens cannot be guessed, it only slows a flood.

private passesDeletionKeyRateLimit() : bool
Return values
bool

passesSendActivationEmailRateLimit()

private passesSendActivationEmailRateLimit(string $username) : bool
Parameters
$username : string
Return values
bool

passesSendNewPasswordRateLimit()

Checks two independent limits; both must pass. Each covers a different attack: a per-IP limit does not stop a botnet aimed at one account, a per-account limit does not stop one IP trying thousands of names.

private passesSendNewPasswordRateLimit(string $username) : bool

username is hashed (sha256 of the normalized form): it is personal data (often an e-mail) that does not belong in the rate-limit table, and the hash maps Franta/franta to one key so that changing case does not bypass the limit. Same approach as AuthPresenter::buildLoginRateLimitKey().

The backends are fail-open (see RateLimit): an outage of Redis or the database does not stop the flow, it only lifts the brake temporarily.

Parameters
$username : string
Return values
bool

permissionDeniedPayload()

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

private static permissionDeniedPayload() : ApiPermissionDeniedShape
Return values
ApiPermissionDeniedShape

produceNotification()

private produceNotification(string $typeKey, int $itemId, string $text) : void
Parameters
$typeKey : string
$itemId : int
$text : string

publicProfileEmailPolicy()

private publicProfileEmailPolicy(int $profileUserId) : PersonalDataExposure, 1: bool}
Parameters
$profileUserId : int
Return values
PersonalDataExposure, 1: bool} —

exposure mode and "sees everything" (owner or privileged)

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

resolveDeletedUserLabel()

private resolveDeletedUserLabel() : string
Return values
string

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

resultArray()

private static resultArray(Result $result) : array<string, mixed>
Parameters
$result : Result
Return values
array<string, mixed>

sendActivationEmailOperation()

private sendActivationEmailOperation(string $username) : array<string, mixed>
Parameters
$username : string
Return values
array<string, mixed>
On this page

Search results