CoolCMS API

OwnershipCheckTrait

Ownership contract for both Api paths.

  • External: self::verifyOwnership() reads identifiers from the request.
  • Internal (bridge): self::isOwner() receives an entity already loaded from the DB.

IMPORTANT: the two methods must not be swapped or merged. On the internal path Presenter::run() never ran, so getRequest() throws Nette\InvalidStateException. All helpers of this trait (getOwnedEntityId(), getOwnedRequestId(), getOwnedParentId(), isOwnershipInsert()) go through getRequestParameters(), so on the internal path they are not merely useless but blow up; isOwner() must never call them.

PermissionResolver can return allow_if_owner, a conditional grant of the owner role. It allows nothing by itself: the gate (RbacCheckTrait::checkApiPermission()) turns it into allow only when verifyOwnership($userId) returns true, otherwise deny.

Usage: put the trait on a concrete Api presenter (a descendant of BasePresenter), never on Api\BasePresenter: there RbacCheckTrait already declares verifyOwnership() and two traits with the same method name are a PHP fatal error. On the descendant the trait legitimately overrides the parent's method.

protected function verifyOwnership(int $userId): bool
{
    return ($id = $this->getOwnedRequestId()) !== null && $this->complaintManager->isOwnedBy($id, $userId);
}

Three rules that must not be broken:

  1. Fail-closed default: self::verifyOwnership() returns false. Changing it to true opens every presenter that uses the trait with one line.
  2. Ownership is verified against the DB, not against the payload. The helpers extract only identifiers from the request (data.id, data.order.id, id), i.e. which record is meant; the Api manager decides who owns it by a SELECT on that id. Values such as data.userId / data.user.id / data.insertUserId are attacker-controlled and are deliberately never read.
  3. Identity comes from the JWT, never from the request. verifyOwnership() receives $userId from RbacCheckTrait (getJwtPayload()?->getUserId()); the trait takes it from nowhere else.

NOTE: the trait is consumed by many presenters and phpstan analyzes its methods in the context of each consumer, multiplying any error. Everything here is therefore typed narrowly and independent of a concrete presenter's properties; the only host requirement is getRequestParameters() (BasePresenter), and payload parsing lives statelessly in OwnershipPayload, testable without DI.

Table of Contents

Methods

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.
isOwner()  : bool
Generic ownership fallback for the internal (bridge) path.
isOwnershipInsert()  : bool
Tells whether the request creates a record (no `id` in `data`).
verifyOwnership()  : bool
Delegates to the generic base in {@see RbacCheckTrait::verifyOwnership()}.
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).

Methods

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

isOwner()

Generic ownership fallback for the internal (bridge) path.

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

Overrides the fail-closed default ApiPermissionGuardTrait::isOwner() inherited by Api\BasePresenter. Only ApiPermissionGuardTrait::requireApiPermission() calls it, and only when PermissionResolver returned allow_if_owner, i.e. for an unprivileged caller with a conditional owner grant.

It lives here and not in ApiPermissionGuardTrait because that trait sits on every Api presenter. A generic fallback there would silently apply to Blog:Article (userId is the editor who created the content), System:AuthLog (userId is the user the audit is about) or System:UserRole*Rel (userId is who receives the role), where getUserId() is not the owner in the sense of "your own record". Here its reach is exactly the presenters that use the trait knowingly, each classified by name in OwnershipFallbackCoverageTest::FALLBACK_REVIEWED; a presenter without the trait stays fail-closed and cannot inherit the fallback by accident.

It is not a trait method collision because Api\BasePresenter uses ApiPermissionGuardTrait while the concrete presenter uses OwnershipCheckTrait: they sit at different levels of the hierarchy and the descendant's trait method legitimately overrides the inherited one. Widening object to ?object is a legal parameter widening.

Rules, none of which may be dropped:

  • $userId <= 0 gives false: an anonymous caller (getUser()->getId() is null, the cast gives 0) owns nothing.
  • $entity === null gives false: the closure found no record, so there is nothing to own.
  • Without getUserId() it gives false: the entity does not carry the owner under that name (Advert has insertUserId, Order/Complaint have a user association) and the presenter must override.
  • A non-int return gives false: Rating::$userId is nullable (anonymous rating) and null belongs to nobody.
  • Otherwise $ownerId > 0 && $ownerId === $userId.

IMPORTANT: the > 0 edge is not cosmetic. Bazaar\Watchdog::$userId, Comcat\Watchdog::$userId and Bazaar\AdvertFollower::$userId are int not null default 0 (app/Core/Base/Shared/Bazaar/Models/Entities/Watchdog.php) and an anonymous watchdog is allowed (FormTrait sets no owner for an anonymous user). Without > 0, 0 === 0 would make every anonymous visitor the owner of an anonymous record. WatchdogManager::ownershipMatches() has the same edge for the same reason.

IMPORTANT: $userId is never taken from the request or from a internalAction* parameter. The only source is getJwtPayload()?->getUserId(), set by requireApiPermission(); the userId parameters of internalAction* methods (internalActionSummary, internalActionMyOrders, internalActionUpdatePassword) are the caller's claim, not an identity.

IMPORTANT: the entity must come from the DB, not from request data, otherwise the check compares the caller's data with itself. The $owned closure on the caller's side (SaveTrait::internalActionSave(), DeleteTrait::internalActionDelete()) ensures that; this method only reads the entity and must not build one.

Parameters
$entity : object|null

entity loaded by the $owned closure from the DB by the id from the method parameter

$userId : int

ID from the JWT payload, never from the request

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

write payload; for an INSERT the only source of identifiers (the 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

verifyOwnership()

Delegates to the generic base in {@see RbacCheckTrait::verifyOwnership()}.

protected verifyOwnership(int $userId) : bool

The base takes the record id from the request, calls manager->find() and accepts an entity with OwnerAwareInterface and a matching positive id; an INSERT without an id, an anonymous caller or a missing interface gives false (fail-closed). A concrete presenter overrides it and delegates to its Api manager; SQL does not belong in a presenter.

IMPORTANT: never change this to return true; it would open every presenter using the trait.

Parameters
$userId : int

ID from the JWT payload, never from the request

Return values
bool

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>
On this page

Search results