alepha@docs:~/docs/reference/primitives$
cat $owns.md | pretty
3 min read

#$owns

#Import

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

  1. Owner: row[owner] === user.id.
  2. Membership: when via is set, a row in the join entity links the caller to this resource.
  3. Grant: when requires is set, ResourceGrantsProvider says 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".

typescript
 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&lt;SecureOptions, "guard"&gt; No Additional $secure checks layered on top - roles, permissions, issuers.