Kilpi offers an optional but powerful Kilpi.query
API for creating protected queries for co-locating your queries with the authorization logic.
What is a protected query and why?
A protected query is a way to co-locate your queries with the authorization logic. It consists of a query function (often a pure function) and a protector function.
When calling a protected query, the query function is called first, and the result is passed through the protector function. The protector is responsible for authorizing the data. On unauthorized, it may throw, return null
or return partial data.
Example query
Below is an example protected query to retrieve a document by ID, which in this case throws when unauthorized.
const getDocument = Kilpi.query( async (id: string) => { return await db.documents.get(id); }, { async protector({ input: [id], output: document, subject }) { if (document) await Kilpi.authorize("documents:read", document); return document; }, },);
await getDocument.protect("123"); // Call with protectorawait getDocument.unsafe("123"); // Or skip the protectorawait getDocument("123"); // Type error (to enforce communicating intent)
Motivation for proteted queries
In many applications, queries and authorization logic are separated, and every time a query is called, you have to authorize the caller to the data (see example below).
This often leads to duplicated and hard to maintain authorization logic and handling of unauthorized casess, and you may forget to authorize the data or update the logic when requirements change.
export async function getDocument(id: string) { return await db.documents.get(id);}
export default async function DocumentPage({ id }) { const document = await getDocument(id); if (!(await Kilpi.isAuthorized("documents:read", document))) { redirect("/login"); } return <Document document={document} />;}
To reduce this duplication and make the code more maintainable, you may attempt co-locating your queries with the authorization logic as follows.
export async function getDocument(id: string) { const document = await db.documents.get(id); return await Kilpi.isAuthorized("documents:read", document) ? document : null;}
export default async function DocumentPage({ id }) { const document = await getDocument(id); if (!document) redirect("/login"); return <Document document={document} />;}
However, this approach has some downsides that Kilpi.query
can solve.
In some contexts (e.g. a cron job), you may want to call the query without authorization. Instead of e.g. baking in custom skipAuthorization
parameters, Kilpi.query
allows you to call the query without authorization with .unsafe()
.
export async function createDocumentPdfCronJob({ id }) { const document = await getDocument.unsafe(id); const pdf = await createPdf(document); await savePdf(pdf);}
In some cases you want your functions to be pure (for example with Next.js pure functions can be cached with "use cache"
). Running authorization logic, which depends on the caller makes your functions inherently impure.
With Kilpi.query
, you can make the inner function pure (and e.g. cacheable).
const getDocument = Kilpi.query( async (id: string) => { "use cache"; // <-- Works because the function is pure return await db.documents.get(id); }, { async protector({ ... }) { ...} },);
When collaborating with other developers, communicating intent in your code is important. For example, guess whether the following endpoint is safe?
app.get("/documents/:id", async (req, res) => { const document = await getDocument(req.params.id); if (!document) return res.status(404).send("Not found"); return res.send(document);});
Protected queries enforce explicilty communicating your intent by selecting either .protect()
or .unsafe()
when calling the query. This makes it very clear to yourself and other developers that the following query is safe.
app.get("/documents/:id", async (req, res) => { const document = await getDocument(req.params.id); const document = await getDocument.protect(req.params.id); if (!document) return res.status(404).send("Not found"); return res.send(document);});
And even more so with .unsafe()
, which immediately signals to others that the data from the query should not be exposed to users.
export async function backupDocumentDataCronJob(id: string) { const document = await getDocument.unsafe(id); await backup(document);}
In all these examples, you have to duplicate the unauthorized logic every time. When a page starts to have multiple queries and authorizations scattered across multiple functions or components, this can lead to a lot of duplicated code.
export default async function DocumentPage({ id }) { const isAuthed = await isAuthenticated(); if (!isAuthed) redirect("/login");
const document = await getDocument(id); if (!document) redirect("/login");
return <Document document={document} />;}
function Document({ document, comments }) { const comments = await getComments(document.id); if (!comments) redirect("/login");
return (...);}
Especially when using protectors that throw on unauthorized (using e.g. Kilpi.authorize
or Kilpi.unauthorized
) you can write your unauthorized logic once, either as a default error handler or a request level error handler. This allows for simpler code with less duplication.
// Option 1. Use default error handlerexport const Kilpi = await createKilpi({ // ... settings: { defaultOnUnauthorized(error) { redirect(`/login?message=${error.message}`); }, },});
export default async function DocumentPage({ id }) { // Option 2. Use request level error handler Kilpi.onUnauthorized((error) => redirect(`/login?message=${error.message}`));
// No need for unauthorized logic await Kilpi.authorize("authenticated"); const document = await getDocument.protect(id);
return <Document document={document} />;}
function Document({ document, comments }) { // No need for unauthorized logic even in nested functions const comments = await getComments.protect(document.id);
return (...);}
Defining protected queries
Defining a protected query is done by wrapping an existing query with Kilpi.query
.
Wrap a query function with Kilpi.query
Create a protected query (without a protector) by wrapping the query function with Kilpi.query
. The query can be any asynchronous function.
export const getDocument = Kilpi.query( // Any asynchronous function async (id: string) => await db.documents.get(id),);
Create a protector
As the second argument, provide an object with a protector
function. It is an asynchronous function that receives the input, output and subject as arguments and returns the authorized output.
Read designing protectors below for more information on how to define protectors.
export const getDocument = Kilpi.query( async (id: string) => await db.documents.get(id), // Add a protector { async protector({ input: [id], // The input to the query function output: document, // The output of the query function subject, // The current subject (user) }) { // Example: Throw on unauthorized if (output) await Kilpi.authorize("documents:read", output); return output; }, },);
Call the protected query
Call the protected query with .protect()
to run the query function and pass the result through the protector.
const document = await getDocument.protect("123");
In special cases, you may optionally skip the protector with .unsafe()
and call the inner query function directly.
const document = await getDocument.unsafe("123");
Designing protectors
There are several ways to design your protectors, depending on your use case.
Throw on unauthorized
The most powerful method is to throw on unauthorized. This allows you to define your error handlers once, either as a default error handler or as request level error handlers. Read more about handling unauthorized errors.
Kilpi.query(..., { async protector({ input, output: doc, subject }) { if (doc && await Kilpi.isAuthorized("docs:read", doc)) { return doc; } return null; }})
If not using Kilpi.authorize
, you can manually trigger the error handlers using Kilpi.unauthorized
.
Kilpi.query(..., { async protector({ input, output: doc, subject }) { if (!doc) return null; if (doc.userId !== subject.id) Kilpi.unauthorized(); return doc; }})
This enables you to write your unauthorized logic once (globally or per request), and call your queries from your functions without having to worry about unauthorized logic.
export async function Page({ id }) { // Specify request level error handler (or use global default error handler) Kilpi.onUnauthorized((error) => redirect(`/login?message=${error.message}`));
// No need to handle unauthorized errors const document = await getDocument.protect(id); const comments = await getComments.protect(document.id);
// Children can also call .protect() without handling unauthorized cases return <Document document={document} />;}
Return null on unauthorized
If you are unable to throw on unauthorized, you can return null
(or other value) to signal unauthorized and handle it when calling the query.
Kilpi.query(..., { async protector({ input, output: doc, subject }) { if (doc && await Kilpi.isAuthorized("docs:read", doc)) { return doc; } return null; }})
You then have to handle the unauthorized case when calling the query.
export async function Page({ id }) { const document = await getDocument.protect(id); if (!document) redirect("/documents"); return <Document document={document} />;}
Redact data
In special cases, you may also want to return redacted (or filtered) data. You can return partial data from the protector. This pattern enables you to get easy typesafety for your redacted data.
const getUser = Kilpi.query( // Get full user details async (id: string) => await db.users.get(id), { async protector({ input: [id], output: user, subject }) { if (!user) return null;
// Authorized to full user data if (await Kilpi.isAuthorized("users:read", user)) return user;
// Only show public data return { id: user.id, name: user.name }; }, },);
Filter data
In addition to returning partial data, you can also return filtered data either manually or by using Kilpi.filter
to filter a set of resources to only the ones passing a specific policy.
-
Define a policy that accepts a resource.
docs: {read(user, doc: Document) {return user.id === doc.userId ? grant(user) : deny();},}, -
Call
Kilpi.filter
to get only the resources that pass the policy for the current subject.const docs = await listAllDocuments();const authorizedDocs = await Kilpi.filter("docs:read", docs); -
Apply
Kilpi.filter
in your protector to filter the data to only the authorized entries.Kilpi.query(..., {async protector({ output }) {return Kilpi.filter("docs:read", output);},});