Tutorial: Using Cerbos with Prisma

Prisma has come onto the Node/Typescript scene recently as a new generation of ORM. With it’s strongly-typed client, schema abstraction and great documentation, it is turning into the natural choice for modern applications.

This article covers setting up a basic CRM web application using Prisma for data storage and Cerbos for authorization to create, read, update and delete contacts based on who the user is. Our business requirements for who can do what are as follows:

  • Admins can do all actions

  • Users in the Sales department can read and create contacts

  • Only the user who created the contact can update and delete it

The last point is an important one as the authorization decision requires context of what is being accessed to make the decision if an action can be performed.

Note that whilst authentication is out of scope of this article, Cerbos is compatible with any authentication system - be it basic auth, JWT or a service like Auth0.

You can find the GitHub repo for this tutorial here.

Setting up Prisma

To get started, we need to install our various dependencies. Copy and run the following:

mkdir express-prisma-cerbos
cd express-prisma-cerbos

cat << EOF > package.json
{
  "prisma": {
      "seed": "ts-node prisma/seed.ts"
  }
}
EOF

npm i express @cerbos/grpc @prisma/client &&
npm i --save-dev @types/express ts-node

For this simplified tutorial, we will use a simple Prisma model to represent a CRM contact. We’ll also opt to use a SQLite database, but this can be swapped out to your DB of choice. You can find the Prisma documentation here for more details.

Create a prisma folder and add the basic Prisma schema to prisma/schema.prisma, by copying and running the following:

mkdir prisma && cat << EOF > prisma/schema.prisma
// This is your Prisma schema file,
// learn more about it in the docs: https://pris.ly/d/prisma-schema

datasource db {
  provider = "sqlite"
  url      = "file:./dev.db"
}

generator client {
  provider = "prisma-client-js"
}

model Contact {
  id             String   @id @default(cuid())
  createdAt      DateTime @default(now())
  updatedAt      DateTime @updatedAt
  firstName      String
  lastName       String
  ownerId        String
  active         Boolean  @default(false)
  marketingOptIn Boolean  @default(false)
}
EOF

Next, we define the seed script which will be used to populate the database with the following contacts:

ID First Name Marketing Opt-In Active Owner ID

1

Nick

Yes

Yes

1

2

Simon

Yes

No

1

3

Mary

No

Yes

1

4

Christina

Yes

No

2

5

Aleks

Yes

Yes

2

Run the following to generate the script:

cat << EOF > prisma/seed.ts
import { PrismaClient } from "@prisma/client";

const prisma = new PrismaClient();

const contactData = [
  {
    id: "1",
    firstName: "Nick",
    lastName: "Smyth",
    marketingOptIn: true,
    active: true,
    ownerId: "1",
  },
  {
    id: "2",
    firstName: "Simon",
    lastName: "Jaff",
    marketingOptIn: true,
    active: false,
    ownerId: "1",
  },
  {
    id: "3",
    firstName: "Mary",
    lastName: "Jane",
    active: true,
    marketingOptIn: false,
    ownerId: "1",
  },
  {
    id: "4",
    firstName: "Christina",
    lastName: "Baker",
    marketingOptIn: true,
    active: false,
    ownerId: "2",
  },
  {
    id: "5",
    firstName: "Aleks",
    lastName: "Kozlov",
    marketingOptIn: true,
    active: true,
    ownerId: "2",
  }
];

async function main() {
  console.log("Start seeding ...");
  for (const c of contactData) {
    const contact = await prisma.contact.create({
      data: c,
    });
    console.log("Created contact with id: " + contact.id);
  }
  console.log("Seeding finished.");
}

main()
  .catch((e) => {
    console.error(e);
    process.exit(1);
  })
  .finally(async () => {
    await prisma.\$disconnect();
  });
EOF

Now, to initialize our DB, generate the Prisma client and seed the database, run the following:

npx prisma migrate dev --name init

Creating an access policy

We will be using a Docker container to run the Cerbos PDP instance, so ensure that you have Docker set up first!

The first step is to create a resource policy file. Our requirements, as a reminder, were:

  • Admins can do all actions

  • Users in the Sales department can read and create contacts

  • Only the user who created the contact can update and delete it

  • A resource policy file called ‘contacts.yaml’ should be created in the policies folder with the following:

