#Resource Authorization
$secure answers what kind of user is this? - issuer, roles, permissions. It cannot answer does this user own row 42?, because ownership is a property of the data, not of the token.
1import { $inject } from "alepha";2import { $action } from "alepha/server";3import { $owns, $secure, OwnedResourceProvider } from "alepha/security";
#The problem
Without a resource gate, ownership checks live inside handlers:
1// Works, but nothing enforces it. 2read = $action({ 3 path: "/campaigns/:id", 4 use: [$secure({ permissions: ["campaign:read"] })], 5 handler: async ({ params, user }) => { 6 const campaign = await this.campaigns.getOne({ 7 where: { id: { eq: params.id } }, 8 }); 9 if (campaign.createdBy !== user.id) {10 throw new ForbiddenError("Not yours");11 }12 return campaign;13 },14});
The check is correct. The problem is that it is invisible: no test fails if the next endpoint forgets it, no tooling can see that this route is owner-scoped, and the rule gets copy-pasted into every handler that touches the resource.
#$owns
Move the rule into the middleware chain, where its absence is visible:
1class CampaignController { 2 protected readonly campaigns = $repository(campaigns); 3 protected readonly owned = $inject(OwnedResourceProvider); 4 5 read = $action({ 6 path: "/campaigns/:id", 7 use: [ 8 $secure({ permissions: ["campaign:read"] }), 9 $owns({10 repository: () => this.campaigns,11 param: "id",12 owner: "createdBy",13 cast: Number,14 }),15 ],16 handler: async () => this.owned.get<Campaign>(),17 });18}
cast coerces the value before querying. It is rarer than it looks: the guard runs after request validation, so a param declared z.integer() arrives already decoded to a number, and findById coerces whatever is left to the primary key's declared type. Reach for it when the value needs a transformation the schema cannot express - an undeclared param, or a slug to decode.
repository is a thunk rather than the repository itself. $owns() runs during class-field initialization, so a $repository() field declared after it would not exist yet; deferring the lookup to request time makes field order irrelevant.
#The loaded row is handed to you
$owns has to read the row to make its decision, so it publishes it rather than throwing it away. Inject OwnedResourceProvider and read it back - no second query:
1handler: async () => {2 const campaign = this.owned.get<Campaign>(); // already loaded by the gate3 return this.present(campaign);4};
get() throws if no $owns ran, because that is a wiring mistake rather than a runtime condition. Use find() when a handler is legitimately reachable both with and without the gate.
#Membership: via
Shared resources are rarely owner-only. Point via at the join entity:
1$owns({ 2 repository: () => this.campaigns, 3 param: "id", 4 owner: "createdBy", 5 cast: Number, 6 via: { 7 repository: () => this.characters, 8 resource: "campaignId", 9 user: "userId",10 },11});
Checks run in order: owner first, then membership. When you supply the message option, it's used for both denials on purpose - a different message per branch tells an attacker whether the resource exists and who owns it. (Without a custom message, the defaults differ; set one for endpoints where that distinction matters.)
owner is optional, so a via-only gate is legal: membership is then the whole answer, which is what an application that has stopped treating "who created the row" as an authorization input wants. A gate with neither owner nor via would allow every authenticated caller, so it is refused when the class is constructed rather than at request time.
#What a member may do: requires
via answers whether you are in. It does not answer what you may do once you are, and applications that give their members different powers - a viewer, a contributor, an administrator - need that second answer from the same gate. A rule split across a middleware and a hand-written check in the handler is a rule with two versions of itself.
1$owns({ 2 repository: () => this.campaigns, 3 param: "id", 4 via: { 5 repository: () => this.members, 6 resource: "campaignId", 7 user: "userId", 8 }, 9 requires: "release:manage",10});
One string, checked twice. It is folded into secure.permissions, so the application-scope check runs exactly as a separate $secure({ permissions: ["release:manage"] }) beside the gate would have - and it lands in the middleware's options, which is what publishes it to the client's action registry and lets the UI hide a control nobody may use. It is then handed to ResourceGrantsProvider for the resource-scope check. No call site can name one permission at one layer and a different one at the other.
ResourceGrantsProvider's default answers allow, unconditionally. An application that never substitutes it behaves exactly as it did before requires existed, whether or not its call sites use the option. Substitute it like any other seam:
1alepha.with({ provide: ResourceGrantsProvider, use: RankGrantsProvider });
An implementation receives the rows the gate already read - the authority row and the membership row - and never their ids. That is the whole performance contract: it cannot go and query for the assignment, because it was handed nothing to query with, so the assignment has to be a column on a row the request already pays for. It answers { allowed: true }, or { allowed: false, message } - the message being its own to write, since it is the only party that knows which conjunct failed.
⚠️ An owner reads the membership row when requires is set. The owner check short-circuits before the join, which is exactly why the owner of a resource costs one read fewer than a plain member - and a permission set lives on that join row. A gate that named a permission and then skipped the read would hand the owner an empty grant.
#The second hop: through
via only works when the route param names the thing being shared. It usually doesn't. Membership lives on a campaign; the route names a quest that belongs to one. There is no join to make, and via cannot express the rule at all.
through says the authority row is one hop away:
1$owns({ 2 repository: () => this.quests, // the row the param names 3 param: "id", 4 through: { column: "campaignId", repository: () => this.campaigns }, 5 owner: "createdBy", // read off the CAMPAIGN 6 via: { 7 repository: () => this.characters, 8 resource: "campaignId", 9 user: "userId",10 },11});
Picking the wrong one of the two is silent, so it is worth stating the distinction plainly:
| The route param names… | Use |
|---|---|
| the row that carries the membership | via alone |
| a row that belongs to that row | via + through |
owner and via keep their meaning; through only says which row they apply to. via.resource is matched against the resolved foreign key, so a membership in a different campaign does not accidentally match.
A null or absent foreign key denies. An orphan row must not become world-readable, and falling through would refuse it only by accident.
Pass an array to chain, when the resource does not carry the foreign key itself and neither does the next row:
1through: [2 { column: "questId", repository: () => this.quests },3 { column: "campaignId", repository: () => this.campaigns },4];
Only the last link is the authority. Keep chains short: each link is a query, and a rule that needs four of them is usually a missing column rather than a missing feature.
#Reading the authority row back
OwnedResourceProvider.get() always returns the row the param named. authority() returns the row the decision was made against - the same row without through, the hopped-to row with it:
1handler: async () => {2 const quest = this.owned.get<Quest>();3 const campaign = this.owned.authority<Campaign>();4 return { quest, ownerId: campaign.createdBy };5};
Both are published before the access decision, so a handler reads them identically on the owner, member and privileged paths.
#Where the id comes from: from
By default the id is a route param. An endpoint that takes it in the query string or the request body sets from:
1$owns({2 repository: () => this.campaigns,3 param: "campaignId",4 from: "query", // "params" (default) | "query" | "body"5 owner: "createdBy",6});
A body value is caller-controlled in a way a path segment is not. That widens nothing: it is still just an id handed to findById, and the gate is what decides access - a caller naming somebody else's row gets a 403 for it.
#Caching the authority read
cache is passed straight through to the authority read - the row the gate decides against, which is the resource itself when there is no through:
1$owns({2 repository: () => this.campaigns,3 param: "id",4 owner: "createdBy",5 cache: { ttl: 30_000 },6});
Deliberately not applied to the membership read. A membership row is the grant, so caching it caches an authorization decision and revocation stops taking effect on the next request.
Independently of cache, $owns memoizes its reads for the lifetime of one request. A page that loads seven things at once sends one batched request, and every entry gates independently; without the memo the same (user, resource) pair is resolved seven times over. The two are not the same mechanism: the memo is deterministic (every gate in one request shares one query, warm process or cold) and never outlives the request, so it preserves revocation semantics exactly; cache is opportunistic across requests and trades a staleness window for the saving.
#Privileged identities
A caller with ownership === false bypasses both checks. That is the same ownership flag $secure sets from the permission registry: false means an admin whose grant is not narrowed to their own rows.
This is deliberately strict - undefined does not bypass. undefined only means no permission check ran, which is not the same as "this caller is privileged". If you are migrating hand-written authz that treated !user.ownership as the bypass, note that undefined used to pass and now does not.
#Raw guards
For rules that are not owner- or membership-shaped, $secure's guard sees the whole request:
1$secure({2 guard: async ({ user, params, body, alepha }) => {3 const invite = await alepha.inject(InviteService).find(params.token);4 return invite?.email === user.email;5 },6});
Guards may be async and run after all other $secure checks. params, query, and body come from the action request when there is one, falling back to the raw HTTP request - so the same guard works over HTTP, over action.run(), and over MCP.
#Browser Behavior
On the client, $secure returns undefined instead of throwing, and the guard sees empty params. A guard that reads request data therefore denies in the browser and is re-evaluated for real on the server. $owns goes further: ownership lives in database rows the browser can't load, so its browser variant always returns undefined - the server-side gate is what actually enforces it.
That is the safe direction: the UI hides the action, and the API is what actually enforces it. Never treat a client-side pass as authorization.