Policies

Learn how to define and structure authorization policies with Kilpi.


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 assertions
const { 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 details
const 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.

src/posts.policies.ts
import type { Policyset } from "@kilpi/core";
export const postPolicies = {
async create(subject) { ... },
async update(subject, post: Post) { ... },
} as const satisfies Policyset<MySubject>;
src/orgs.policies.ts
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().