All server-side authorizations happen with the authorize()
API.
Authorization using authorize()
Reference a policy as e.g. Kilpi.posts.edit(post)
and call the policy’s authorize()
method. This evaluates the policy and returns an authorization decision. In many cases, this API is all you need.
const decision = await Kilpi.posts.edit(post).authorize();if (decision.granted) { await db.posts.update(...);}
The decision is either a granted or a denied decision.
type Decision<TSubject> = | { granted: true; // Signals "granted decision" (Discriminator) subject: TSubject; // The subject from the policy's Grant(subject) } | { granted: false; // Signals "denied decision" (Discriminator) message?: string; // User-facing message, e.g. "You are not signed in" reason?: string; // System-facing reason, e.g. "UNAUTHORIZED" metadata?: unknown; // Optional metadata about the denial };
To specify a policy, access it via dot notation and call it to “instantiate” it (optionally provide the object if the policy requires one). This returns you the policy object, which has e.g. the authorize
method.
export const Kilpi = createKilpi({ /* ... */ policies: { async admin(subject) {...}, // Root-level policy posts: { // Namespace async create(subject) {...}, // No object write: { // Nested namespaces async edit(subject, post: Post) {...}, // Needs "Post" object } }, }});
// Use as followsawait Kilpi.admin().authorize();await Kilpi.posts.create().authorize();await Kilpi.posts.write.edit(post).authorize();
Providing a context
If your getSubject
adapter requires a ctx
parameter (e.g. the current Request
), you can pass it as follows.
await Kilpi.myPolicy().authorize({ ctx });
In rare cases, you can even override the getSubject
adapter and pass your own subject.
await Kilpi.myPolicy().authorize({ subject: mySubject });
This may be useful for e.g. testing or admin functionality, where an admin (the subject) wants to check the access of another user.
Throw on unauthorized with assert()
In very many cases, you want to throw on unauthorized, especially when your framework supports throwing e.g. HTTP errors or redirections. For this purpose, you can use the assert()
API as follows.
// Always returns a granted decision (or throws)const { subject } = await Kilpi.myPolicy().authorize().assert();
When on onUnauthorizedAssert
handlers are defined, Kilpi throws a KilpiUnauthorizedError
. To customize what is thrown (or to run side effects), you can provide a global onUnauthorizedAssert
handler.
export const Kilpi = createKilpi({ // ... // Called when `.assert()` denies access. async onUnauthorizedAssert(denial) { console.log(`Denied: ${denial.message}`); throw new HttpForbiddenError(); },});
To override the default behavior, you can also provide an onUnauthorizedAssert
callback to the assertion to run before the global handler.
await Kilpi.usePremiumFeature() .authorize() .assert(async (denial) => { throw HttpRedirect(`/subscribe?message=${denial.message}`); });
Kilpi has three types of onUnauthorizedAssert
handlers, which run when assert()
denies access.
// Called first: Assertion-specific overrideawait Kilpi.some.policy().authorize().assert((denial) => { ... });
// Called in-between: onUnauthorizedAssert hooks// (If multiple hooks registered, they can be called in any order)Kilpi.$hooks.onUnauthorizedAssert((denial) => { ... })
// Called last: global onUnauthorizedAssertexport const Kilpi = createKilpi({ async onUnauthorizedAssert(denial) { ... }})
All of these handlers can run side-effects and throw. Importantly, Kilpi will run all of the handlers, even if one of them throws. After all handlers are run, the first encountered thrown exception is rethrown. If none threw, Kilpi defaults to throwing a KilpiUnauthorizedError
.
Handling different types of denials
The Deny()
function can provide additional data about the reason of the denial. This data can then be used anywhere you have the denied decision, such as in the global onUnauthorizedAssert
handler as shown below.
const Kilpi = createKilpi({ // ... policies: { posts: { // Only signed-in premium users can save posts async save(subject, post: Post) { if (!subject) { return Deny({ reason: "UNAUTHENTICATED" }); } if (subject.tier !== "premium") { return Deny({ reason: "NOT_SUBSCRIBED", metadata: { requiredTier: "premium" }, }); } return Grant(subject); }, }, },
// Customize behavior based on reason async onUnauthorizedAssert(denial) { switch (denial.reason) { case "UNAUTHENTICATED": throw new HttpRedirect("/sign-in"); case "NOT_SUBSCRIBED": const tier = denial.metadata?.requiredTier; throw new HttpRedirect(`/subscribe?tier=${tier}`); default: throw new HttpForbiddenError(); } },});