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
| Value | Meaning | Rejects 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
createis"authenticated"andownerColumnis set, Outer injects the current user's ID into the insert.createManyinjects it into every row, and you never pass it in the request. - When
listis"owner", results are scoped to the signed-in user's rows and AND-composed with any caller-suppliedwhere. The filter runs in SQL, so nothing extra is fetched and then discarded. Unauthenticated calls get a401. - When
updateordeleteis"owner", Outer fetches the row first and comparesrow[ownerColumn]to the session user's ID. A mismatch returns403.
"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 withoutownerColumnthrows as soon as.resource()is called.- Any non-public permission without
.auth()anywhere in the chain throws at.build(), listing the offending names.