Documentation
Schema-driven security layer for Prisma. Input validation, query shape enforcement, and tenant isolation — generated from your schema, enforced in one chain.
prisma-guard prevents three classes of backend mistakes:
- invalid input reaching Prisma
- unsafe or overly broad query shapes — client-controlled include chains that traverse into sensitive fields
- missing tenant filters in multi-tenant systems
It is focused on data boundaries, not RBAC. Role-based access control is intentionally out of scope.
Quick start
1. Add the generator
generator guard {
provider = "prisma-guard"
output = "generated/guard"
}2. Generate
npx prisma generateThis emits a ready-to-use client.ts with all type mappings and a pre-wired guard instance.
3. Set up the Prisma client
import { AsyncLocalStorage } from 'node:async_hooks'
import { PrismaClient } from '@prisma/client'
import { guard } from './generated/guard/client'
const store = new AsyncLocalStorage<{ tenantId: string }>()
const prisma = new PrismaClient().$extends(
guard.extension(() => ({
Tenant: store.getStore()?.tenantId,
}))
)The extension is the automatic scope backstop: it injects scope foreign keys on creates, rejects unsafe scoped findUnique, and fails closed when tenant context is missing. Keep it enabled even when you declare tenant filters in shapes.
4. Provide request context
await store.run({ tenantId }, async () => {
await handler()
})5. Enforce tenants in the shape (multi-tenant)
import { force } from 'prisma-guard'
await prisma.project
.guard((ctx) => ({
where: { tenantId: { equals: force(ctx.Tenant) } },
select: { id: true, title: true },
}))
.findMany(req.body)Tenant filters written in the shape apply at the top level and inside every nested to-many include the shape exposes. They are visible in code review, greppable, and cannot be widened by client input.
6. Use it
// validated create — only title passes, @zod chains apply
await prisma.project
.guard({ data: { title: true } })
.create({ data: req.body })
// restricted read — only title filter, max 100 rows
await prisma.project
.guard({
where: { title: { contains: true } },
orderBy: { title: true },
take: { max: 50, default: 20 },
})
.findMany(req.body)Installation notes
npm install prisma-guard zod @prisma/client- Peer dependencies:
zod ^4,@prisma/client ^6 || ^7 - Both must be installed before
prisma generate— the generator validates@zoddirectives against real Zod schemas - Generated output is TypeScript; a TS-capable build pipeline is required
importStyle = "auto"reads your nearest tsconfig/package.json to pick extensionless,.js, or.tsimports — set it explicitly if auto-detection does not match your build
Data shapes
Each field in a data shape accepts these value types:
| Value | Meaning |
|---|---|
true | client may provide this value; @zod chains apply automatically |
| literal value | server forces this value; client cannot override it |
force(value) | server forces this value; required when the literal is true |
(base) => schema | client may provide; function refines the base Zod type (replaces @zod chains) |
unsupported() | explicitly omit an Unsupported(...) Prisma field from client input |
await prisma.project
.guard({
data: {
title: (base) => base.min(1, 'Title required').max(200),
status: true,
createdBy: currentUserId,
isActive: force(true),
},
})
.create({ data: req.body })For creates, guard validates that every required field without defaults is accounted for — client-allowed, forced, scope FK, or carrying a @zod .default(...) / .catch(...) directive. Missing fields throw ShapeError at shape evaluation time.
In update mode all data fields are optional. Bulk mutations (updateMany, deleteMany) require a where shape and reject empty resolved wheres — unconstrained bulk writes cannot happen by accident.
The force() helper
true always means client-controlled. When you need to force a boolean to literally true, wrap it:
data: { isActive: true } // client-controlled
data: { isActive: false } // forced to false
data: { isActive: force(true) } // forced to true
where: { published: { equals: true }, // client-controlled
isDeleted: { equals: false } } // forced to falseWhere DSL
A Prisma-compatible subset. All standard scalar operators are supported: equals, not, contains, startsWith, endsWith, in, notIn, gt, gte, lt, lte, search — plus mode for case-insensitive string filtering.
where: {
// client controls the filter and the mode
title: { contains: true, mode: true },
// server forces case-insensitive matching
slug: { contains: true, mode: force('insensitive') },
// forced condition — always enforced
status: { equals: 'published' },
}Forced conditions merge two ways: inline into the operator object the client provided (required for mode), or as a separate AND branch. Conflicting forced values on the same field and operator throw ShapeError at construction time — ambiguous security configurations never silently degrade.
Logical combinators
AND, OR, NOT are supported with strict rules:
- client arrays must contain at least one element —
{ OR: [] }is rejected - each member must carry at least one defined condition —
{ AND: [{}] }is rejected - empty combinator definitions in shapes throw at construction time
- forced values inside combinators are lifted to top-level AND constraints — they restrict, never broaden
Relation filters
To-many relations support some, every, none; to-one supports is, isNot (including is: null existence checks). Everything works recursively: operators, forced values, combinators, nested relations.
await prisma.user
.guard({
where: {
posts: {
some: {
title: { contains: true },
status: { equals: 'published' },
},
},
},
})
.findMany(req.body)take · orderBy · cursor
guard({
take: { max: 100, default: 25 }, // or shorthand: take: 50
orderBy: { createdAt: true, title: true },
skip: true,
cursor: { tenantId_slug: { tenantId: true, slug: true } },
})takeis restricted to positive integers (no negative reverse pagination)cursorfields must cover a unique constraint — enforced at shape construction- config flags like
skip/orderByentries must be exactlytrue— anything else throws, so misconfiguration is never silently enabled
Tenant isolation — two layers
Layer 1 — primary: tenant filters in the shape. Declared with a context-dependent shape. This is the recommended mechanism for multi-tenant systems:
import { force } from 'prisma-guard'
await prisma.project
.guard((ctx) => ({
where: { tenantId: { equals: force(ctx.Tenant) } },
include: {
tasks: {
where: { tenantId: { equals: force(ctx.Tenant) } },
select: { id: true, title: true },
},
},
}))
.findMany(req.body)Filters written in the shape apply top-level and inside nested to-many includes. Visible in review, greppable, impossible for the client to widen.
Layer 2 — backstop: automatic scope injection. Independently of shapes, the extension injects tenant predicates into top-level operations on models marked with @scope-root ancestry:
What the backstop covers
- all top-level reads, aggregates, groupBy
- all top-level creates — scope FK injected into data
- unique mutations — scope condition merged into where
- bulk mutations — scope merged into where, FK stripped from data
- upsert — where merge + create injection + update strip
What it does not cover
- scope root model delegates themselves — mark them, but protect their routes explicitly
- nested reads via include/select — constrain them with forced where in the shape (to-many only)
- nested relation writes — see below
$queryRaw/$executeRaw— raw SQL bypasses everything
findUnique behavior
Scoped findUnique cannot receive a where filter in Prisma extension mode. Two modes:
findUniqueMode = "reject"(default, recommended) — operation rejected, usefindFirstso scope applies at query time"verify"— runs then verifies result against scope; weaker, post-read, TOCTOU window
Multi-root scope
A model can be scoped by multiple roots (e.g. Tenant + Organization). Reads combine both conditions with AND; missing-context policy follows onMissingScopeContext. On writes all roots must be present — missing roots always throw PolicyError.
Named shapes and caller routing
Different consumers need different boundaries for the same model. Route them:
await prisma.project
.guard({
'/admin/projects': {
where: { title: { contains: true }, status: { equals: true } },
take: { max: 100 },
},
'/public/projects': {
where: { title: { contains: true } },
take: { max: 20, default: 10 },
},
default: { where: { title: { contains: true } } },
}, req.headers['x-caller'])
.findMany(req.body)- parameterized patterns:
/org/:orgId/users— exact match wins over patterns; ambiguous matches throw - caller resolution order: explicit argument →
contextFn().caller→ none - fail-closed: unmatched caller uses
defaultor throwsCallerError; acallerfield inside the request body is rejected
Context-dependent shapes
Shapes can be functions receiving the guard context — same context used for scoping and caller routing:
await prisma.project
.guard((ctx) => ({
where: { tenantId: { equals: ctx.Tenant } },
take: ctx.role === 'admin' ? { max: 100 } : { max: 20 },
}))
.findMany(req.body)The context function must return a plain object — invalid returns throw PolicyError. A key matching a scope root name with a non-primitive value also throws immediately: context bugs cannot silently weaken scope.
Relation writes
Data shapes support relation fields with per-operation configs. All 11 Prisma nested write operations are supported:
await prisma.post
.guard({
data: {
title: true,
tags: {
connect: { id: true },
disconnect: { id: true },
},
},
where: { id: true },
})
.update({
data: {
title: 'Updated',
tags: { connect: [{ id: 'tag1' }] },
},
where: { id: 'post1' },
})Projections
Reads: a shape's select/include is both whitelist and default — if the client omits projection, the shape's is synthesized and passed to Prisma. Client-provided projections can narrow inside the whitelist, never widen.
await prisma.company
.guard({
where: { id: { equals: true } },
select: {
id: true, name: true,
posts: {
select: { id: true, title: true },
take: { max: 10, default: 5 },
where: { isDeleted: { equals: false } },
},
},
})
.findFirst({ where: { id: { equals: 'abc' } } })Mutations: projection shapes validate client-requested projections; enable enforceProjection to also apply them when the client omits them. Batch methods return BatchPayload and reject projections.
resolve()
Read-planning helper: resolves the same shape boundary without executing a query. Returns the concrete shape, normalized body, effective read body (with synthesized projection), matched caller key, and whether the matched shape was dynamic. Rejects write keys. Used by generated routers and adapters that need the same resolution as real guarded methods.
Output shaping
const userOutput = guard.model('User', {
pick: ['id', 'email', 'name'],
include: { profile: { pick: ['bio'] } },
strict: true,
})Validates returned data using base Prisma types (input @zod rules are not re-applied). Non-strict by default (unknown fields stripped); strict: true rejects unknowns. Include depth defaults to 5.
Generator configuration
generator guard {
provider = "prisma-guard"
output = "generated/guard"
onInvalidZod = "error" // error | warn
onAmbiguousScope = "error" // error | warn | ignore
onMissingScopeContext = "error" // error | warn | ignore
findUniqueMode = "reject" // reject | verify
onScopeRelationWrite = "error" // error | warn | strip
strictDecimal = "false" // true | false
enforceProjection = "false" // true | false
typedGuardShapes = "true" // true | false
typedGuardRelationDepth = "1" // 0 | 1 | 2 | 3
importStyle = "auto" // auto | none | js | ts
runtimeImportPath = "prisma-guard"
}onAmbiguousScope, onMissingScopeContext, onScopeRelationWrite to "error", findUniqueMode to "reject", and consider strictDecimal = "true" and enforceProjection = "true".
@zod directives
Field-level validation declared in the Prisma schema, validated at generate-time (syntax, method allowlist, argument arity, actual Zod schema construction):
model User {
id String @id @default(cuid())
/// @zod .email().max(255)
email String
}Supported method families: string validations (email, url, uuid, regex, datetime, …), number validations (int, positive, gte, …), array validations (min, max, nonempty), modifiers (optional, nullable, default, catch). Chain order matters: type-changing modifiers must come last. Inline refine functions replace @zod chains entirely — refine is a full override.
Error handling
| Error | status | code | Raised for |
|---|---|---|---|
ShapeError | 400 | SHAPE_INVALID | invalid shape config, body violations, validation failures |
CallerError | 400 | CALLER_UNKNOWN | missing, unknown, or ambiguous caller |
PolicyError | 403 | POLICY_DENIED | denied scope, missing tenant context, invalid context |
ZodError | — | — | lower-level parse APIs (unless wrapZodErrors: true) |
All guard errors carry status and code for direct HTTP mapping. Prisma errors propagate unmodified.
Security model
| Layer | Purpose |
|---|---|
| Input boundary | prevents invalid input |
| Query boundary | restricts allowed query shapes |
| Tenant filters in shapes | primary tenant enforcement — top level and nested to-many includes |
| Scope extension | automatic backstop for top-level operations |
Limitations
$queryRaw/$executeRawbypass all guard protections- scope root models are not self-scoped — protect their routes explicitly
- nested writes are not scope-intercepted — use DB constraints or app-level checks
- nested reads via include need explicit forced where (to-many relations only; Prisma does not support where on to-one includes)
- scoped findUnique is rejected by default (use findFirst)
- @zod on list fields applies to the array, not elements
- Decimal accepts numbers by default; enable strictDecimal for money paths