Access Control
Declare the roles callers hold and say who may call each route. Kizuna checks the caller's role before the handler runs.
Authentication turns a credential into the caller. This page decides what that caller may do.
There are three levels, and each one adds to the one before it:
| Level | The route says | Enough when |
|---|---|---|
| Identity only | auth: 'member' | Logged in is the whole question. |
| Roles | roles: 'owner' | Some routes are for some kinds of member. |
| Roles with permissions | requires: { project: ['delete'] } | What a role may do changes, or differs per member. |
Pick one and the examples follow:
Declare who can call
Put the roles on the identity whose callers hold them:
That adds role to what the guard returns and what handlers receive, typed as 'member' | 'admin' | 'owner'.
Say who may call each route
Every route carries its own auth, so a route cannot be added without stating its rule, and public is an explicit false.
A route names the identity, and the roles it accepts:
A public route says so:
A role or permission a route names must be one the identity declares, so a typo does not compile.
Return the caller from the guard
The role is a column on the membership row or a claim in the token. The guard reads it:
Kizuna answers 403 before the handler when the role is not one the route accepts. The handler reads it under auth.member.role.
A caller with several roles returns them as an array, and passes when any of them is accepted.
What an auth can say
| Value | Meaning |
|---|---|
false | Public |
'member' | Requires the member identity, any role |
['user', 'member'] | Either identity |
{ identity, roles?, requires? } | The same, narrowed |
'member' is shorthand for { identity: 'member' }. roles checks who the caller is and requires checks what they hold, so a route with only roles does not look at permissions.
Permissions
Roles built from a catalog give the handler everything the caller holds under auth.member.permissions, typed to the catalog.
The verbs are yours. Naming them after what the route does, send for an invite rather than create, tends to read best in the map.
Permissions per member
A role says what a member can be given. Which of them one member has is a row, and the guard returns it as permissions:
Kizuna treats the list as what the caller holds, and fails the guard if it names something the role cannot hold. A guard that returns no list hands over the whole role, which is what an 'all' role wants.
The route that saves the list validates each entry against the catalog:
A permission outside the catalog is a 400 before the handler runs:
Rows
The map says whether the caller may call the route. Which rows are theirs is a second question, answered in the handler with the row in hand, and row-level security says which rows a query may see at all:
| Layer | Runs | Answers |
|---|---|---|
| The map | In the guard, before the handler | May this caller call this route |
| The handler | With the row it loaded | Is this row the caller's |
| Row-level security | In the database, inside the query | Which rows this request may see at all |
Kizuna runs the first. You write the second, with the row and auth in hand. The third is the database's.
One row
A member updates the projects they own. The map lets members in, and the handler settles whose project it is with the row in hand:
The 403 is the one every guarded route already declares, so the handler answers it without declaring it.
Something about the row alone is a conflict
An archived project that cannot be updated reads the row and never the caller. That is a 409 from the handler, since no role could
ever get past it.
Every row
Kizuna cannot filter a list
It runs before the query, never inside it. A list that forgets its where returns every tenant's rows.
The database can. Postgres row-level security applies a filter to every query on the table:
Set the workspace per request, inside a transaction, so it ends with the request:
OAuth
An OAuth token's scopes are permissions. The guard returns them as permissions, and requires checks them like any other. See OAuth.
What it drives
defineConfig puts 401 and 403 on every route this map guards, as Problem Details, both no-store. A public route gets neither:
generateOpenApi emits each route's security, the roles it accepts under x-kizuna-roles, and the permissions it requires under x-kizuna-requires: