Access Control
Declare the roles callers hold and say who may call each route. kizuna checks the caller's role before the handler runs.
The least settled part of kizuna. Kizuna.roles, Kizuna.permissions and k.accessControl may change shape, move out of the contract, or be removed before v2. Pin your version, and expect to rewrite what you build on them.
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 map says | Enough when |
|---|---|---|
| Identity only | '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
export const roles = Kizuna.roles(['member', 'admin', 'owner']);Put the roles on the identity whose callers hold them:
export const member = Kizuna.identity.apiKey({
name: 'x-workspace-token',
in: 'header',
context: z.object({
userId: z.string(),
workspaceId: z.string(),
}),
roles,
});That adds role to what the guard returns and what handlers receive, typed as 'member' | 'admin' | 'owner'.
Say who may call each route
Every group must appear in the map, so public is an explicit false.
A route names the identity, and the roles it accepts:
export const accessControl = k.accessControl(routes, {
health: false,
projects: {
'*': 'member',
createProject: {
auth: 'member',
roles: ['admin', 'owner'],
},
deleteProject: {
auth: 'member',
roles: 'owner',
},
},
invites: {
cancelInvite: {
auth: 'member',
roles: ['admin', 'owner'],
},
},
});export const contract = k.contract({
routes,
accessControl,
});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:
export const requireMember = server.guard('member', async ({ apiKey, params, deny }) => {
const membership = apiKey ? await findMembership(apiKey.value, params.workspaceId) : undefined;
if (!membership) {
return deny({
status: 403,
body: {
detail: 'Forbidden',
},
});
}
return {
userId: membership.userId,
workspaceId: membership.workspaceId,
role: membership.role,
};
});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 entry can say
| Entry | Meaning |
|---|---|
false | Public |
'member' | Requires the member identity, any role |
{ auth, roles?, requires? } | The same, narrowed |
{ '*': ..., login: false } | '*' is the group default, named keys override it |
'member' is shorthand for { auth: 'member' }. roles checks who the caller is and requires checks what they hold, so a route with only roles does not look at permissions. A subgroup key covers its whole subtree, and can nest its own '*'. A key matching nothing in the group is an error.
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:
return {
userId: membership.userId,
workspaceId: membership.workspaceId,
role: membership.role,
permissions: membership.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:
setPermissions: {
method: 'PUT',
path: '/members/:userId/permissions',
body: z.object({
permissions: z.array(permissions.schema),
}),
},A permission outside the catalog is a 400 before the handler runs:
{
"permissions": ["project:read", "project:archive"]
}{
"type": "about:blank",
"title": "Bad Request",
"status": 400,
"detail": "Request validation failed",
"errors": [
{
"code": "invalid_value",
"path": ["permissions", "1"],
"message": "Invalid option: expected one of \"workspace:read\"|\"workspace:update\"|...|\"invite:accept\""
}
]
}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:
updateProject: async ({ params, body, auth }) => {
const project = await db.query.projects.findFirst({
where: eq(projects.id, params.projectId),
});
if (!project) {
return {
status: 404,
body: {
detail: 'Project not found',
},
};
}
if (project.ownerId !== auth.member.userId) {
return {
status: 403,
body: {
detail: 'Not your project',
},
};
}
return {
status: 200,
body: await saveProject(project.id, body),
};
},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:
alter table projects enable row level security;
alter table projects force row level security;
create policy "own workspace" on projects
using (workspace_id = current_setting('app.workspace_id', true));Set the workspace per request, inside a transaction, so it ends with the request:
export const withWorkspace = <Result>(workspaceId: string, run: (tx: Transaction) => Promise<Result>) =>
db.transaction(async (tx) => {
await tx.execute(sql`select set_config('app.workspace_id', ${workspaceId}, true)`);
return run(tx);
});listProjects: async ({ auth }) => ({
status: 200,
body: {
projects: await withWorkspace(auth.member.workspaceId, (tx) => tx.query.projects.findMany()),
},
}),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
k.contract puts 401 and 403 on every route this map guards, as Problem Details, both no-store. A public route gets neither:
const result = await apiClient.projects.deleteProject({
params: {
projectId: 'prj_42',
},
});
if (result.status === 403) {
console.log(result.body.detail);
}generateOpenApi emits each route's security, the roles it accepts under x-kizuna-roles, and the permissions it requires under x-kizuna-requires:
# generated
paths:
/projects/{projectId}:
delete:
security:
- member: []
x-kizuna-roles: [owner]Reference
AuthenticationBeta
Say who can call your API. An identity is a credential you accept, a guard turns it into the caller, and the handler receives them typed.
OAuthAlpha
Verify tokens from an authorization server. Declare the identity, secure the routes, verify in the guard, and serve the discovery document.