Prisma adapter
| This documentation is for an as-yet unreleased version of Cerbos. Choose 0.50.0 from the version picker at the top right or navigate to https://docs.cerbos.dev for the latest version. |
The @cerbos/orm-prisma package converts a Cerbos PlanResources response into a Prisma where clause. The resulting filter object can be passed directly to findMany, count, or any Prisma method that accepts a where argument.
Requirements
-
Cerbos >= v0.40
-
@cerbos/httpor@cerbos/grpcclient -
Prisma >= 6.0 (v7 supported)
-
Node.js >= 20.0.0
Supported operators
Usage
import { queryPlanToPrisma, PlanKind } from "@cerbos/orm-prisma";
const queryPlan = await cerbos.planResources({
principal: { id: "user1", roles: ["USER"] },
resource: { kind: "contact" },
action: "read",
});
const result = queryPlanToPrisma({
queryPlan,
mapper: {
"request.resource.attr.ownerId": { field: "ownerId" },
"request.resource.attr.status": { field: "status" },
},
});
switch (result.kind) {
case PlanKind.ALWAYS_DENIED:
return [];
case PlanKind.ALWAYS_ALLOWED:
return await prisma.contact.findMany();
case PlanKind.CONDITIONAL:
return await prisma.contact.findMany({
where: result.filters,
});
}
Field mapper
The mapper translates Cerbos attribute references to Prisma field names. It accepts either an object or a function.
// Object mapper
const result = queryPlanToPrisma({
queryPlan,
mapper: {
"request.resource.attr.status": { field: "status" },
},
});
// Function mapper
const result = queryPlanToPrisma({
queryPlan,
mapper: (attr) => ({
field: attr.replace("request.resource.attr.", ""),
}),
});
Relation mapping
Relations are declared with their type and optional field configuration. Fields not explicitly mapped are inferred from the attribute path.
const result = queryPlanToPrisma({
queryPlan,
mapper: {
"request.resource.attr.owner": {
relation: {
name: "owner",
type: "one",
},
},
"request.resource.attr.tags": {
relation: {
name: "tags",
type: "many",
field: "name",
},
},
},
});
Nested relations
Relations can be nested to arbitrary depth:
const result = queryPlanToPrisma({
queryPlan,
mapper: {
"request.resource.attr.categories": {
relation: {
name: "categories",
type: "many",
fields: {
subCategories: {
relation: {
name: "subCategories",
type: "many",
fields: {
name: { field: "name" },
},
},
},
},
},
},
},
});
The adapter generates the nested NOT, some, every, and none structures that Prisma requires for the full relation chain.
Lambda expressions
Collection operators such as exists and all produce lambda expressions in the query plan. The adapter translates these into the corresponding Prisma relation filters:
const result = queryPlanToPrisma({
queryPlan,
mapper: {
"request.resource.attr.comments": {
relation: {
name: "comments",
type: "many",
fields: {
author: {
relation: {
name: "author",
type: "one",
},
},
status: { field: "status" },
},
},
},
},
});
in operator normalization
The adapter normalizes in expressions to match Prisma conventions:
-
Single values become equality comparisons:
{ field: "value" } -
Arrays remain
{ field: { in: […] } } -
Relation-backed fields retain their relation structure while applying the appropriate operator at the leaf
Types
import { PlanKind, QueryPlanToPrismaResult } from "@cerbos/orm-prisma";
type QueryPlanToPrismaResult =
| { kind: PlanKind.ALWAYS_ALLOWED | PlanKind.ALWAYS_DENIED }
| { kind: PlanKind.CONDITIONAL; filters: Record<string, any> };
type MapperConfig = {
field?: string;
relation?: {
name: string;
type: "one" | "many";
field?: string;
fields?: { [key: string]: MapperConfig };
};
};
type Mapper =
| { [key: string]: MapperConfig }
| ((key: string) => MapperConfig);
Tutorial: Express + Prisma + Cerbos
This walkthrough builds a CRM API using Express, Prisma, and Cerbos. The business rules:
-
Admins can perform 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 full source code is available at github.com/cerbos/express-prisma-cerbos.
Prisma schema
mkdir express-prisma-cerbos
cd express-prisma-cerbos
npm i express @cerbos/grpc @prisma/client @cerbos/orm-prisma &&
npm i --save-dev @types/express ts-node
Create prisma/schema.prisma:
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)
}
Initialize the database:
npx prisma migrate dev --name init
Cerbos policy
Create cerbos/policies/contacts.yaml:
apiVersion: api.cerbos.dev/v1
resourcePolicy:
version: default
resource: contact
rules:
- actions: ["*"]
effect: EFFECT_ALLOW
roles:
- admin
- actions: ["read", "create"]
effect: EFFECT_ALLOW
roles:
- user
condition:
match:
expr: request.principal.attr.department == "Sales"
- actions: ["update", "delete"]
effect: EFFECT_ALLOW
roles:
- user
condition:
match:
expr: request.resource.attr.ownerId == request.principal.id
Start the Cerbos PDP:
docker run -i -t -p 3592:3592 \
-v $(pwd)/cerbos/policies:/policies \
ghcr.io/cerbos/cerbos:0.51.0-prerelease \
server
Authorizing individual resources
Use checkResource to authorize access to a single contact:
import { PrismaClient } from "@prisma/client";
import express from "express";
import { GRPC as Cerbos } from "@cerbos/grpc";
const prisma = new PrismaClient();
const cerbos = new Cerbos("localhost:3592", { tls: false });
const user = {
id: "1",
role: "user",
department: "Sales",
};
app.get("/contacts/:id", async ({ params }, res) => {
const contact = await prisma.contact.findUnique({
where: { id: params.id },
});
if (!contact) return res.status(404).json({ error: "Contact not found" });
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"],
});
if (decision.isAllowed("read")) {
return res.json(contact);
} else {
return res.status(403).json({ error: "Unauthorized" });
}
});
Filtering with the query plan adapter
Use planResources with the Prisma adapter to retrieve only the contacts the principal is authorized to access:
import { queryPlanToPrisma, PlanKind } from "@cerbos/orm-prisma";
app.get("/contacts", async (req, res) => {
const contactQueryPlan = await cerbos.planResources({
principal: {
id: `${user.id}`,
roles: [user.role],
attributes: { department: user.department },
},
resource: { kind: "contact" },
action: "read",
});
const queryPlanResult = queryPlanToPrisma({
queryPlan: contactQueryPlan,
mapper: {
"request.resource.attr.ownerId": { field: "ownerId" },
"request.resource.attr.department": { field: "department" },
"request.resource.attr.active": { field: "active" },
"request.resource.attr.marketingOptIn": { field: "marketingOptIn" },
},
});
let contacts;
if (queryPlanResult.kind === PlanKind.ALWAYS_DENIED) {
contacts = [];
} else if (queryPlanResult.kind === PlanKind.ALWAYS_ALLOWED) {
contacts = await prisma.contact.findMany({
select: {
firstName: true,
lastName: true,
active: true,
marketingOptIn: true,
},
});
} else if (queryPlanResult.kind === PlanKind.CONDITIONAL) {
contacts = await prisma.contact.findMany({
where: {
AND: [{ ...queryPlanResult.filters }],
},
select: {
firstName: true,
lastName: true,
active: true,
marketingOptIn: true,
},
});
}
return res.json({ contacts });
});