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:
- Fail-closed default: self::verifyOwnership() returns false. Changing it to true opens every presenter that uses the trait with one line.
- 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 asdata.userId/data.user.id/data.insertUserIdare attacker-controlled and are deliberately never read. - Identity comes from the JWT, never from the request.
verifyOwnership()receives$userIdfrom 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|nullgetOwnedParentId()
Returns the parent id from `data`, e.g. `order` for a complaint or `advert` for a response to an advert.
protected
getOwnedParentId(string $property) : int|null
Parameters
- $property : string
-
name of the to-one property on the entity (camelCase, e.g.
order)
Return values
int|nullgetOwnedRequestId()
Returns the record id from the request parameters (delete path, `?id=5`), or null.
protected
getOwnedRequestId() : int|null
NOTE: the router passes parameters as strings ('5'), hence the normalization. id can
also be an array (bulk delete, DeleteTrait::internalActionDelete() takes int|array); then
it returns null because "owner of one record" makes no sense there and the fail-closed
deny is the right answer.
Return values
int|nullisOwner()
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 <= 0gives false: an anonymous caller (getUser()->getId()is null, the cast gives 0) owns nothing.$entity === nullgives 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 (AdverthasinsertUserId,Order/Complainthave auserassociation) and the presenter must override. - A non-
intreturn gives false:Rating::$userIdis 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
$ownedclosure 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
boolisOwnershipInsert()
Tells whether the request creates a record (no `id` in `data`).
protected
isOwnershipInsert() : bool
Ownership is then verified through the parent's ownership or through a server-set owner.
Return values
boolverifyOwnership()
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
boolgetOwnershipPayload()
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.