Hear about Adam's experience of integrating Cerbos into a SvelteKit app for fine-grained authorization.
It's always complexity that brings down software development in the end, and not just the complexity of making an app that solves a really complex problem, no it's the "Accidental Complexity" that eventually slows down developer velocity, and makes it harder and harder to maintain an application over time.
Henrik Joreteg phrased it best:
If you don’t actively fight for simplicity in software, complexity will win.
...and it will suck.
But as humans, we have very few ways to actually deal with complexity, mostly we just "move complexity around" or "outsource" the complexity to someone else by using a library or framework. One of the few tools we have for dealing with complexity is Abstraction and Isolation.
What does that mean? Well, to gloss over it a bit too much, it's like how I don't need to know how my car works. I don't need to know how the engine works, how the transmission works, or how the brakes work. I just need to know how to drive it.
I push gas pedal, car go forward.
I turn steering wheel, car turns.
It could be magic, or little dinosaurs in there, I don't care, my interface to the car is simple, and I can drive it.
So, when writing software applications, it's usually a good idea to look for places where you can isolate and abstract parts of the app, to hide a big chunk of complexity behind some simple functions or operations, like the code equivalent of a brake pedal or a steering wheel. So I am often on the lookout for places I can tear off a piece of functionality, where it makes sense to take this bit and wrap it up and move it out from everything else, so that while working in this part, I know I'm dealing with a smaller set of input, and a specific set of output, or state or whatever.
I had never really thought about it before, but Authorization (a.k.a AuthZ) is a very appropriate candidate for this type of isolation. I've abstracted Authentication (AuthN) before, sure, with services like Auth0, Amazon Cognito, or even our own custom auth service, but I've never thought about separating authorization in this way, we just sort of "left it up to the Auth service" by adding a role or whatever and then checking that role when we do stuff in the application code.
But I was introduced to Cerbos, and it was a revelation to me. I had never thought about authorization in this way: even when I was first looking at it I thought, "is this a problem I have?", I'd never felt like my authorization code was too complex, or that it was too hard to maintain. But when I was playing around with it a little, I started to notice how this "architecture" of keeping AuthZ separate and isolated from the rest of the app, really started to simplify things in the codebase, and then ended up having other benefits I just never thought of as well.
My epiphany was this: AuthZ code spreads.
It starts to infect many different parts of the app. It's subtle too because it just sort of "feels" like this is the way we've always done it, but when Tommy gets a ticket to allow the Facilities Managers to update the milage for contractors' work orders, they don't tend to look around and understand the Auth system and figure out the best way to handle this request, they just hard code this:
if (user.account.active && user.properties.includes(order.propertyId)) {
if (user.roles.includes(ROLES.fmanager)) {
// access allowed
Audit.log('update', 'allowed', user, order);
updateMilage(order, update);
} else {
// access denied
Audit.log('update', 'denied', user, order);
throw error(403, 'You do not have permissions to update this order');
}
} else {
// access denied
Audit.log('update', 'denied', user, order);
}
...so they can get back to messing with indexes in the installations table.
(note: not a real story, but you know who you are Tommy!)
With Cerbos you can standardize all the AuthZ logic in one place, and then call it consistently from anywhere in the app, and it's always the same kind of code. Now, I know you can probably get the same benefits from just extracting out your own "AuthZ" module, and keeping those things in one place too, and yes, you should do that, but Cerbos also does that and has some other benefits that I think you'll find compelling too.
Okay, so let's talk about what Cerbos is, and what it isn't.
Cerbos is an open-source Policy Engine that you can run as a "service" somewhere in your infrastructure, for nicely decoupled access control.
So it isn't a library. It's an application that runs alongside your app, maybe deployed as a sidecar or a lambda/FaaS. It does have a bunch of client-sdks for various languages to make it easier to use, but you could also just hit it with fetch()
or curl
.
It also doesn't really deal with Authentication (AuthN) at all. Cerbos adopts a "bring your own identity" philosophy. Cerbos merely consumes the user data that an identity provider (like Auth0 or something homegrown) gives you to make policy decisions. So, it will work with whatever Authentication setup you have, and you can still leave information like what roles the user has, which department they belong to, etc.. in the AuthN service, and just pass that data to Cerbos.
So, what does it do?
Okay, so one thing you can do with it (because there is a lot more it can do, and a lot I haven't explored yet) is you can use it to check access control policies on your resources.
Basically, you send it a request with:
With that, Cerbos will evaluate the policies that you wrote, in a very human-readable YAML config by the way, and return a list of what actions are allowed and which are denied, as well as the reasons why the actions were denied, if they are available.
With the JS SDK, it returns a result
object with an.isAllowed()
method (among other things). It's a simple API, and it's easy to integrate with.
In fact, there are many examples on the Cerbos website of how to use Cerbos with various frameworks, including SvelteKit, Next.js, NestJS, and more, and SDKs for different languages like Go, Rust, Python... just go check it out for yourself, there is quite a lot of them.
Once your app nicely decoupled from the authorization logic like this you may start to notice some benefits.
You may even realize like I did, that a lot of your "business logic" is really just AuthZ logic that you've been writing in your app code. When you move that out to Cerbos, it can simplify your code, and make it much easier to reason about.
So for example, I've been using Cerbos with SvelteKit my favourite web framework.
If you can imagine a little app that has these requirements:
With these authorization specs:
employee
should be able to view, create, and edit their own expense reports, but not approve thememployee
should be able to submit their own expense reports for approvalemployee
should not be able to edit their expense reports after they have been approvedmanager
should be able to view, edit and approve submitted expense reports for all their direct reportsSo, on the Expense Report page you may have a Form Action that looks like this:
export const actions: Actions = {
approve: async ({ locals, request }) => {
const data = await request.formData();
const expenseId = data.get('expenseId');
const expenseReport = await getExpenseReportById(expenseId);
const user = locals.user;
if (
user?.roles.includes('manager') &&
user.team.membersIds.includes(expenseReport.author.id)
) {
await approveExpenseReport({
expenseReportId: expenseReport.id,
approvedBy: user,
approvalDate: Date.now(),
});
return { success: true };
} else {
throw error(
403,
'You do not have permissions to approve this expense report'
);
}
},
};
Which doesn't seem so bad.
But with Cerbos, you can do this:
export const actions: Actions = {
approve: async ({ locals, request }) => {
const data = await request.formData();
const expenseId = data.get('expenseId');
const expenseReport = await getExpenseReportById(expenseId);
const user = locals.user;
const isAllowed = await cerbos.isAllowed({
principal: user,
resource: {
kind: 'expenseReport',
id: expenseReport.id,
attributes: expenseReport,
},
action: 'approve',
});
if (isAllowed) {
await approveExpenseReport({
expenseReportId: expenseReport.id,
approvedBy: user,
approvalDate: Date.now(),
});
return { success: true };
} else {
throw error(
403,
'You do not have permissions to approve this expense report'
);
}
},
};
...and the code that decicides whether or not the user can approve the expense report is in the Cerbos config, and not in your app code.
policies/expense-reports.yaml
---
apiVersion: api.cerbos.dev/v1
resourcePolicy:
version: default
resource: expenseReport
rules:
- actions: ['approve']
effect: EFFECT_ALLOW
roles:
- manager
condition:
match:
all:
of:
- expr: request.resource.attr.submitted == true
- expr: request.resource.attr.author.id in request.principal.attr.team.members
...and you might be thinking, "well, that look like more code and complexity, so what's the big deal?"
So first off, don't be intimidated by that strange YAML file. It's actually based on a spec calledCommon Expression Language (CEL) which is a set of common semantics for expression evaluation, that let different applications interoperate with each other. It's not fully a standard but it's not just some made up Cerbos thing either, it's more likeJSON Schema orJSON-LD.
Okay, So let's say later on, you get some updated requirements that are like:
"Hey gang, so the Directors want to be able to view and approve expense reports for all the employees that are members of the teams that report to them, okay?"
So, you might have to go back and update your code to look something like this:
export const actions: Actions = {
approve: async ({ locals, request }) => {
const data = await request.formData();
const expenseId = data.get('expenseId');
const expenseReport = await getExpenseReportById(expenseId);
const user = locals.user;
if (
(user?.roles.includes('manager') &&
user.team.membersIds.includes(expenseReport.author.id)) ||
(user?.roles.includes('director') &&
user.teams.includes(expenseReport.author?.team.id))
) {
await approveExpenseReport({
expenseReportId: expenseReport.id,
approvedBy: user,
approvalDate: Date.now(),
});
return { success: true };
} else {
throw error(
403,
'You do not have permissions to approve this expense report'
);
}
},
};
...but with Cerbos, the source code stays exactly the same...
/* NO CHANGES TO THIS FILE */
export const actions: Actions = {
approve: async ({ locals, request }) => {
const data = await request.formData();
const expenseId = data.get('expenseId');
const expenseReport = await getExpenseReportById(expenseId);
const user = locals.user;
const isAllowed = await cerbos.isAllowed({
principal: user,
resource: {
kind: 'expenseReport',
id: expenseReport.id,
attributes: expenseReport,
},
action: 'approve',
});
if (isAllowed) {
await approveExpenseReport({
expenseReportId: expenseReport.id,
approvedBy: user,
approvalDate: Date.now(),
});
return { success: true };
} else {
throw error(403, 'Forbidden');
}
},
};
...and just the policy is updated to look like this:
---
apiVersion: api.cerbos.dev/v1
resourcePolicy:
version: default
resource: expenseReport
rules:
- actions: ['approve']
effect: EFFECT_ALLOW
roles:
- manager
condition:
match:
all:
of:
- expr: request.resource.attr.submitted == true
- expr: request.resource.attr.author.id in request.principal.attr.team.members
- actions: ['approve']
effect: EFFECT_ALLOW
roles:
- director
condition:
match:
all:
of:
- expr: request.resource.attr.submitted == true
- expr: request.resource.attr.author.team in request.principal.attr.teams
Why is that better?
So now let's talk about loading the expense reports, probably in a list or table.
Note: Because Cerbos already has a Prisma ORM Adapter, let's assume we are using Prisma in our project. But, you can actually create your own query-plan-adapters to create query filters, because it is a fairly straight forward Consitions ast returned from the PlanResources
api.
The AuthZ logic for the list/table of expense reports is fairly simple.
So our specs are something like:
employee
should be able to see all their own expense reports, but no one elsesmanager
should be able to view all thesubmitted expense reports for all their direct reportsdirector
should be able to view all thesubmitted expense reports employees
inteams
that are assigned to themSo you may have a load function that looks something like this:
import { prisma } from '$lib/db';
import type { PageServerLoad } from './$types';
export const load = (async ({ locals, params }) => {
let expenseReports;
if (locals.user.roles.includes('manager')) {
expenseReports = prisma.expenseReport.findMany({
where: {
author: {
id: { in: locals.user.team.members.map((m) => m.id) },
},
submitted: true,
},
});
} else if (locals.user.roles.includes('director')) {
expenseReports = prisma.expenseReport.findMany({
where: {
author: {
id: {
in: locals.user.teams.flatMap((t) => t.members.map((m) => m.id)),
},
},
submitted: true,
},
});
} else {
expenseReports = prisma.expenseReport.findMany({
where: { authorId: locals.user.id },
});
}
return {
expenseReports,
};
}) satisfies PageServerLoad;
But with our cerbos policy in place out load functioncould look like this
import { queryPlanToPrisma, PlanKind } from "@cerbos/orm-prisma";
import { prisma, FieldMappers } from '$lib/db';
import type { PageServerLoad } from './$types';
export const load = (async ({ locals, params }) => {
const queryPlan = await cerbos.planResources({
principal: locals.user,
resource: {
kind: "expenseReport",
},
action: "read",
});
const queryPlanResult = queryPlanToPrisma({
queryPlan,
// map or function to change field names to match the prisma model
fieldNameMapper: FieldMappers.expenseReport,
});
if (queryPlanResult.kind === PlanKind.ALWAYS_DENIED) {
return {
expenseReports: [];
};
} else {
// Pass the filters in as where conditions
return {
expenseReports: prisma.expenseReports.findMany({
where: queryPlanResult.filters
});
};
}
}) satisfies PageServerLoad;
policies/expense-reports.yaml
---
apiVersion: api.cerbos.dev/v1
resourcePolicy:
version: default
resource: expenseReport
rules:
- actions: ['read']
effect: EFFECT_ALLOW
roles:
- '*'
condition:
match:
expr: request.resource.attr.author.id == request.principal.id
- actions: ['read', 'approve']
effect: EFFECT_ALLOW
roles:
- manager
condition:
match:
all:
of:
- expr: request.resource.attr.submitted == true
- expr: request.resource.attr.author.id in request.principal.attr.team.members
- actions: ['read', 'approve']
effect: EFFECT_ALLOW
roles:
- director
condition:
match:
all:
of:
- expr: request.resource.attr.submitted == true
- expr: request.resource.attr.author.team in request.principal.attr.teams
It's the same policy file we used in the last example, because it's all the policies for the expense reports. We just added the read
action, and the conditions were the same for manager
anddirector
roles.
...and once again, requirements are updated!
"So, hey gang, Francine the Financial Controller needs to be able to see all the approved expense reports, alright?"
So, you might have to go back and update your code to look something like this:
import { prisma } from '$lib/db';
import type { PageServerLoad } from './$types';
export const load = (async ({ locals, params }) => {
let expenseReports;
if (locals.user.roles.includes('manager')) {
expenseReports = prisma.expenseReport.findMany({
where: {
author: {
id: { in: locals.user.team.members.map((m) => m.id) },
},
submitted: true,
},
});
} else if (locals.user.roles.includes('director')) {
expenseReports = prisma.expenseReport.findMany({
where: {
author: {
id: {
in: locals.user.teams.flatMap((t) => t.members.map((m) => m.id)),
},
},
submitted: true,
},
});
} else if (locals.user.roles.includes('fcontroller')) {
expenseReports = prisma.expenseReport.findMany({
where: { submitted: true },
});
} else {
expenseReports = prisma.expenseReport.findMany({
where: { authorId: locals.user.id },
});
}
return {
expenseReports,
};
}) satisfies PageServerLoad;
...but with Cerbos, the source code stays exactly the same...
/* NO CHANGES TO THIS FILE */
import { queryPlanToPrisma, PlanKind } from "@cerbos/orm-prisma";
import { prisma, FieldMappers } from '$lib/db';
import type { PageServerLoad } from './$types';
export const load = (async ({ locals, params }) => {
const queryPlan = await cerbos.planResources({
principal: locals.user,
resource: {
kind: "expenseReport",
},
action: "read",
});
const queryPlanResult = queryPlanToPrisma({
queryPlan,
// map or function to change field names to match the prisma model
fieldNameMapper: FieldMappers.expenseReport,
});
if (queryPlanResult.kind === PlanKind.ALWAYS_DENIED) {
return {
expenseReports: [];
};
} else {
// Pass the filters in as where conditions
return {
expenseReports: prisma.expenseReports.findMany({
where: queryPlanResult.filters
});
};
}
}) satisfies PageServerLoad;
...and just the policy is updated:
apiVersion: api.cerbos.dev/v1
resourcePolicy:
version: default
resource: expenseReport
rules:
- actions: ['read']
effect: EFFECT_ALLOW
roles:
- '*'
condition:
match:
expr: request.resource.attr.author.id == request.principal.id
- actions: ['read', 'approve']
effect: EFFECT_ALLOW
roles:
- manager
condition:
match:
all:
of:
- expr: request.resource.attr.submitted == true
- expr: request.resource.attr.author.id in request.principal.attr.team.members
- actions: ['read', 'approve']
effect: EFFECT_ALLOW
roles:
- director
condition:
match:
all:
of:
- expr: request.resource.attr.submitted == true
- expr: request.resource.attr.author.team in request.principal.attr.teams
- actions: ['read']
effect: EFFECT_ALLOW
roles:
- fcontroller
condition:
match:
expr: request.resource.attr.submitted == true
So as a thought exercise, expand this kind of code to include all sorts of roles, all across the company, and all sorts of different resources, and maybe you can imagine how all this logic will just be ...spread out, in different load functions, across the application code. Hungrily eating the simplicity of the code and producing vast quantities of complexity and waste.
SvelteKit enables the building of web apps that feature a small footprint and high performance. SvelteKit is a framework used for creating web applications using Svelte. Cerbos enables developers to simplify access control policies for SvelteKit applications and provides an array of benefits related to security, access policy management, auditing and more. Here is an overview of these benefits:
Fine-grained access control: Cerbos allows you to create fine-grain access control policies that specify which users have access to and can perform specific actions on important resources. This type of easy-to-implement yet ironclad access control is particularly important in SvelteKit applications that require a wide variety of user roles and permissions.
Declarative policy language: Cerbos’ declarative policy language makes it easier to define and implement complex access control rules. The human-readable format prioritizes clarity and comprehension and enables non-programmer stakeholders to gain a firm grasp of who is being allowed to access what.
Detailed audit trails: Using Cerbos for SvelteKit authorization provides the added benefit of producing detailed audit logs of everyone who accessed or attempted to access the Svelte application. Audit trails enable easy compliance while identifying potential security threats.
Scalability: Access control becomes a more complicated matter as the number of users and the variety of functions grows. With its centralized access policy control authorization rules can be quickly and effectively updated application-wide in accordance with your changing business needs.
Enhanced security: Cerbos access control system enhances the security of your SvelteKit application through the aforementioned fine-grained access control. You’ll be able to exercise total control over who accesses which resources and what actions they can take, while detailed audit logs will enable more effective security audits.
Transparency and support: Cerbos is an open-source access control system with a large and knowledgeable community to lean on. Being open source also means that there are no secrets when it comes to the functioning of the SvelteKit authorization provided by Cerbos.
Faster development: When you choose to leverage Cerbos to provide SvelteKit authorization you completely bypass the need to develop your own access control mechanism. The result is shorter development time and more time for developers to focus on perfecting functionality.
There is so much more to Cerbos, and I'm just scratching the surface here.
If you want to check out a working example of using SvelteKit and Cerbos, where it demonstrates how to use route guards to protect pages, and how to protect API endpoints, you can check it out here.
I'm excited about this project, and I hope you are too.
If I've piqued your interest, you can find out more about Cerbos on the Cerbos website, or you can check out the Cerbos GitHub Org.
Book a free Policy Workshop to discuss your requirements and get your first policy written by the Cerbos team
Join thousands of developers | Features and updates | 1x per month | No spam, just goodies.