Let’s create a cerbos directory (see repo) with a subdirectory; policies, and a file contacts.yaml inside there. To do this, run the following:

mkdir -p cerbos/policies && cat << EOF > cerbos/policies/contacts.yaml
---
apiVersion: api.cerbos.dev/v1
resourcePolicy:
  version: default
  resource: contact
  rules:
  # Admins can do all actions
  - actions: ["*"]
    effect: EFFECT_ALLOW
    roles:
      - admin

  # Users in the Sales department can read and create contacts
  - actions: ["read", "create"]
    effect: EFFECT_ALLOW
    roles:
      - user
    condition:
      match:
        expr: request.principal.attr.department == "Sales"

  # Only the user who created the contact can update and delete it
  - actions: ["update", "delete"]
    effect: EFFECT_ALLOW
    roles:
      - user
    condition:
      match:
        expr: request.resource.attr.ownerId == request.principal.id
EOF

Conditions are the powerful part of Cerbos which enables authorization decisions to be made at request time using context from both the principal (the user) and the resource they are trying to access. In this policy we are using conditions to check the department of the user for read and create actions, then again in the update and delete policy to check that the owner of the resource is the principal making the request.

As you are working on the policies, you can run the following to check that they are valid. If no errors are logged then you are good to go.

cd cerbos
docker run -i -t -p 3592:3592 \
  -v $(pwd)/policies:/policies \
  ghcr.io/cerbos/cerbos:0.40.0 \
  compile /policies

Now let’s fire up the Cerbos PDP. We provide an image to do this easily — simply run the following:

docker run -i -t -p 3592:3592 \
  -v $(pwd)/policies:/policies \
  ghcr.io/cerbos/cerbos:0.40.0 \
  server

If everything is correct, we should see the following output:

2022-12-07T16:43:40.626Z        INFO    cerbos.server   maxprocs: Leaving GOMAXPROCS=4: CPU quota undefined
2022-12-07T16:43:40.626Z        INFO    cerbos.server   Loading configuration from /conf.default.yaml
2022-12-07T16:43:40.630Z        INFO    cerbos.index    Found 1 executable policies
2022-12-07T16:43:40.631Z        INFO    cerbos.telemetry        Anonymous telemetry enabled. Disable via the config file or by setting the CERBOS_NO_TELEMETRY=1 environment variable
2022-12-07T16:43:40.631Z        INFO    cerbos.dir.watch        Watching directory for changes  {"dir": "/policies"}
2022-12-07T16:43:40.632Z        INFO    cerbos.http     Starting HTTP server at :3592
2022-12-07T16:43:40.632Z        INFO    cerbos.grpc     Starting gRPC server at :3593

Setting up the server

Having now set up both our Cerbos policy and our Prisma database, it is time to implement our web server. For this example we will be using Express to set up a simple server running on port 3000. We will also import our Prisma and Cerbos clients which we will use later on.

mkdir src
cat << EOF > src/index.ts
import { PrismaClient } from "@prisma/client";
import express, { Request, Response } from "express";
import { GRPC } from "@cerbos/grpc";

const prisma = new PrismaClient({ log: ["query", "info", "warn", "error"] });
const cerbos = new GRPC("localhost:3592", { tls: false }); // The Cerbos PDP instance

const app = express();

app.use(express.json());

const server = app.listen(3000, () =>
  console.log("🚀 Server ready at: http://localhost:3000")
);
EOF

Now we need to create our routes which we will authorize. For this simple example, we will create a GET for a contact resource.

Using the Prisma client, query for the contact which matches the ID of the URL parameter. If it is not found, return an error message. Add the following to src/index.ts:

// Implementing an authentication provider is out of scope of this article and you will more than likely already have one in place,
// So we build a static one here for indicative use
const user = {
  "id": "1", // user id
  "role": "user", // single role (user, admin)
  //"role": "admin",
  "department": "Sales" // department of the user
  //"department": "Marketing"
};

app.get("/contacts/:id", async ({ params }, res) => {
  // load the contact
  const contact = await prisma.contact.findUnique({
    where: {
      id: params.id,
    },
  });
  if (!contact) return res.status(404).json({ error: "Contact not found" });

  // TODO check authz and return a response
});

Authorizing requests

With our policy defined, we can call Cerbos from our request handler to authorize the principal to take the action on the resource.

