Skip to content

Permissions

Outer uses one permission vocabulary everywhere: on procedures, on resources, and on file uploads. You declare the guard next to the endpoint instead of writing the check inside the handler.

Before you begin

Every value except "public" needs a session, so call .auth() somewhere in the chain. Forget it and .build() throws at startup, listing the endpoints that need it.

The values

ValueMeaningRejects with
"public"No restriction. The default.
"authenticated"The caller must be signed in.401
"admin"The caller must hold an admin role. user.role may be a comma-separated list.403
"owner"The caller must own the row. Requires ownerColumn, and needs a row.403
A function({ context, row? }) => boolean | Promise<boolean>403

Pass roles alongside a permission to change which roles "admin" accepts. It defaults to ["admin"].

On procedures

Pass the guard as the third argument to .procedure():

.procedure("post.publish", (base) => base.handler(...), { permission: "authenticated" })
.procedure("stats.purge",  (base) => base.handler(...), { permission: "admin", roles: ["staff"] })
.procedure("team.invite",  (base) => base.handler(...), {
  permission: ({ context }) => context.user?.email.endsWith("@acme.com") ?? false,
})

There is no "owner" here, because an owner check needs a row and a bare procedure has none. Use a resource, or check inside the handler.

On resources

.resource() takes a permission per CRUD action, plus the ownerColumn that "owner" compares against:

.resource("post", {
  permissions: {
    list: "public",          // anyone can list
    get: "public",           // anyone can read
    create: "authenticated", // must be signed in; userId auto-filled
    update: "owner",         // only the creator can edit
    delete: "admin",         // only admins can delete
  },
  ownerColumn: "userId",
})

What ownerColumn does

Three behaviors follow from it:

  • When create is "authenticated" and ownerColumn is set, Outer injects the current user's ID into the insert. createMany injects it into every row, and you never pass it in the request.
  • When list is "owner", results are scoped to the signed-in user's rows and AND-composed with any caller-supplied where. The filter runs in SQL, so nothing extra is fetched and then discarded. Unauthenticated calls get a 401.
  • When update or delete is "owner", Outer fetches the row first and compares row[ownerColumn] to the session user's ID. A mismatch returns 403.

"owner" is not valid on create, since there is no row yet.

Streaming resources

{name}.live reuses the list permission rather than adding one of its own, so a stream can never be looser than the one-shot read. See Stream a list with live for how long-lived subscriptions are re-checked.

On file routes

.files() takes the same vocabulary for upload, list, get, and delete. It defaults to "authenticated" for upload and list, and "owner" for get and delete.

One difference is worth knowing: an "owner"-guarded download answers 404, not 403, so file IDs cannot be probed for existence. See File uploads.

Checks happen at build time

Outer validates your permission configuration while you build the chain, not on the first request:

  • "owner" on any action without ownerColumn throws as soon as .resource() is called.
  • Any non-public permission without .auth() anywhere in the chain throws at .build(), listing the offending names.

Next steps

  • Auth — where the session these checks read comes from
  • Resources — the actions you are guarding