#$owns
#Import
1import { $owns } from "alepha/security";
#Overview
Resource-scoped authorization gate.
Roles and permissions answer "what kind of user is this?". They cannot answer "does this user own row 42?", so that check ends up inline in every handler - where nothing enforces its presence and a forgotten call is a silent authorization hole.
$owns loads the row named by a route param, checks the caller against it,
and publishes it via OwnedResourceProvider so the handler does not
re-fetch what the gate already read. An application whose request does not
name the row - a single-tenant deployment with exactly one container -
computes the id with OwnsOptions.resolve instead.
Three checks, applied in order:
- Owner:
row[owner] === user.id. - Membership: when
viais set, a row in the join entity links the caller to this resource. - Grant: when
requiresis set,ResourceGrantsProvidersays whether the caller holds that permission INSIDE this resource. Its default answers allow, so an application that never substitutes it behaves exactly as it did before the option existed.
Both are read off the row the param names, unless through says ownership
lives one hop away - on the project a quest belongs to, say. The resource
is still published to OwnedResourceProvider.get(); the row the decision
was actually made against is published to authority().
A privileged identity (user.ownership === false) bypasses both, matching
the ownership semantics $secure already applies: an admin whose grant is
not narrowed to rows they own. Note this is deliberately strict - an
undefined ownership does not bypass, because undefined only means
"no permission check ran", not "this caller is privileged".
1class CampaignController { 2 read = $action({ 3 path: "/campaigns/:id", 4 use: [ 5 $secure(), 6 $owns({ 7 repository: () => this.campaigns, 8 param: "id", 9 owner: "createdBy",10 cast: Number,11 via: {12 repository: () => this.characters,13 resource: "campaignId",14 user: "userId",15 },16 }),17 ],18 handler: async () => this.owned.get<Campaign>(),19 });20}
#Options
| Option | Type | Required | Description |
|---|---|---|---|
repository |
Object |
Yes | Repository the guarded resource is loaded from, as a thunk |
param |
string |
No | Key holding the resource id, in whichever source OwnsOptions.from names |
resolve |
Object |
No | Compute the resource id instead of reading it off the request |
from |
"params" | "query" | "body" |
No | Where to read OwnsOptions.param from |
through |
OwnsHop | OwnsHop[] |
No | The second hop: say that ownership is not held by the row the param names, but by a row it belongs to |
owner |
string |
No | Column holding the owner's user id, on the row the decision is made against - the resource itself, or the row through lands on |
requires |
string | string[] |
No | Permission(s) the caller must hold inside this resource, on top of being its owner or one of its members |
via |
Object |
No | Membership fallback: a join entity linking users to the row the decision is made against |
cast |
Object |
No | Coerce the raw value before querying |
cache |
StatementOptions["cache"] |
No | Cache window for the authority read - the row the gate decides against, which is the resource itself when there is no through |
message |
string |
No | Message used for both the owner and the membership denial |
secure |
Omit<SecureOptions, "guard"> |
No | Additional $secure checks layered on top - roles, permissions, issuers. |