To do this, we need to update our GET handler and replace the TODO with a call to Cerbos; passing in the details about the user and the attributes of the contact resource, as well as the action being made:

  // check user is authorized
  const decision = await cerbos.checkResource({
    principal: {
      id: `${user.id}`,
      roles: [user.role],
      attributes: {
        department: user.department,
      },
    },
    resource: {
      kind: "contact",
      id: contact.id + '',
      attributes: JSON.parse(JSON.stringify(contact)),
    },
    actions: ["read"],
  });

  // authorized for read action
  if (decision.isAllowed("read")) {
    return res.json(contact);
  } else {
    return res.status(403).json({ error: "Unauthorized" });
  }

In this case, we are only checking a single contact using the checkResource method. There is also a checkResources method available which supports batching resources into a single request (perhaps for use in a list endpoint). A checkResources call could be used like this:

  const decision = await cerbos.checkResources({
    principal: {
        id: `${user.id}`,
        roles: [user.role]
        attributes: {
          department: user.department,
        },
    },
    resources: [
      {
        resource: {
          kind: "contact",
          id: contact.id + '',
          attributes: JSON.parse(JSON.stringify(contact)),
        },
        actions: ["read"],
      },
      ...
    ],
  });

  decision.isAllowed({
    resource: { kind: "contact", id: ${user.id} },
    action: "read",
  }); // => true

Once we get the response back from Cerbos, calling the .isAllowed method for the required action (and optionally, the given resource ID in the checkResources case) will return a simple boolean of whether the user is authorized or not. Using this, we can either return the contact or throw an HTTP 403 Unauthorized response.

The query planner

If we provide Cerbos with a principal, a description of the resource they’re trying to access and the required action, we can ask it for a query plan.

Start by installing the following dependency:

npm i express @cerbos/orm-prisma

Then add the following to index.js:

import { queryPlanToPrisma, PlanKind } from "@cerbos/orm-prisma";

app.get("/contacts", async (req, res) => {
  // Fetch the query plan from Cerbos passing in the principal
  // resource type and action
  const contactQueryPlan = await cerbos.planResources({
    principal: {
      id: `${user.id}`,
      roles: [user.role],
      attributes: {
        department: user.department,
      },
    },
    resource: {
      kind: "contact",
    },
    action: "read",
  });

  // TODO convert query plan to a Prisma adapater instance
});

We can then use the Cerbos Prisma ORM adapter to convert this query plan response, like so:

  const queryPlanResult = queryPlanToPrisma({
    queryPlan: contactQueryPlan,
    // map or function to change field names to match the prisma model
    fieldNameMapper: {
      "request.resource.attr.ownerId": "ownerId",
      "request.resource.attr.department": "department",
      "request.resource.attr.active": "active",
      "request.resource.attr.marketingOptIn": "marketingOptIn",
    },
  });

  let contacts: any[];

  if (queryPlanResult.kind === PlanKind.ALWAYS_DENIED) {
    contacts = [];
  } else {
    // Pass the filters in as where conditions
    // If you have prexisting where conditions, you can pass them in an AND clause
    contacts = await prisma.contact.findMany({
      where: {
        AND: queryPlanResult.filters
      },
      select: {
        firstName: true,
        lastName: true,
        active: true,
        marketingOptIn: true,
      },
    });
  }

  return res.json({
    contacts,
  });

In the case that the result kind is not ALWAYS_DENIED, we retrieve the filters from the adapter instance, and use them to construct a query using the Prisma ORM.

Trying it out

Run the Cerbos PDP, as described above, and separately, fire up the node server as follows:

npx ts-node src/index.ts

Then, hit it with some requests:

curl -i http://localhost:3000/contacts/1
curl -i http://localhost:3000/contacts

Conclusion

Through this simple example, we have used Prisma as our ORM to create a REST API which is authorized using Cerbos for a simple CRM system. This can be built upon to add more complex requirements, for example:

  • Checking the IP address of the request to ensure it is within the corporate IP range

  • Check if the incoming change is within an acceptable boundary eg only allow 20% discounts on a product unless an admin

  • Ensure only certain actions are done during work-hours

You can find a sample repo of integrating Prisma and Cerbos in an Express server on GitHub, as well as many other example projects of implementing Cerbos.