#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({ where: { id: { eq: params.id } } }); 7 if (campaign.createdBy !== user.id) { 8 throw new ForbiddenError("Not yours"); 9 }10 return campaign;11 },12});
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 route param before querying — route params are always strings, so integer primary keys need Number.
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
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. Both denials raise the same message on purpose — a different message per branch tells an attacker whether the resource exists and who owns it.
#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 behaviour
On the client, $secure and $owns return 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.
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.