Authorization in your SvelteKit app

Published by Adam Barrett on January 12, 2023
image

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.

Authorization

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.

Cerbos

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:

  • the principle (generally the user but perhaps a server, microservice, etc..) trying to take the action
  • a "description" of the resource(s) being acted on
  • the action(s) the principle wants to perform on the resource(s)

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.

SvelteKit + Cerbos

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:

  • lets the users view, create, update, and approve/deny expense reports
  • lets the user submit the expense reports for approval

With these authorization specs:

  • an employee should be able to view, create, and edit their own expense reports, but not approve them
  • an employee should be able to submit their own expense reports for approval
  • an employee should not be able to edit their expense reports after they have been approved
  • a manager should be able to view, edit and approve submitted expense reports for all their direct reports

Approving the Expense Reports

So, 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 called Common 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 like JSON Schema or JSON-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?

  1. you have to imagine, every little change, every new rule, is going to get hardcoded like this, across many files and mamy resources. Every time there a policy change, you have to go through and update all the code that checks permissions. With these small changes it seems not so bad, but this is going to spread across your codebase, and it's going to affect maintainablity. With Cerbos the source code stays consistent across all resources, and all actions and only the policies change

  2. the change goes in one place, which specifically only deals with who is allowed to do what, and not in the code that actually does the thing. This makes it much easier to reason about, and much easier to test

  3. maybe you don't even need a developer to make this change. Not only is it very readable for non-dev, there is even an API to make policy updates, so you could create an internal app specifically for updating permissions (here's a demo of something exactly like that).
    There is even a cool playground that they can go to, load up there policies and some example users and resources, and test out what the result of the changes they want make would do before making those changes.

  4. it's easy to see this change in the history of the policy file, and you can roll back if you need to. You also have a built in audit log, so you can always tell who had permission to do what, and when at any point in time.

  5. By centralizing and externalizing AuthZ concerns, every front-end or micro-service or long running task, will all be using the same permissions policies consistently and you don't have to update the individual services when the policy changes.

Loading the Expense Reports

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:

  • an employee should be able to see all their own expense reports, but no one elses
  • a manager should be able to view all the submitted expense reports for all their direct reports
  • a director should be able to view all the submitted expense reports employees in teams that are assigned to them

So 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 function could 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 and director 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.

Conclusion

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