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:

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 generate

This 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

Data shapes

Each field in a data shape accepts these value types:

ValueMeaning
trueclient may provide this value; @zod chains apply automatically
literal valueserver forces this value; client cannot override it
force(value)server forces this value; required when the literal is true
(base) => schemaclient 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 false

Where 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:

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 } },
})

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

What it does not cover

Important Nested writes bypass the scope backstop. Prisma extension hooks fire on top-level operations only. For relation writes on scoped models, enforce tenant boundaries via database constraints (RLS, FKs, triggers) or application-level checks — or constrain what you expose through shapes.

findUnique behavior

Scoped findUnique cannot receive a where filter in Prisma extension mode. Two modes:

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)

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' },
  })
Security Nested writes bypass the scope backstop entirely: no FK injection on nested creates, no tenant filtering on nested updates/deletes/connects. Unconstrained destructive nested writes are rejected as unsafe boundary configuration. For scoped models prefer connect/disconnect over create/delete, validate ownership in application code, or rely on database constraints.

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"
}
Recommended production Set 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

ErrorstatuscodeRaised for
ShapeError400SHAPE_INVALIDinvalid shape config, body violations, validation failures
CallerError400CALLER_UNKNOWNmissing, unknown, or ambiguous caller
PolicyError403POLICY_DENIEDdenied scope, missing tenant context, invalid context
ZodErrorlower-level parse APIs (unless wrapZodErrors: true)

All guard errors carry status and code for direct HTTP mapping. Prisma errors propagate unmodified.

Security model

LayerPurpose
Input boundaryprevents invalid input
Query boundaryrestricts allowed query shapes
Tenant filters in shapesprimary tenant enforcement — top level and nested to-many includes
Scope extensionautomatic backstop for top-level operations

Limitations