Protected queries


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 protector
await getDocument.unsafe("123"); // Or skip the protector
await 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.


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.

  1. Define a policy that accepts a resource.

    docs: {
    read(user, doc: Document) {
    return user.id === doc.userId ? grant(user) : deny();
    },
    },
  2. 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);
  3. 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);
    },
    });