Skip to main content

Prisma adapter

Persist policies, roles, assignments, and subject attributes through Prisma Client, with the four reference models and their real behaviour.

IamPrismaAdapter takes a Prisma Client and maps the adapter interface onto four models: AccessPolicy, AccessRole, AccessAssignment, AccessSubjectAttr. It has no dependency on @prisma/client — the constructor accepts anything structurally matching IamPrisma.ILike, which is just those four delegates with the handful of methods the adapter calls.

Install


npm i @gentleduck/iam @prisma/client

prisma is a dev dependency (bun add -D prisma).

import { IamPrismaAdapter, iamPrismaAdapter } from '@gentleduck/iam/adapters/prisma'
import type { IamPrisma } from '@gentleduck/iam/adapters/prisma'

Setup

Add the four models to your schema.prisma. A copy ships at node_modules/@gentleduck/iam/src/adapters/prisma/schema.prisma.

model AccessPolicy {
  id          String   @id
  name        String
  description String?
  version     Int      @default(1)
  algorithm   String   @default("deny-overrides")
  rules       Json
  targets     Json?
  createdBy   String?  @map("created_by")
  updatedBy   String?  @map("updated_by")
  createdAt   DateTime @default(now()) @map("created_at")
  updatedAt   DateTime @updatedAt @map("updated_at")

  @@map("iam_policies")
}

model AccessRole {
  id          String             @id
  name        String
  description String?
  permissions Json
  inherits    String[]           @default([])
  scope       String?
  metadata    Json?
  createdBy   String?            @map("created_by")
  updatedBy   String?            @map("updated_by")
  createdAt   DateTime           @default(now()) @map("created_at")
  updatedAt   DateTime           @updatedAt @map("updated_at")
  assignments AccessAssignment[]

  @@index([scope])
  @@map("iam_roles")
}

model AccessAssignment {
  id        String     @id @default(cuid())
  subjectId String     @map("subject_id")
  roleId    String     @map("role_id")
  scope     String?
  role      AccessRole @relation(fields: [roleId], references: [id], onDelete: Cascade)
  createdBy String?    @map("created_by")
  updatedBy String?    @map("updated_by")
  createdAt DateTime   @default(now()) @map("created_at")
  updatedAt DateTime   @updatedAt @map("updated_at")

  @@unique([subjectId, roleId, scope])
  @@index([subjectId])
  @@index([roleId])
  @@index([subjectId, scope])
  @@map("iam_assignments")
}

model AccessSubjectAttr {
  subjectId String   @id @map("subject_id")
  data      Json
  createdBy String?  @map("created_by")
  updatedBy String?  @map("updated_by")
  createdAt DateTime @default(now()) @map("created_at")
  updatedAt DateTime @updatedAt @map("updated_at")

  @@map("iam_subject_attrs")
}

Replace the unique index by hand. This step is not hygiene — skip it and assignRole accumulates duplicate unscoped grants under concurrency.

scope is nullable, SQL unique indexes treat NULLs as distinct, and Prisma can express neither NULLS NOT DISTINCT nor a coalesce index. So @@unique([subjectId, roleId, scope]) does not prevent two identical unscoped grants. assignRole is a read-then-write — the composite key includes a nullable column, so upsert cannot address the row — and a read cannot make a write atomic. The index is what actually decides the outcome: the e2e suite measures twenty concurrent identical unscoped grants leaving three rows under the plain index and exactly one under NULLS NOT DISTINCT.

-- Postgres 15+
DROP INDEX "iam_assignments_subject_id_role_id_scope_key";
CREATE UNIQUE INDEX "iam_assignments_subject_id_role_id_scope_key"
  ON "iam_assignments" ("subject_id", "role_id", "scope") NULLS NOT DISTINCT;

-- MySQL / SQLite
CREATE UNIQUE INDEX "iam_assignments_subject_id_role_id_scope_key"
  ON "iam_assignments" ("subject_id", "role_id", (coalesce("scope", '')));

Migrate and generate.

bunx prisma migrate dev --name add-iam-models
bunx prisma generate

Construct the adapter. The client is the only argument — there is no config object.

import { PrismaClient } from '@prisma/client'
import { IamEngine } from '@gentleduck/iam'
import { IamPrismaAdapter } from '@gentleduck/iam/adapters/prisma'

const prisma = new PrismaClient()
const adapter = new IamPrismaAdapter(prisma)
const engine = new IamEngine({ adapter })

iamPrismaAdapter(prisma) is the same thing for callers who prefer a factory to new.

Options

There is no options object. IamPrismaAdapter is generic over TAction, TResource, TRole, TScope (all defaulting to string) and TPrisma extends IamPrisma.ILike, and the constructor takes one positional argument.

ParameterTypeDefaultMeaning
prismaTPrisma extends IamPrisma.ILikerequiredAny object exposing the accessPolicy, accessRole, accessAssignment, and accessSubjectAttr delegates. A real PrismaClient satisfies it; so does a test double.

