Skip to main content

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, and ssn get kind-aware partial masks; any other kind falls back to a generic partial mask. mask:partial is the default when no mask: option is given.
  • @policy read:where <condition> — on a model. Drives the where clause 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):

ConditionEffect on the query
read:where userId == ctx.userIdadds where.userId = context.userId; if the context has no userId, narrows the query to match nothing
read:where tenantId == ctx.tenantIdadds 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.

Fixed in 2.4.1

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.

Only these two annotations are parsed

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 contexts

A 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))
})
DTO schemas accept what Prisma returns (2.4.1+)

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')
})
Redactor options

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 implemented

The 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.

Redaction applies in every context (2.4.1+)

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 cryptographic

hashValue() 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