Policies & Redaction
Available in: Professional, Business, Enterprise tiers
PII-safe logging and response redaction driven by annotations in your Prisma schema. Automatically mask sensitive data in logs, API responses, and error tracking.
Why Use Policies & Redaction
Problem: Sensitive data leaks everywhere:
- PII (emails, phone numbers, SSNs) exposed in application logs
- Passwords and tokens accidentally logged to monitoring services
- Sensitive fields returned in API responses to unauthorized users
- Manual redaction is error-prone and inconsistent
Solution: Annotate sensitive fields in your Prisma schema once, then automatically redact them everywhere.
Benefits
- Schema-Driven: Define policies once in Prisma schema
- Auto Redaction: Works with all logging libraries
- Compliance Ready: GDPR, HIPAA, PCI-DSS compliant logging
- Zero Leaks: Redact before data leaves your application
Prerequisites
# Core dependencies
pnpm add @prisma/client zod
# PZG Pro license required
Generate
Add to your schema.prisma:
generator pzgPro {
provider = "node ./node_modules/prisma-zod-generator/lib/cli/pzg-pro.js"
output = "./generated/pro"
enablePolicies = true
}
Then run:
prisma generate
Generated Files
generated/
pro/
policies/
safe-crud/
user.ts # User safe CRUD operations
post.ts # Post safe CRUD operations
redaction/
user.ts # User PII redaction middleware
post.ts # Post PII redaction middleware
dto/
user.ts # User DTO schemas
post.ts # Post DTO schemas
index.ts # Exports and factory functions
Per-model files are emitted only where the annotations call for them: safe-crud/ for models with
@policy rules, redaction/ for models with @pii fields. dto/ is generated for every model that
carries either.
Schema Annotations
Annotate fields in your Prisma schema:
/// @policy read:where role in ["admin"]
model User {
id Int @id @default(autoincrement())
/// @pii email redact:logs
email String @unique
/// @pii phone redact:logs
phone String?
/// @pii password redact:logs mask:full
password String
name String?
}
Two annotations are recognized:
@pii <kind> [redact:logs] [mask:partial|mask:full|mask:hash]— on a field. Marks it for redaction.email,phone, andssnget kind-aware partial masks; any other kind falls back to a generic partial mask.mask:partialis the default when nomask:option is given.@policy read:where <condition>— on a model. Drives thewhereclause of the generated safe-CRUD operations.
Conditions that are enforced
The generated combinePolicyCondition recognizes exactly three condition shapes. Both
read:role in [...] and read:where role in [...] work (in 2.4.1+; earlier versions dropped the
role keyword while parsing, leaving the policy inert):
| Condition | Effect on the query |
|---|---|
read:where userId == ctx.userId | adds where.userId = context.userId; if the context has no userId, narrows the query to match nothing |
read:where tenantId == ctx.tenantId | adds where.tenantId = context.tenantId; if the context has no tenantId, narrows the query to match nothing |
read:where role in ["admin", "owner"] | if the role is not listed, adds an impossible where.id so nothing matches |
A missing context value denies rather than widens. Prisma strips undefined from a where clause,
so in earlier versions a findMany() with no context — or one whose tenantId had not been
populated — dropped the filter and read every tenant's rows while the call site still read as scoped.
Any other condition text is refused: the generator warns at build time, naming the model and the
condition, and the generated code throws rather than running an unscoped query. Earlier versions
carried such a condition into the generated file and ignored it, leaving the query unfiltered.
read:fields and read:values parse but are not implemented, and are reported the same way.
@policy deny:<condition> refuses a create or update whose condition matches; the same three shapes
apply. (Before 2.4.1 the generated evaluator returned false unconditionally, so a deny rule never
denied anything.)
The role is read from the context you pass to the method, falling back to the one given to the constructor, so a wrapper built once per process can vary the caller per request. A caller whose role is not listed gets a query narrowed to match nothing.
Before 2.4.1 the role check only consulted the constructor's context, so a role supplied per call was
ignored; denial was also expressed as where.id = -1, which Prisma rejects outright on a
String @id model rather than returning no rows.
Anything else — @sensitive, for example — is ignored silently. A field you meant to protect with an
unrecognized annotation is emitted unredacted, so check that every sensitive field uses @pii.
Basic Usage
Safe CRUD Operations
import { createSafeUserOperations } from '@/generated/pro/policies'
import { PrismaClient } from '@prisma/client'
const prisma = new PrismaClient()
// Create safe CRUD with policy context
const userOps = createSafeUserOperations(prisma, {
userId: 'current-user-id',
role: 'user'
})
// Automatically applies read policies
const users = await userOps.findMany()
// Automatically injects userId/tenantId on create
const newUser = await userOps.create({}, {
data: { name: 'John', email: 'john@example.com' }
})
PII Redaction
Redactors are generated per model, and only for models that carry at least one @pii field:
import { UserRedactor } from '@/generated/pro/policies/redaction/user'
const redactor = new UserRedactor({ redactLogs: true })
// Redact sensitive fields before logging
const safeUser = redactor.redact(user, 'logs')
// Result: { email: 's***@example.com', phone: '********4567', ... }
redact() only acts on the logs and analytics contextsA field is redacted when it is annotated redact:logs and you pass 'logs' or 'analytics' as
the context. redactor.redact(user, 'api') returns the record unchanged, so do not rely on the
'api' context — or on the Express middleware below, which uses it — as your only barrier against
leaking PII in responses. Use the dto/ *PublicSchema (which omits PII fields outright) for
response shaping.
The same applies to error trackers — redact explicitly before you capture:
Sentry.captureException(err, {
extra: new UserRedactor({ redactLogs: true }).redact(user, 'logs'),
})
Integration Examples
Express API
Middleware factories are generated per model, so mount them per route rather than globally:
import express from 'express'
import { createUserRedactionMiddleware } from '@/generated/pro/policies/redaction/user'
import { UserPublicSchema } from '@/generated/pro/policies/dto/user'
const app = express()
app.get('/users/:id', createUserRedactionMiddleware({ redactLogs: true }), async (req, res) => {
const user = await prisma.user.findUnique({
where: { id: parseInt(req.params.id) }
})
// Strip PII fields from the response payload
res.json(UserPublicSchema.parse(user))
})
Nullable columns are emitted .nullable(), and Decimal columns coerce, so
parse() accepts a row straight from a Prisma query. Enum members are inlined as string literals, so
the module needs no import beyond zod.
Before 2.4.1 nullable columns were .optional() only (rejecting the null Prisma returns), Decimal
was z.number() (rejecting Prisma.Decimal), enum fields referenced an enum that was never imported
(ReferenceError on import), and the omit masks were hardcoded, so any model without
id/createdAt/updatedAt threw Unrecognized key on first use.
Koa and NestJS
Call the per-model redactor directly rather than looking for a framework adapter — none is generated:
import { UserRedactor } from '@/generated/pro/policies/redaction/user'
const redactor = new UserRedactor({ redactLogs: true })
// Koa: redact before send
app.use(async (ctx, next) => {
await next()
ctx.body = redactor.redact(ctx.body, 'logs')
})
new <Model>Redactor({ context: 'logs' }) sets the default context for redact()
calls that do not pass one — before 2.6.0 the config was stored and never read.
redactLogs is superseded: a field marked @pii is redacted in every context.
The enableRLS generator option is a no-op and is reported as such; row-level
security is the PostgreSQL RLS pack, enabled with
enablePostgresRLS.
redactPII() is not implementedThe policies index also exports redactPII(data, config?), which cannot work: redaction is per model
and it has no way to tell which model an arbitrary object came from. From 2.4.1+ it throws and
points at the per-model <Model>Redactor. Before that it returned its input unchanged, so a caller
received unredacted PII with no indication of it.
A field marked @pii is masked whether you call redact(row), redact(row, 'logs'), or let the
generated Express middleware do it. Earlier versions only masked for the logs and analytics
contexts while both the default call and the middleware used api, so the redactor returned its
input untouched and the middleware was a no-op.
Hashing and Browser Support
The generated redactors are dependency-free — plain string masking, no Node crypto import — so they
run in the browser as-is.
mask:hash is not cryptographichashValue() uses a hand-rolled 32-bit string hash. It is a display-level obfuscation only; do not
treat hashed output as anonymized or pseudonymized for compliance purposes.
See Also
- Multi-Tenant Kit - Tenant isolation with policy enforcement
- PostgreSQL RLS - Database-level row security