IamPrisma.ILike is the contract your client has to satisfy, and it is deliberately narrow:

export namespace IamPrisma {
  export interface ILike {
    accessPolicy: {
      findMany: (args?: unknown) => Promise<IPolicyRow[]>
      findUnique: (args: { where: { id: string } }) => Promise<IPolicyRow | null>
      upsert: (args: { where: { id: string }; create: Record<string, unknown>; update: Record<string, unknown> }) => Promise<IPolicyRow>
      deleteMany: (args: { where: { id: string } }) => Promise<{ count: number }>
    }
    accessRole: {
      /* findMany, findUnique, upsert, deleteMany over IRoleRow */
    }
    accessAssignment: {
      findMany: (args: { where: { subjectId: string; roleId?: string; scope?: string | null }; take?: number }) => Promise<IAssignmentRow[]>
      create: (args: { data: Record<string, unknown> }) => Promise<IAssignmentRow>
      deleteMany: (args: { where: Record<string, unknown> }) => Promise<{ count: number }>
      updateMany: (args: { where: Record<string, unknown>; data: Record<string, unknown> }) => Promise<{ count: number }>
    }
    accessSubjectAttr: {
      findUnique: (args: { where: { subjectId: string } }) => Promise<IAttrRow | null>
      upsert: (args: { where: { subjectId: string }; create: Record<string, unknown>; update: Record<string, unknown> }) => Promise<IAttrRow>
    }
  }
}

Row shapes are exported alongside it: IamPrisma.IPolicyRow, IRoleRow, IAssignmentRow, IAttrRow. Note that IAssignmentRow is only { subjectId, roleId, scope } — the adapter never reads id or createdAt.

Storage layout

Four models, one relation. AccessAssignment.roleId references AccessRole.id and cascades on delete; nothing else is related.

Loading diagram...

The models map to the same physical table names the Drizzle schemas use — iam_policies, iam_roles, iam_assignments, iam_subject_attrs — with snake-case column names via @map. The two adapters can therefore read the same database. The Prisma schema is a reduced mirror: Prisma cannot express CHECK constraints or partial indexes, so the blank-string and date-ordering checks the Drizzle schemas carry are absent and the adapter validates instead, and iam_assignments has no starts_at / expires_at / attributes. If you plan to share a database, generate the tables from the Drizzle schema — it is the superset — and point Prisma at them.

inherits is String[], a native array, which restricts this exact schema to Postgres and CockroachDB. On MySQL, SQLite, SQL Server, or MongoDB change it to Json — the adapter reads row.inherits ?? [] and writes r.inherits ?? [], so a JSON array round-trips identically.

@@unique([subjectId, roleId, scope]) is what makes an assignment addressable and what updateAssignmentScope has to work around — and, once you replace it in a migration, what makes assignRole idempotent under concurrency. @@index([subjectId]) serves every subject read, @@index([roleId]) backs the cascade and the role-wide revoke, and @@index([subjectId, scope]) backs the scoped-subject lookup.

Method mapping

Adapter methodPrisma call
listPolicies()accessPolicy.findMany(), each row through parsePolicyRow
getPolicy(id)accessPolicy.findUnique({ where: { id } })
savePolicy(p)accessPolicy.upsert({ where: { id }, create, update })
deletePolicy(id)accessPolicy.deleteMany({ where: { id } })
listRoles()accessRole.findMany(), each row through parseRoleRow
getRole(id)accessRole.findUnique({ where: { id } })
saveRole(r)accessRole.upsert({ where: { id }, create, update })
getSubjectRoles(id)accessAssignment.findMany({ where: { subjectId, scope: null } }), deduplicated
getSubjectScopedRoles(id)accessAssignment.findMany({ where: { subjectId } }), then filter(r => r.scope != null)
assignRole(id, role, scope?)findMany for an existing row, then create when there is none; a P2002 from a racing writer is treated as success
revokeRole(id, role, scope?)accessAssignment.deleteMany({ where: { subjectId, roleId, ...(scope !== undefined && { scope }) } })
updateAssignmentScope(…)findMany + deleteMany + updateMany see below
getSubjectAttributes(id)accessSubjectAttr.findUnique({ where: { subjectId } })
setSubjectAttributes(id, attrs)read, merge in JavaScript, accessSubjectAttr.upsert

deleteRole(id) is accessRole.deleteMany({ where: { id } }), and the relation's onDelete: Cascade removes that role's assignments with it. Both deletes use deleteMany rather than delete because Prisma's delete raises P2025 when nothing matches, so deleting a row that is already gone threw here and no-opped on the other five adapters.

Transactions and consistency

The adapter opens no transactions and never calls prisma.$transaction. It does implement withClient, so engine.withTransaction(tx) re-binds it to your interactive-transaction handle — a tx exposes the same delegates as the base client minus $transaction itself, which is why the structural typing pays off here. withClient returns a new adapter; the original keeps writing to its own client. Cache invalidations buffer in pending and fire on flush() after the commit, so a rolled-back grant never evicts another node's cache.

