Prisma adapter :: Cerbos Authorization Management Platform // Documentation
Prisma adapter
| This documentation is for a previous version of Cerbos. Choose 0.53.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
Installation
npm install @cerbos/orm-prisma
Supported operators
Scalar operators
and, or, not, eq, ne, lt, gt, lte, gte, in, startsWith, endsWith, contains, isSet
Relation operators
is, isNot, some, none, every, exists, exists_one, all, filter, except, hasIntersection
Advanced features
- Deep nested relation support
- Automatic field inference from attribute paths
- Collection mapping and filtering
- Lambda expression handling
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" },
},
},
},
},
},
},
},
});
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
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
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 });
});
Running the example
Start the Cerbos PDP as described above, then start the Express server:
npx ts-node src/index.ts
Test the endpoints:
curl -i http://localhost:3000/contacts/1
curl -i http://localhost:3000/contacts