A policy is an asynchronous function. It receives the subject and optionally an object, then returns an authorization decision. The decision is either granted with Grant(...)
or denied with Deny(...)
. Policies are always run on the server.
Each policy uniquely corresponds to an action, such as posts.edit
.
Defining policies
Define policies as the policies
argument to createKilpi
. Each policy function must either return Grant(subject)
or return Deny(...)
.
export const Kilpi = createKilpi({ // ... policies: { // Root-level policy without object async admin(subject) { return subject?.isAdmin ? Grant(subject) : Deny(); },
// Nested policy with object posts: { async edit(subject, post: Post) { return subject?.id === post.userId ? Grant(subject) : Deny(); }, },
// Deeply nested policy with object and data fetching organizations: { members: { async delete(subject, { orgId }: { orgId: string }) { if (!subject) return Deny(); const role = await getUserRole(subject.id, orgId); if (role !== "admin") return Deny(); return Grant(subject); }, }, }, },});
The subject
type is automatically inferred, but the optional object type must be manually provided. Policies can be nested to any depth.
The policies are then referenced as e.g.
Kilpi.always();Kilpi.posts.edit(myPost);Kilpi.organizations.members.delete({ orgId });
Grant(subject)
: Subject narrowing
Instead of returning true
, the function returns Grant(subject)
. This allows Kilpi to infer the narrowed down type subject type when authorizing and reduce boilerplate.
export const Kilpi = createKilpi({ // ... policies: { async authenticated(subject) { if (!subject) return Deny(); return Grant(subject); // TS infers subject here is not null }, },});
// TS also infers that subject is not null here, no// `if (decision.subject)` or `subject?.id` required.const decision = await Kilpi.authenticated().authorize();if (decision.granted) { console.log(decision.subject.id);}
// Similarly for assertionsconst { subject } = await Kilpi.authenticated().authorize().assert();console.log(subject.id);
Deny(...)
: Additional denial data
Similarly, instead of returning false
, the policy function returns Deny(...)
. The denial can be provided additional data about why the policy failed. All metadata is optional and you can simply return Deny()
when no additional data is required.
export const Kilpi = createKilpi({ // ... policies: { posts: { async edit(subject, post: Post) { if (!subject) { return Deny({ message: "You are not signed in", // User-facing reason: "UNAUTHENTICATED", // System-facing }); }
if (subject.tier !== "premium") { return Deny({ message: "Not subscribed", // User-facing reason: "NOT_SUBSCRIBED", // System-facing metadata: { requiredTier: "premium", // System-facing }, }); }
return Grant(subject); }, }, },});
// Customize unauthorized behavior based on denial detailsconst decision = await Kilpi.posts.edit(post).authorize();if (!decision.granted) { showMessage(decision.message); if (decision.reason === "NOT_SUBSCRIBED") { redirect(`/subscribe?tier=${decision.metadata?.requiredTier}`); }}
Data fetching
Policies are asynchronous and run on the server. This means they can fetch data from the database or any other source, allowing you to create a stateful authorization system.
export const Kilpi = createKilpi({ // ... policies: { posts: { // A free user can create at most 3 posts async create(subject) { if (!subject) return Deny(); if (subject.tier === "premium") return Grant(subject);
// Fetch # of posts from DB const postsCount = await db.countPostsForUser(subject.id); return postsCount < 3 ? Grant(subject) : Deny(); }, }, },});
Structuring policies
You can structure your policies in any way you’d like. However, as a starting point, we recommend a combination of two methods:
Root-level utility policies
Sometimes you don’t need to specify an action. You just want to check the user is authenticated or an admin. For this case, you may define root-level policies such as authed
or admin
.
export const Kilpi = createKilpi({ /* ... */, // ... policies: { authed: (subject) => subject ? Grant(subject) : Deny(), admin: (subject) => subject?.isAdmin ? Grant(subject) : Deny(), // ... }})
// Get the current user (we can be sure they are an admin)const { subject } = await Kilpi.admin().authorize().assert();
Domain-specific policies
The rest of your policies are recommended to belong to a domain or an “object type”, such as posts
or organizations
, which includes all the available actions for that domain.
export const Kilpi = createKilpi({ /* ... */, // ... policies: { // ... posts: { async read(subject, post: Post) { ... }, async list(subject) { ... }, async create(subject) { ... }, async update(subject, post: Post) { ... }, async delete(subject, post: Post) { ... }, async archive(subject, post: Post) { ... }, // ... } }})
Scaling policies to larger projects
Instead of defining all policies inside the createKilpi
body, it is usually a more scalable approach to split them into multiple files.
import type { Policyset } from "@kilpi/core";
export const postPolicies = { async create(subject) { ... }, async update(subject, post: Post) { ... },} as const satisfies Policyset<MySubject>;
import type { Policyset } from "@kilpi/core";
export const orgPolicies = { async create(subject) { ... }, async update(subject, org: Organization) { ... },} as const satisfies Policyset<MySubject>;
Then finally combine them in createKilpi
.
export const Kilpi = createKilpi({ // ..., policies: { posts: postPolicies, orgs: orgPolicies, },});
You can take this nesting as deep as you want, e.g. Kilpi.orgs.memberships.invites.create()
.