Otherwise every call is independent:

OperationStatementsAtomic?
savePolicy / saveRoleone upsertyes
assignRolefindMany, then create when absentidempotent; atomicity comes from the unique index, not the read
revokeRoleone deleteManyyes
deletePolicy / deleteRoleone deleteManyyes, cascade included
setSubjectAttributesfindUnique, merge in JS, upsertno read-modify-write
updateAssignmentScopefindMany, deleteMany, updateManyno three statements

Scope

scope is a nullable column. NULL is a global grant, a non-NULL string is a scoped one, and @@unique([subjectId, roleId, scope]) keeps one row per combination.

getSubjectRoles filters in SQL (where: { subjectId, scope: null }) and deduplicates; getSubjectScopedRoles fetches every row for the subject and filters scope != null in JavaScript, mapping each to { role, scope }. Per-grant attributes are not part of this schema, so the scoped roles this adapter returns never carry an attributes field.

revokeRole without a scope deletes every assignment of that role for the subject, scoped and unscoped; with a scope it deletes only that one row.

Moving an assignment between scopes

updateAssignmentScope(subjectId, roleId, fromScope, toScope, actor?) (since 5.5.0) moves a grant without losing its row identity — id and createdAt survive, which a delete-then-create would not. It has to work around the unique constraint, and the order of its three statements is load-bearing.

Loading diagram...

Other behaviours the tests pin: a global assignment moves by matching scope: null and a scoped one moves back to global the same way; other subjects and other roles of the same subject are untouched; and the moved grant is visible through getSubjectScopedRoles immediately afterwards. actor lands in updated_by; created_by and updated_by are written from the actor a caller supplies and stay NULL when none is named.

Row validation

listPolicies, getPolicy, listRoles, and getRole all run the row through parsePolicyRow / parseRoleRow from @gentleduck/iam/core/validate before returning it, so a policy whose rules no longer match the current shape never reaches the engine. This was a real gap until 3.1.0, when these four methods still returned bare casts.

What happens next is not symmetric, and the difference is the whole of the design.

A bad role row is skipped and named: listRoles drops it, returns the readable rows, and warns once per bad row; getRole returns null and warns. The warning carries the actual validation issues — [@gentleduck/iam:prisma] unreadable role row "broken": <issues from validateRole> — not just "it was bad", and a fully readable catalog produces no warning at all. This is a recent correction: the row used to be dropped with a bare null check and nothing written anywhere, so the same corrupt row named itself on file, redis, http and drizzle and vanished on Prisma, while Prisma's own policy path both threw and warned. Within one adapter the two halves of "this row is unreadable" disagreed.

What you saw without that warning: the role's permissions stop applying, every role inheriting it silently loses that branch, and — because resolveEffectiveRoles keeps a directly assigned id whether or not the catalog defines it — subjects still hold the role id while it grants nothing. A support ticket with no log line behind it.

A bad policy row is refused outright: the read throws and the engine denies until the row is repaired, because a dropped policy may be the one that says NO. getPolicy on a corrupt row throws rather than returning null, so a corrupt row cannot impersonate a deleted one.

Reporting is console.warn rather than onPolicyError for a structural reason: this adapter takes no options object, so there is nowhere to wire a handler without changing its constructor.

Subject attributes behave differently on purpose.

The returned object is rebuilt key by key into a fresh Record rather than handed back as the Prisma-managed value, so the engine cannot hold a reference into Prisma's result. setSubjectAttributes is the one path that recovers from corruption: it catches the failed read and merges onto {}, so an operator can always overwrite a broken row instead of being locked out of fixing it.

Gotchas

  • IReadOptions.signal is ignored. The parameter is accepted for interface compatibility, but Prisma's delegates take no signal, so the engine's adapterTimeoutMs releases the request while the query keeps running. Set a database statement timeout as the real backstop.
  • listPolicies and listRoles select the whole table. The engine refuses result sets over maxPolicies / maxRoles (both default 10_000); raise those rather than paginating inside the adapter.
  • deletePolicy and deleteRole are idempotent, because they use deleteMany. A delete would have raised P2025 on a row that is already gone.
  • An unstored roleId is refused, and the refusal is translated. The relation always made the database reject it, but Prisma leaked Foreign key constraint failed on the field: `roleId` straight from the driver where the other five raise the shared message. The error code P2003 is matched rather than the message text, because the code does not move with the server's locale.
  • Prisma hands back already-parsed JSON. A rules column that arrives as a string — the shape a TEXT column migrated in from another adapter produces — is refused, not parsed. '[]' read loosely looks like a policy with no rules, which under deny-overrides denies nothing.
  • Without the hand-written unique index, duplicate unscoped grants accumulate under concurrency. The reads still answer one role and the revoke still clears all of them, so the damage is table growth rather than a wrong decision — but the fix is a migration, not a code change.
  • Multi-database. The adapter itself is database-agnostic; only the inherits String[] line is Postgres-specific.

See also