Tutorial: Using Cerbos with Prisma
This documentation is for a previous version of Cerbos. Choose 0.40.0 from the version picker at the top right or navigate to https://docs.cerbos.dev for the latest version. |
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.39.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.39.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.