alepha@docs:~/docs/guides/server$
cat 11-resource-authorization.md | pretty
3 min read
Last commit:

#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.

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

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

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

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

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

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