You can protect queries in any way you’d like, however Kilpi offers protected queries created using Kilpi.$query
as a solution to co-locate your queries and authorization logic.
Defining a protected query
To define a protected query, pass the query function as the first argument to Kilpi.$query
. As the second argument, provide an object with the authorize
method. The authorize
function receives the input
to the query as well as its output
and the current subject
. It then returns the output to which the subject is authorized.
const getPost = Kilpi.$query( // Query function async (id: string) => { return await db.posts.findById(id); }, // Separate but co-located authorization which runs after the query { async authorize({ input: [id], output: post, subject }) { if (!post) return null; const { granted } = await Kilpi.posts.read(post).authorize(); if (!granted) return null; return post; }, },);
You call the protected query using the authorized
method as follows.
// Post or nullconst post = await getPost.authorized("123");
The following pseudocode might make it easier to conceptualize how the .authorized()
and .unauthorized()
APIs work.
function unauthorized(input) { return query(input);}
function authorized(input) { const output = query(input); const subject = getSubject(); return authorize({ input, subject, output });}
Redacting data
A common issue with authorization is redacting data in a type-safe way. This is easy with the Kilpi.$query
API.
const getUserDetails = Kilpi.$query( async (userId: string) => { return await db.users.findById(userId); }, { async authorize({ output: user }) { if (!user) return null; const { granted } = await Kilpi.users.readPrivate(user).authorize();
// Unauthorized: Only return public fields if (!granted) return { userId: user.id, name: user.name };
// Authorized: Return also private fields return { userId: user.id, name: user.name, email: user.email }; }, },);
// TS knows email is optional, as it may be redacted awayconst userDetails = await getUserDetails.authorized(userId);userDetails.email; // string | undefined
Throwing on unauthorized
Commonly, instead of return null
you want to throw and stop execution on unauthorized. Using the assert()
API (or throwing your own exceptions) works very well with protected queries.
const getPost = Kilpi.$query(..., { async authorize({ output: post }) { // Throws on unauthorized if (post) await Kilpi.posts.read(post).authorize().assert(); return post; }});
If you have setup onUnauthorizedAssert
handlers, throwing on unauthorized can remove a lot of boilerplate. See this example of a Next.js page using protected queries and throwing. Note how little if (...) redirect(...)
authorization logic is required.
export default async function PostPage(props: PageProps<"/posts/[id]">) { const { id } = await props.params;
const { subject } = await Kilpi.authed().authorize().assert();
const post = await getPost.authorized(id); const comments = await listComments.authorized(id);
return (...);}