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

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

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:

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:

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