prisma-guard 1.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +1311 -0
- package/dist/generator/index.d.ts +2 -0
- package/dist/generator/index.js +893 -0
- package/dist/generator/index.js.map +1 -0
- package/dist/runtime/index.d.ts +177 -0
- package/dist/runtime/index.js +2677 -0
- package/dist/runtime/index.js.map +1 -0
- package/package.json +55 -0
package/README.md
ADDED
|
@@ -0,0 +1,1311 @@
|
|
|
1
|
+
# prisma-guard
|
|
2
|
+
|
|
3
|
+
**Schema-driven security layer for Prisma**
|
|
4
|
+
Generate input validation, query shape enforcement, and tenant isolation directly from your Prisma schema.
|
|
5
|
+
|
|
6
|
+
[](https://www.npmjs.com/package/prisma-guard)
|
|
7
|
+
[](./LICENSE)
|
|
8
|
+
[](./package.json)
|
|
9
|
+
[](https://www.prisma.io/)
|
|
10
|
+
[](https://zod.dev/)
|
|
11
|
+
|
|
12
|
+
`prisma-guard` helps prevent three common classes of backend mistakes:
|
|
13
|
+
|
|
14
|
+
* **invalid input** reaching Prisma
|
|
15
|
+
* **unsafe or overly broad query shapes**
|
|
16
|
+
* **missing tenant filters** in multi-tenant systems
|
|
17
|
+
```text
|
|
18
|
+
client request
|
|
19
|
+
↓
|
|
20
|
+
.guard(shape)
|
|
21
|
+
↓
|
|
22
|
+
validated input + allowed query shape
|
|
23
|
+
↓
|
|
24
|
+
tenant scoped query
|
|
25
|
+
↓
|
|
26
|
+
database
|
|
27
|
+
```
|
|
28
|
+
|
|
29
|
+
---
|
|
30
|
+
|
|
31
|
+
## Table of contents
|
|
32
|
+
|
|
33
|
+
* [Why this exists](#why-this-exists)
|
|
34
|
+
* [What prisma-guard does](#what-prisma-guard-does)
|
|
35
|
+
* [Architecture](#architecture)
|
|
36
|
+
* [Install](#install)
|
|
37
|
+
* [Quick start](#quick-start)
|
|
38
|
+
* [Before / After prisma-guard](#before--after-prisma-guard)
|
|
39
|
+
* [Schema annotations](#schema-annotations)
|
|
40
|
+
* [The guard API](#the-guard-api)
|
|
41
|
+
* [Mutation return projection](#mutation-return-projection)
|
|
42
|
+
* [Named shapes and caller routing](#named-shapes-and-caller-routing)
|
|
43
|
+
* [Context-dependent shapes](#context-dependent-shapes)
|
|
44
|
+
* [Automatic tenant isolation](#automatic-tenant-isolation)
|
|
45
|
+
* [findUnique behavior](#findunique-behavior)
|
|
46
|
+
* [Output shaping](#output-shaping)
|
|
47
|
+
* [Security model](#security-model)
|
|
48
|
+
* [Limitations](#limitations)
|
|
49
|
+
* [Advanced: SQL-backed runtimes](#advanced-sql-backed-runtimes)
|
|
50
|
+
* [Error handling](#error-handling)
|
|
51
|
+
* [How it works internally](#how-it-works-internally)
|
|
52
|
+
* [Why this approach](#why-this-approach)
|
|
53
|
+
* [Performance characteristics](#performance-characteristics)
|
|
54
|
+
* [Security philosophy](#security-philosophy)
|
|
55
|
+
* [Recommended production configuration](#recommended-production-configuration)
|
|
56
|
+
* [When to use prisma-guard](#when-to-use-prisma-guard)
|
|
57
|
+
* [Version compatibility](#version-compatibility)
|
|
58
|
+
* [Design principles](#design-principles)
|
|
59
|
+
* [Comparison](#comparison)
|
|
60
|
+
* [Roadmap](#roadmap)
|
|
61
|
+
* [License](#license)
|
|
62
|
+
|
|
63
|
+
---
|
|
64
|
+
|
|
65
|
+
## Why this exists
|
|
66
|
+
|
|
67
|
+
Prisma is powerful, but most real backends eventually hit the same problems.
|
|
68
|
+
|
|
69
|
+
### Missing input validation
|
|
70
|
+
```ts
|
|
71
|
+
await prisma.user.create({
|
|
72
|
+
data: req.body,
|
|
73
|
+
})
|
|
74
|
+
```
|
|
75
|
+
|
|
76
|
+
The client controls the entire payload.
|
|
77
|
+
|
|
78
|
+
### Dangerous query shapes
|
|
79
|
+
|
|
80
|
+
A client-controlled include chain can traverse relations and select sensitive fields from unrelated models:
|
|
81
|
+
```ts
|
|
82
|
+
await prisma.project.findMany({
|
|
83
|
+
include: {
|
|
84
|
+
tasks: {
|
|
85
|
+
include: {
|
|
86
|
+
comments: {
|
|
87
|
+
include: {
|
|
88
|
+
author: {
|
|
89
|
+
select: {
|
|
90
|
+
email: true,
|
|
91
|
+
passwordHash: true,
|
|
92
|
+
resetToken: true,
|
|
93
|
+
},
|
|
94
|
+
},
|
|
95
|
+
},
|
|
96
|
+
},
|
|
97
|
+
},
|
|
98
|
+
},
|
|
99
|
+
},
|
|
100
|
+
})
|
|
101
|
+
```
|
|
102
|
+
|
|
103
|
+
### Tenant isolation bugs
|
|
104
|
+
```ts
|
|
105
|
+
await prisma.project.findFirst({
|
|
106
|
+
where: { id: { equals: projectId } },
|
|
107
|
+
})
|
|
108
|
+
```
|
|
109
|
+
|
|
110
|
+
If `projectId` belongs to another tenant, data can leak.
|
|
111
|
+
|
|
112
|
+
---
|
|
113
|
+
|
|
114
|
+
## What prisma-guard does
|
|
115
|
+
|
|
116
|
+
`prisma-guard` turns your Prisma schema into a runtime **data boundary layer**.
|
|
117
|
+
|
|
118
|
+
| Feature | Description |
|
|
119
|
+
| ----------------------- | ------------------------------------------- |
|
|
120
|
+
| Input validation | Zod schemas generated from Prisma types |
|
|
121
|
+
| Query shape enforcement | Only allowed query shapes pass validation |
|
|
122
|
+
| Tenant isolation | Prisma extension injects tenant filters |
|
|
123
|
+
| Schema-driven | Rules come directly from your Prisma schema |
|
|
124
|
+
|
|
125
|
+
The goal is simple:
|
|
126
|
+
|
|
127
|
+
> Clients should not be able to accidentally or maliciously escape the data boundary defined by your schema.
|
|
128
|
+
|
|
129
|
+
`prisma-guard` is focused on **data boundaries**, not RBAC.
|
|
130
|
+
Role-based access control is intentionally out of scope.
|
|
131
|
+
|
|
132
|
+
---
|
|
133
|
+
|
|
134
|
+
## Architecture
|
|
135
|
+
|
|
136
|
+
`prisma-guard` sits between your application and Prisma Client. The `.guard(shape)` call defines the boundary; the chained Prisma method validates and executes in one step.
|
|
137
|
+
```text
|
|
138
|
+
┌───────────────┐
|
|
139
|
+
│ Client │
|
|
140
|
+
│ (API / RPC) │
|
|
141
|
+
└───────┬───────┘
|
|
142
|
+
│ request
|
|
143
|
+
▼
|
|
144
|
+
┌──────────────────────┐
|
|
145
|
+
│ .guard(shape) │
|
|
146
|
+
│ validates input + │
|
|
147
|
+
│ enforces query shape │
|
|
148
|
+
└─────────┬────────────┘
|
|
149
|
+
│ validated args
|
|
150
|
+
▼
|
|
151
|
+
┌──────────────────────┐
|
|
152
|
+
│ Tenant Scope Layer │
|
|
153
|
+
│ (Prisma extension) │
|
|
154
|
+
│ injects tenant filter │
|
|
155
|
+
└─────────┬────────────┘
|
|
156
|
+
│ scoped query
|
|
157
|
+
▼
|
|
158
|
+
┌──────────────────────┐
|
|
159
|
+
│ Prisma Client │
|
|
160
|
+
└─────────┬────────────┘
|
|
161
|
+
│ SQL
|
|
162
|
+
▼
|
|
163
|
+
┌──────────────────────┐
|
|
164
|
+
│ Database │
|
|
165
|
+
└──────────────────────┘
|
|
166
|
+
```
|
|
167
|
+
|
|
168
|
+
---
|
|
169
|
+
|
|
170
|
+
## Install
|
|
171
|
+
```bash
|
|
172
|
+
npm install prisma-guard zod @prisma/client
|
|
173
|
+
```
|
|
174
|
+
|
|
175
|
+
Peer dependencies:
|
|
176
|
+
|
|
177
|
+
* `zod ^4`
|
|
178
|
+
* `@prisma/client ^6 || ^7`
|
|
179
|
+
|
|
180
|
+
Both `zod` and `@prisma/client` must be installed before running `prisma generate`. The generator validates `@zod` directives against real Zod schemas at generation time.
|
|
181
|
+
|
|
182
|
+
Generated output files (`index.ts`, `client.ts`) are TypeScript. A TypeScript-capable build pipeline is required.
|
|
183
|
+
|
|
184
|
+
The generated output uses `.js` extension imports in TypeScript source (e.g. `import { ... } from './index.js'`). This requires ESM-aware module resolution in your TypeScript config — either `"moduleResolution": "NodeNext"` or `"moduleResolution": "Bundler"` in `tsconfig.json`. The classic `"moduleResolution": "node"` setting is not compatible.
|
|
185
|
+
|
|
186
|
+
---
|
|
187
|
+
|
|
188
|
+
## Quick start
|
|
189
|
+
|
|
190
|
+
### 1. Add the generator
|
|
191
|
+
```prisma
|
|
192
|
+
generator guard {
|
|
193
|
+
provider = "prisma-guard"
|
|
194
|
+
output = "generated/guard"
|
|
195
|
+
}
|
|
196
|
+
```
|
|
197
|
+
|
|
198
|
+
### 2. Generate
|
|
199
|
+
```bash
|
|
200
|
+
npx prisma generate
|
|
201
|
+
```
|
|
202
|
+
|
|
203
|
+
This emits a ready-to-use `client.ts` with all type mappings and a pre-wired guard instance.
|
|
204
|
+
|
|
205
|
+
### 3. Set up the Prisma client
|
|
206
|
+
```ts
|
|
207
|
+
import { AsyncLocalStorage } from 'node:async_hooks'
|
|
208
|
+
import { PrismaClient } from '@prisma/client'
|
|
209
|
+
import { guard } from './generated/guard/client'
|
|
210
|
+
|
|
211
|
+
const store = new AsyncLocalStorage<{ tenantId: string }>()
|
|
212
|
+
|
|
213
|
+
const prisma = new PrismaClient().$extends(
|
|
214
|
+
guard.extension(() => ({
|
|
215
|
+
Tenant: store.getStore()?.tenantId,
|
|
216
|
+
}))
|
|
217
|
+
)
|
|
218
|
+
```
|
|
219
|
+
|
|
220
|
+
### 4. Use it
|
|
221
|
+
```ts
|
|
222
|
+
await prisma.project
|
|
223
|
+
.guard({
|
|
224
|
+
data: { title: true },
|
|
225
|
+
})
|
|
226
|
+
.create({ data: req.body })
|
|
227
|
+
|
|
228
|
+
await prisma.project
|
|
229
|
+
.guard({
|
|
230
|
+
where: { title: { contains: true } },
|
|
231
|
+
orderBy: { title: true },
|
|
232
|
+
take: { max: 50, default: 20 },
|
|
233
|
+
})
|
|
234
|
+
.findMany(req.body)
|
|
235
|
+
```
|
|
236
|
+
|
|
237
|
+
That's it. Input is validated, query shape is enforced, tenant scope is injected. All in one chain.
|
|
238
|
+
|
|
239
|
+
---
|
|
240
|
+
|
|
241
|
+
## Before / After prisma-guard
|
|
242
|
+
|
|
243
|
+
### Without prisma-guard
|
|
244
|
+
```ts
|
|
245
|
+
await prisma.project.create({
|
|
246
|
+
data: req.body,
|
|
247
|
+
})
|
|
248
|
+
|
|
249
|
+
await prisma.project.findMany(req.query)
|
|
250
|
+
|
|
251
|
+
await prisma.project.findFirst({
|
|
252
|
+
where: { id: { equals: projectId } },
|
|
253
|
+
})
|
|
254
|
+
```
|
|
255
|
+
|
|
256
|
+
Problems:
|
|
257
|
+
|
|
258
|
+
* unvalidated input
|
|
259
|
+
* unrestricted query shape
|
|
260
|
+
* missing tenant filter
|
|
261
|
+
|
|
262
|
+
### With prisma-guard
|
|
263
|
+
```ts
|
|
264
|
+
await prisma.project
|
|
265
|
+
.guard({ data: { title: true } })
|
|
266
|
+
.create({ data: req.body })
|
|
267
|
+
|
|
268
|
+
await prisma.project
|
|
269
|
+
.guard({
|
|
270
|
+
where: { title: { contains: true } },
|
|
271
|
+
take: { max: 50, default: 20 },
|
|
272
|
+
})
|
|
273
|
+
.findMany(req.body)
|
|
274
|
+
|
|
275
|
+
await prisma.project
|
|
276
|
+
.guard({
|
|
277
|
+
where: { id: { equals: true } },
|
|
278
|
+
})
|
|
279
|
+
.findFirst({ where: { id: { equals: projectId } } })
|
|
280
|
+
```
|
|
281
|
+
|
|
282
|
+
| Risk | Mitigation |
|
|
283
|
+
| ------------------- | ------------------------------------------------ |
|
|
284
|
+
| arbitrary input | `data` shape restricts writable fields + Zod |
|
|
285
|
+
| expensive queries | shape whitelist on where, include, take, orderBy |
|
|
286
|
+
| cross-tenant access | automatic scope injection via extension |
|
|
287
|
+
|
|
288
|
+
---
|
|
289
|
+
|
|
290
|
+
## Schema annotations
|
|
291
|
+
|
|
292
|
+
### Mark tenant root models
|
|
293
|
+
|
|
294
|
+
Use `@scope-root` on models that represent tenant roots.
|
|
295
|
+
```prisma
|
|
296
|
+
/// @scope-root
|
|
297
|
+
model Tenant {
|
|
298
|
+
id String @id @default(cuid())
|
|
299
|
+
name String
|
|
300
|
+
}
|
|
301
|
+
```
|
|
302
|
+
|
|
303
|
+
Models with a single unambiguous foreign key to a scope root are auto-scoped.
|
|
304
|
+
|
|
305
|
+
If a model has multiple foreign keys to the same scope root, it is excluded from the auto-scope map when `onAmbiguousScope` is `"warn"` or `"ignore"`, and causes a generation error when `onAmbiguousScope` is `"error"` (the default).
|
|
306
|
+
|
|
307
|
+
### Add field-level validation with `@zod`
|
|
308
|
+
```prisma
|
|
309
|
+
model User {
|
|
310
|
+
id String @id @default(cuid())
|
|
311
|
+
/// @zod .email().max(255)
|
|
312
|
+
email String
|
|
313
|
+
/// @zod .min(1).max(100)
|
|
314
|
+
name String?
|
|
315
|
+
}
|
|
316
|
+
```
|
|
317
|
+
|
|
318
|
+
`@zod` chains apply automatically when the field appears in a `data` shape with `true`.
|
|
319
|
+
|
|
320
|
+
`@zod` directives are validated during `prisma generate`. The generator validates directive syntax, checks that each chained method exists on the field's Zod base type, and attempts to construct the full chain. Invalid chains — such as `.email()` on a Boolean field or `.min("abc")` on a String field — fail generation with a clear error message. Note: some argument-level type mismatches may only be caught if Zod throws at schema construction time.
|
|
321
|
+
|
|
322
|
+
For list fields, `@zod` chains apply to the `z.array(...)` schema, not to individual elements. For example, `.min(1)` on a `String[]` field enforces a minimum array length of 1, not a minimum string length.
|
|
323
|
+
|
|
324
|
+
### Supported `@zod` methods
|
|
325
|
+
|
|
326
|
+
The `@zod` DSL supports a restricted subset of Zod methods. These are the allowed methods:
|
|
327
|
+
|
|
328
|
+
**String validations:** `min`, `max`, `length`, `email`, `url`, `uuid`, `cuid`, `cuid2`, `ulid`, `trim`, `toLowerCase`, `toUpperCase`, `startsWith`, `endsWith`, `includes`, `regex`, `datetime`, `ip`, `cidr`, `date`, `time`, `duration`, `base64`, `nanoid`, `emoji`
|
|
329
|
+
|
|
330
|
+
**Number validations:** `int`, `positive`, `nonnegative`, `negative`, `nonpositive`, `finite`, `safe`, `multipleOf`, `step`, `gt`, `gte`, `lt`, `lte`
|
|
331
|
+
|
|
332
|
+
**Array validations:** `min`, `max`, `length`, `nonempty`
|
|
333
|
+
|
|
334
|
+
**Field modifiers:** `optional`, `nullable`, `nullish`, `default`, `catch`, `readonly`
|
|
335
|
+
|
|
336
|
+
Note on field modifiers: prisma-guard already handles `optional` and `nullable` based on Prisma field metadata (`isRequired`, `hasDefault`). Adding `@zod .optional()` or `@zod .nullable()` explicitly will apply the Zod method on top of what prisma-guard already does, which may cause double-wrapping. Use these only when you need to override prisma-guard's default behavior.
|
|
337
|
+
|
|
338
|
+
Note on `default`: a `@zod .default(...)` chain does not set `hasDefault` in the type map — that comes from Prisma's `@default` attribute. A `@zod .default(...)` adds a Zod-level default, which means Zod will fill in the value if it's undefined, but prisma-guard's create completeness check still treats the field based on the Prisma schema metadata.
|
|
339
|
+
|
|
340
|
+
### Supported argument types in `@zod` directives
|
|
341
|
+
|
|
342
|
+
The directive parser accepts these argument types: strings (`'hello'`, `"hello"`), numbers (`42`, `-3.14`, `1e2`), booleans (`true`, `false`), arrays (`[1, 2, 3]`), regex literals (`/^[a-z]+$/i`), and object literals (`{offset: true}`). Identifiers, template literals, `null`, `NaN`, `Infinity`, and function calls are not allowed.
|
|
343
|
+
|
|
344
|
+
### `refine` replaces `@zod` chains
|
|
345
|
+
|
|
346
|
+
Both `guard.input({ refine })` and inline refine functions in `data` shapes bypass `@zod` chains. The function receives the **base** Zod type (without `@zod` chains applied). This is by design — refine is a full override, not a modifier on top of `@zod`.
|
|
347
|
+
```ts
|
|
348
|
+
guard.input('User', {
|
|
349
|
+
refine: {
|
|
350
|
+
email: (base) => base.email().max(320),
|
|
351
|
+
},
|
|
352
|
+
})
|
|
353
|
+
```
|
|
354
|
+
|
|
355
|
+
In this example, any `@zod` directive on the `email` field in the Prisma schema is ignored. The `refine` callback is the sole source of validation for that field.
|
|
356
|
+
|
|
357
|
+
---
|
|
358
|
+
|
|
359
|
+
## The guard API
|
|
360
|
+
|
|
361
|
+
`.guard(shape)` is available on every model delegate. It returns an object with all Prisma methods. The shape defines the boundary; the method validates and executes.
|
|
362
|
+
|
|
363
|
+
### Data shape syntax
|
|
364
|
+
|
|
365
|
+
Each field in a `data` shape accepts one of three value types:
|
|
366
|
+
|
|
367
|
+
* `true` — the client may provide this value; `@zod` chains apply automatically
|
|
368
|
+
* literal value — the server forces this value; the client cannot override it
|
|
369
|
+
* function `(base) => schema` — the client may provide this value; the function receives the base Zod type (without `@zod` chains) and returns a refined schema
|
|
370
|
+
```ts
|
|
371
|
+
await prisma.project
|
|
372
|
+
.guard({
|
|
373
|
+
data: {
|
|
374
|
+
title: (base) => base.min(1, 'Title required').max(200),
|
|
375
|
+
status: true,
|
|
376
|
+
priority: (base) => base.refine(v => v >= 1 && v <= 5, 'Priority 1-5'),
|
|
377
|
+
createdBy: currentUserId,
|
|
378
|
+
},
|
|
379
|
+
})
|
|
380
|
+
.create({ data: req.body })
|
|
381
|
+
```
|
|
382
|
+
|
|
383
|
+
In this example, `title` and `priority` use inline refines for custom validation and error messages, `status` uses `@zod` chains from the Prisma schema, and `createdBy` is forced to `currentUserId` regardless of client input.
|
|
384
|
+
|
|
385
|
+
Inline refines work everywhere data shapes are used — single shapes, named shapes, and context-dependent shapes:
|
|
386
|
+
```ts
|
|
387
|
+
await prisma.project
|
|
388
|
+
.guard({
|
|
389
|
+
'/admin/projects': {
|
|
390
|
+
data: {
|
|
391
|
+
title: (base) => base.min(1).max(500),
|
|
392
|
+
status: true,
|
|
393
|
+
priority: (base) => base.refine(v => v >= 1 && v <= 10, 'Priority 1-10'),
|
|
394
|
+
},
|
|
395
|
+
},
|
|
396
|
+
'/editor/projects': {
|
|
397
|
+
data: {
|
|
398
|
+
title: (base) => base.min(1).max(200),
|
|
399
|
+
},
|
|
400
|
+
},
|
|
401
|
+
})
|
|
402
|
+
.create({
|
|
403
|
+
caller: req.headers['x-caller'],
|
|
404
|
+
data: req.body,
|
|
405
|
+
})
|
|
406
|
+
```
|
|
407
|
+
```ts
|
|
408
|
+
await prisma.project
|
|
409
|
+
.guard((ctx) => ({
|
|
410
|
+
data: {
|
|
411
|
+
title: (base) => base.min(1).max(ctx.role === 'admin' ? 500 : 200),
|
|
412
|
+
status: ctx.role === 'admin' ? true : 'draft',
|
|
413
|
+
},
|
|
414
|
+
}))
|
|
415
|
+
.create({ data: req.body })
|
|
416
|
+
```
|
|
417
|
+
|
|
418
|
+
### Query shape syntax
|
|
419
|
+
|
|
420
|
+
For read operations, `true` means the client may provide this value and literal values are forced:
|
|
421
|
+
|
|
422
|
+
### Reads
|
|
423
|
+
```ts
|
|
424
|
+
await prisma.project
|
|
425
|
+
.guard({
|
|
426
|
+
where: { title: { contains: true } },
|
|
427
|
+
orderBy: { title: true },
|
|
428
|
+
take: { max: 100, default: 25 },
|
|
429
|
+
})
|
|
430
|
+
.findMany(req.body)
|
|
431
|
+
```
|
|
432
|
+
|
|
433
|
+
The client can only filter by `title`, sort by `title`, and take up to 100 rows. Everything else is rejected.
|
|
434
|
+
|
|
435
|
+
### Creates
|
|
436
|
+
```ts
|
|
437
|
+
await prisma.project
|
|
438
|
+
.guard({
|
|
439
|
+
data: { title: true, status: true },
|
|
440
|
+
})
|
|
441
|
+
.create({ data: req.body })
|
|
442
|
+
```
|
|
443
|
+
|
|
444
|
+
Only `title` and `status` are accepted from the client. `@zod` chains apply automatically.
|
|
445
|
+
|
|
446
|
+
For create operations, guard validates that all required fields without defaults are accounted for in the data shape — either as client-allowed (`true` or function), forced (literal value), or as scope foreign keys that the scope extension will inject automatically. If a required non-default field is missing from the shape and is not a scope FK, guard throws `ShapeError` at shape evaluation time.
|
|
447
|
+
|
|
448
|
+
### Updates
|
|
449
|
+
```ts
|
|
450
|
+
await prisma.project
|
|
451
|
+
.guard({
|
|
452
|
+
data: { title: true },
|
|
453
|
+
where: { id: { equals: true } },
|
|
454
|
+
})
|
|
455
|
+
.update({
|
|
456
|
+
data: { title: 'New title' },
|
|
457
|
+
where: { id: { equals: 'abc123' } },
|
|
458
|
+
})
|
|
459
|
+
```
|
|
460
|
+
|
|
461
|
+
In update mode, all `data` fields are optional. The `where` shape enforces which filters the client can use.
|
|
462
|
+
|
|
463
|
+
### Forced values
|
|
464
|
+
|
|
465
|
+
Literal values in the shape are forced by the server and cannot be overridden by the client:
|
|
466
|
+
```ts
|
|
467
|
+
await prisma.project
|
|
468
|
+
.guard({
|
|
469
|
+
data: { title: true, status: 'draft' },
|
|
470
|
+
})
|
|
471
|
+
.create({ data: req.body })
|
|
472
|
+
```
|
|
473
|
+
|
|
474
|
+
`status` is always `'draft'` regardless of what the client sends.
|
|
475
|
+
|
|
476
|
+
The same applies to `where`:
|
|
477
|
+
```ts
|
|
478
|
+
await prisma.project
|
|
479
|
+
.guard({
|
|
480
|
+
where: {
|
|
481
|
+
status: { equals: 'published' },
|
|
482
|
+
title: { contains: true },
|
|
483
|
+
},
|
|
484
|
+
})
|
|
485
|
+
.findMany(req.body)
|
|
486
|
+
```
|
|
487
|
+
|
|
488
|
+
`status = 'published'` is always enforced. The client can only control the `title` filter.
|
|
489
|
+
|
|
490
|
+
### Deletes
|
|
491
|
+
```ts
|
|
492
|
+
await prisma.project
|
|
493
|
+
.guard({
|
|
494
|
+
where: { id: { equals: true } },
|
|
495
|
+
})
|
|
496
|
+
.delete({ where: { id: { equals: 'abc123' } } })
|
|
497
|
+
```
|
|
498
|
+
|
|
499
|
+
`data` is not valid for delete shapes.
|
|
500
|
+
|
|
501
|
+
### Batch creates
|
|
502
|
+
```ts
|
|
503
|
+
await prisma.project
|
|
504
|
+
.guard({
|
|
505
|
+
data: { title: true, status: true },
|
|
506
|
+
})
|
|
507
|
+
.createMany({
|
|
508
|
+
data: [
|
|
509
|
+
{ title: 'Project A', status: 'active' },
|
|
510
|
+
{ title: 'Project B', status: 'draft' },
|
|
511
|
+
],
|
|
512
|
+
})
|
|
513
|
+
```
|
|
514
|
+
|
|
515
|
+
Each item in the array is validated against the same data shape.
|
|
516
|
+
|
|
517
|
+
In guarded mode, `createMany` and `createManyAndReturn` require `data` to be an array. Single-object data is not silently wrapped.
|
|
518
|
+
|
|
519
|
+
### Bulk mutations
|
|
520
|
+
|
|
521
|
+
`updateMany`, `updateManyAndReturn`, and `deleteMany` require a `where` shape in the guard definition. This prevents accidental unconstrained bulk writes.
|
|
522
|
+
```ts
|
|
523
|
+
await prisma.project
|
|
524
|
+
.guard({
|
|
525
|
+
data: { status: true },
|
|
526
|
+
where: { status: { equals: true } },
|
|
527
|
+
})
|
|
528
|
+
.updateMany({
|
|
529
|
+
data: { status: 'archived' },
|
|
530
|
+
where: { status: { equals: 'draft' } },
|
|
531
|
+
})
|
|
532
|
+
```
|
|
533
|
+
|
|
534
|
+
A guard shape without `where` on a bulk mutation method throws `ShapeError`. Additionally, if the client provides no where conditions at runtime (or the resolved where is empty), the request is rejected. Each where operator object must contain at least one operator with a value — empty operator objects like `{ status: {} }` are rejected.
|
|
535
|
+
|
|
536
|
+
### Mutation body validation
|
|
537
|
+
|
|
538
|
+
Mutation bodies are strictly validated. The accepted keys depend on whether the shape defines a return projection (`select` or `include`):
|
|
539
|
+
|
|
540
|
+
Without projection in shape:
|
|
541
|
+
|
|
542
|
+
* `create`, `createMany`, `createManyAndReturn`: `data`
|
|
543
|
+
* `update`, `updateMany`, `updateManyAndReturn`: `data`, `where`
|
|
544
|
+
* `delete`, `deleteMany`: `where`
|
|
545
|
+
|
|
546
|
+
With projection in shape (methods that support it):
|
|
547
|
+
|
|
548
|
+
* `create`, `createManyAndReturn`: `data`, `select`, `include`
|
|
549
|
+
* `update`, `updateManyAndReturn`: `data`, `where`, `select`, `include`
|
|
550
|
+
* `delete`: `where`, `select`, `include`
|
|
551
|
+
|
|
552
|
+
Unknown keys are rejected with `ShapeError`. If the body contains `select` or `include` but the shape does not define them, the request is rejected.
|
|
553
|
+
|
|
554
|
+
Guard shape keys are also validated per method:
|
|
555
|
+
|
|
556
|
+
* create methods accept `data`, and optionally `select`/`include` (if the method supports projection)
|
|
557
|
+
* update methods accept `data`, `where`, and optionally `select`/`include`
|
|
558
|
+
* delete methods accept `where`, and optionally `select`/`include`
|
|
559
|
+
|
|
560
|
+
Shape keys not valid for the method throw `ShapeError`.
|
|
561
|
+
|
|
562
|
+
### Supported shape keys
|
|
563
|
+
|
|
564
|
+
For reads: `where`, `include`, `select`, `orderBy`, `cursor`, `take`, `skip`, `distinct`, `_count`, `_avg`, `_sum`, `_min`, `_max`, `by`, `having`
|
|
565
|
+
|
|
566
|
+
For writes: `data`, `where`, `select`, `include` (select/include only on methods that return records)
|
|
567
|
+
|
|
568
|
+
### Supported methods
|
|
569
|
+
|
|
570
|
+
Reads: `findMany`, `findFirst`, `findFirstOrThrow`, `findUnique`, `findUniqueOrThrow`, `count`, `aggregate`, `groupBy`
|
|
571
|
+
|
|
572
|
+
Writes: `create`, `createMany`, `createManyAndReturn`, `update`, `updateMany`, `updateManyAndReturn`, `delete`, `deleteMany`
|
|
573
|
+
|
|
574
|
+
---
|
|
575
|
+
|
|
576
|
+
## Mutation return projection
|
|
577
|
+
|
|
578
|
+
Mutations that return records can use `select` and `include` in the guard shape to control which fields and relations are returned. This uses the same shape syntax as reads — the shape whitelists what the client may request, and forced where conditions on nested includes work identically.
|
|
579
|
+
|
|
580
|
+
### Which methods support projection
|
|
581
|
+
|
|
582
|
+
| Method | Returns | select/include |
|
|
583
|
+
| --------------------- | ----------------- | -------------- |
|
|
584
|
+
| `create` | record | yes |
|
|
585
|
+
| `createMany` | BatchPayload | no |
|
|
586
|
+
| `createManyAndReturn` | record[] | yes |
|
|
587
|
+
| `update` | record | yes |
|
|
588
|
+
| `updateMany` | BatchPayload | no |
|
|
589
|
+
| `updateManyAndReturn` | record[] | yes |
|
|
590
|
+
| `delete` | record | yes |
|
|
591
|
+
| `deleteMany` | BatchPayload | no |
|
|
592
|
+
|
|
593
|
+
### Create with projection
|
|
594
|
+
```ts
|
|
595
|
+
await prisma.project
|
|
596
|
+
.guard({
|
|
597
|
+
data: { title: true },
|
|
598
|
+
include: {
|
|
599
|
+
members: true,
|
|
600
|
+
},
|
|
601
|
+
})
|
|
602
|
+
.create({
|
|
603
|
+
data: { title: 'New project' },
|
|
604
|
+
include: { members: true },
|
|
605
|
+
})
|
|
606
|
+
```
|
|
607
|
+
|
|
608
|
+
### Update with select
|
|
609
|
+
```ts
|
|
610
|
+
await prisma.project
|
|
611
|
+
.guard({
|
|
612
|
+
data: { title: true },
|
|
613
|
+
where: { id: { equals: true } },
|
|
614
|
+
select: {
|
|
615
|
+
id: true,
|
|
616
|
+
title: true,
|
|
617
|
+
members: {
|
|
618
|
+
select: { id: true, email: true },
|
|
619
|
+
},
|
|
620
|
+
},
|
|
621
|
+
})
|
|
622
|
+
.update({
|
|
623
|
+
data: { title: 'Updated' },
|
|
624
|
+
where: { id: { equals: 'abc123' } },
|
|
625
|
+
select: {
|
|
626
|
+
id: true,
|
|
627
|
+
title: true,
|
|
628
|
+
members: {
|
|
629
|
+
select: { id: true, email: true },
|
|
630
|
+
},
|
|
631
|
+
},
|
|
632
|
+
})
|
|
633
|
+
```
|
|
634
|
+
|
|
635
|
+
### Delete with include
|
|
636
|
+
```ts
|
|
637
|
+
await prisma.project
|
|
638
|
+
.guard({
|
|
639
|
+
where: { id: { equals: true } },
|
|
640
|
+
include: { members: true },
|
|
641
|
+
})
|
|
642
|
+
.delete({
|
|
643
|
+
where: { id: { equals: 'abc123' } },
|
|
644
|
+
include: { members: true },
|
|
645
|
+
})
|
|
646
|
+
```
|
|
647
|
+
|
|
648
|
+
### Forced where on nested includes in mutations
|
|
649
|
+
|
|
650
|
+
Forced where conditions work the same as in reads. This is useful for ensuring tenant-scoped nested data in mutation responses:
|
|
651
|
+
```ts
|
|
652
|
+
await prisma.project
|
|
653
|
+
.guard({
|
|
654
|
+
data: { title: true },
|
|
655
|
+
include: {
|
|
656
|
+
members: {
|
|
657
|
+
where: { isActive: { equals: true } },
|
|
658
|
+
},
|
|
659
|
+
},
|
|
660
|
+
})
|
|
661
|
+
.create({
|
|
662
|
+
data: { title: 'New project' },
|
|
663
|
+
include: { members: true },
|
|
664
|
+
})
|
|
665
|
+
```
|
|
666
|
+
|
|
667
|
+
The returned `members` will always be filtered to `isActive = true`, regardless of what the client sends.
|
|
668
|
+
|
|
669
|
+
### Projection is optional
|
|
670
|
+
|
|
671
|
+
If the shape does not define `select` or `include`, the mutation returns the full record (default Prisma behavior). The projection shape is purely additive — omitting it changes nothing about existing behavior.
|
|
672
|
+
|
|
673
|
+
### select and include are mutually exclusive
|
|
674
|
+
|
|
675
|
+
Same as reads: a shape (and a body) cannot define both `select` and `include` at the same level. Doing so throws `ShapeError`.
|
|
676
|
+
|
|
677
|
+
### Batch methods do not support projection
|
|
678
|
+
|
|
679
|
+
`createMany`, `updateMany`, and `deleteMany` return `BatchPayload` (a count), not records. Passing `select` or `include` in the shape or body for these methods throws `ShapeError`.
|
|
680
|
+
|
|
681
|
+
---
|
|
682
|
+
|
|
683
|
+
## Named shapes and caller routing
|
|
684
|
+
|
|
685
|
+
Different pages or API consumers often need different shapes for the same model. Named shapes route requests to the right shape based on a `caller` field.
|
|
686
|
+
|
|
687
|
+
Caller keys must not collide with reserved shape config keys (`where`, `data`, `include`, `select`, `orderBy`, etc.). Using a reserved key as a caller path throws `ShapeError`.
|
|
688
|
+
|
|
689
|
+
### Define named shapes
|
|
690
|
+
```ts
|
|
691
|
+
await prisma.project
|
|
692
|
+
.guard({
|
|
693
|
+
'/admin/projects': {
|
|
694
|
+
where: { title: { contains: true }, status: { equals: true } },
|
|
695
|
+
take: { max: 100 },
|
|
696
|
+
},
|
|
697
|
+
'/public/projects': {
|
|
698
|
+
where: { title: { contains: true } },
|
|
699
|
+
take: { max: 20, default: 10 },
|
|
700
|
+
},
|
|
701
|
+
})
|
|
702
|
+
.findMany({
|
|
703
|
+
caller: req.headers['x-caller'],
|
|
704
|
+
...req.body,
|
|
705
|
+
})
|
|
706
|
+
```
|
|
707
|
+
|
|
708
|
+
The frontend sends its current route as a header:
|
|
709
|
+
```ts
|
|
710
|
+
fetch('/api/projects', {
|
|
711
|
+
headers: { 'x-caller': window.location.pathname },
|
|
712
|
+
body: JSON.stringify({
|
|
713
|
+
where: { title: { contains: 'demo' } },
|
|
714
|
+
}),
|
|
715
|
+
})
|
|
716
|
+
```
|
|
717
|
+
|
|
718
|
+
The backend extracts `caller` from the body, matches it against the shape map, strips it, and validates the rest.
|
|
719
|
+
|
|
720
|
+
### Named mutation shapes
|
|
721
|
+
```ts
|
|
722
|
+
await prisma.project
|
|
723
|
+
.guard({
|
|
724
|
+
'/admin/projects/:id': {
|
|
725
|
+
data: { title: true, status: true, priority: true },
|
|
726
|
+
where: { id: { equals: true } },
|
|
727
|
+
},
|
|
728
|
+
'/editor/projects/:id': {
|
|
729
|
+
data: { title: true },
|
|
730
|
+
where: { id: { equals: true } },
|
|
731
|
+
},
|
|
732
|
+
})
|
|
733
|
+
.update({
|
|
734
|
+
caller: req.headers['x-caller'],
|
|
735
|
+
data: req.body.data,
|
|
736
|
+
where: { id: { equals: req.params.id } },
|
|
737
|
+
})
|
|
738
|
+
```
|
|
739
|
+
|
|
740
|
+
### Named shapes with inline refines
|
|
741
|
+
```ts
|
|
742
|
+
await prisma.project
|
|
743
|
+
.guard({
|
|
744
|
+
'/admin/projects': {
|
|
745
|
+
data: {
|
|
746
|
+
title: (base) => base.min(1).max(500),
|
|
747
|
+
status: true,
|
|
748
|
+
},
|
|
749
|
+
},
|
|
750
|
+
'/public/projects': {
|
|
751
|
+
data: {
|
|
752
|
+
title: (base) => base.min(1).max(100),
|
|
753
|
+
},
|
|
754
|
+
},
|
|
755
|
+
})
|
|
756
|
+
.create({
|
|
757
|
+
caller: req.headers['x-caller'],
|
|
758
|
+
data: req.body,
|
|
759
|
+
})
|
|
760
|
+
```
|
|
761
|
+
|
|
762
|
+
All data shape value types (`true`, literal, function) work in named shapes, context-dependent shapes, and single shapes.
|
|
763
|
+
|
|
764
|
+
### Parameterized caller patterns
|
|
765
|
+
```text
|
|
766
|
+
/org/:orgId/users
|
|
767
|
+
/org/:orgId/users/:userId
|
|
768
|
+
```
|
|
769
|
+
|
|
770
|
+
Matching is case-sensitive. Exact matches are checked first. If no exact match is found, parameterized patterns are evaluated. Parameters are routing-only and are not extracted into context.
|
|
771
|
+
|
|
772
|
+
### Fail-closed behavior
|
|
773
|
+
|
|
774
|
+
If `caller` is missing or doesn't match any pattern, the request is rejected with a `CallerError`. If a caller matches multiple parameterized patterns, it is also rejected with a `CallerError`.
|
|
775
|
+
|
|
776
|
+
---
|
|
777
|
+
|
|
778
|
+
## Context-dependent shapes
|
|
779
|
+
|
|
780
|
+
Shapes can be functions that receive the context provided to `guard.extension()`. This is the same context used for tenant scoping — no separate mechanism.
|
|
781
|
+
```ts
|
|
782
|
+
const prisma = new PrismaClient().$extends(
|
|
783
|
+
guard.extension(() => ({
|
|
784
|
+
Tenant: store.getStore()?.tenantId,
|
|
785
|
+
role: store.getStore()?.role,
|
|
786
|
+
}))
|
|
787
|
+
)
|
|
788
|
+
```
|
|
789
|
+
|
|
790
|
+
The context function returns an object with arbitrary keys. Keys whose values are `string`, `number`, or `bigint` are used as scope context for tenant isolation. Other keys (like `role` in the example above) are passed through to shape functions but are not used for scoping.
|
|
791
|
+
|
|
792
|
+
### Single context-dependent shape
|
|
793
|
+
```ts
|
|
794
|
+
await prisma.project
|
|
795
|
+
.guard((ctx) => ({
|
|
796
|
+
where: {
|
|
797
|
+
tenantId: { equals: ctx.Tenant },
|
|
798
|
+
title: { contains: true },
|
|
799
|
+
},
|
|
800
|
+
take: ctx.role === 'admin' ? { max: 100 } : { max: 20 },
|
|
801
|
+
}))
|
|
802
|
+
.findMany(req.body)
|
|
803
|
+
```
|
|
804
|
+
|
|
805
|
+
### Context-dependent data shapes with inline refines
|
|
806
|
+
```ts
|
|
807
|
+
await prisma.project
|
|
808
|
+
.guard((ctx) => ({
|
|
809
|
+
data: {
|
|
810
|
+
title: (base) => base.min(1).max(ctx.role === 'admin' ? 500 : 200),
|
|
811
|
+
status: ctx.role === 'admin' ? true : 'draft',
|
|
812
|
+
},
|
|
813
|
+
}))
|
|
814
|
+
.create({ data: req.body })
|
|
815
|
+
```
|
|
816
|
+
|
|
817
|
+
Context-dependent shapes can use the context both for structural decisions (which fields to expose, forced vs client-provided) and within inline refine functions (dynamic validation limits).
|
|
818
|
+
|
|
819
|
+
### Named context-dependent shapes
|
|
820
|
+
```ts
|
|
821
|
+
await prisma.project
|
|
822
|
+
.guard({
|
|
823
|
+
'/admin/projects': (ctx) => ({
|
|
824
|
+
where: {
|
|
825
|
+
tenantId: { equals: ctx.Tenant },
|
|
826
|
+
title: { contains: true },
|
|
827
|
+
},
|
|
828
|
+
take: { max: 100 },
|
|
829
|
+
}),
|
|
830
|
+
'/public/projects': {
|
|
831
|
+
where: { title: { contains: true } },
|
|
832
|
+
take: { max: 20 },
|
|
833
|
+
},
|
|
834
|
+
})
|
|
835
|
+
.findMany({
|
|
836
|
+
caller: req.headers['x-caller'],
|
|
837
|
+
...req.body,
|
|
838
|
+
})
|
|
839
|
+
```
|
|
840
|
+
|
|
841
|
+
Static shapes and function shapes can be mixed freely in the same shape map.
|
|
842
|
+
|
|
843
|
+
---
|
|
844
|
+
|
|
845
|
+
## Automatic tenant isolation
|
|
846
|
+
|
|
847
|
+
Tenant predicates are injected into top-level scoped queries only. Nested reads via `include` or `select` and nested writes are **not** automatically scoped by the extension. See [Limitations](#limitations) for details.
|
|
848
|
+
|
|
849
|
+
Input query:
|
|
850
|
+
```ts
|
|
851
|
+
await prisma.project
|
|
852
|
+
.guard({ where: { id: { equals: true } } })
|
|
853
|
+
.findFirst({ where: { id: { equals: projectId } } })
|
|
854
|
+
```
|
|
855
|
+
|
|
856
|
+
Actual enforced condition:
|
|
857
|
+
```text
|
|
858
|
+
WHERE id = ?
|
|
859
|
+
AND tenantId = ?
|
|
860
|
+
```
|
|
861
|
+
|
|
862
|
+
This applies to all top-level operations on scoped models, including reads, writes, and deletes.
|
|
863
|
+
|
|
864
|
+
### What is scoped
|
|
865
|
+
|
|
866
|
+
* All top-level reads (`findMany`, `findFirst`, `findFirstOrThrow`, `count`, `aggregate`, `groupBy`)
|
|
867
|
+
* All top-level creates (`create`, `createMany`, `createManyAndReturn`) — scope FK is injected into data
|
|
868
|
+
* All top-level unique mutations (`update`, `delete`) — scope condition is merged into where
|
|
869
|
+
* All top-level bulk mutations (`updateMany`, `updateManyAndReturn`, `deleteMany`) — scope condition is merged into where, scope FK is stripped from data
|
|
870
|
+
|
|
871
|
+
### What is NOT scoped
|
|
872
|
+
|
|
873
|
+
* Nested reads loaded via `include` or `select` — use forced where conditions in the shape to restrict these
|
|
874
|
+
* Nested writes — Prisma extension hooks operate on top-level operations only
|
|
875
|
+
* `$queryRaw` and `$executeRaw` — raw SQL bypasses all guard protections
|
|
876
|
+
* `upsert` on scoped models — rejected with `PolicyError`; handle explicitly in route logic
|
|
877
|
+
|
|
878
|
+
### Scope relation writes
|
|
879
|
+
|
|
880
|
+
When a mutation includes data for a scoped model, the scope extension manages the foreign key field automatically. The `onScopeRelationWrite` generator config controls what happens if the mutation data also includes the Prisma relation field (e.g. writing `tenant: { connect: { id: '...' } }` alongside the managed `tenantId` FK):
|
|
881
|
+
|
|
882
|
+
| Value | Behavior |
|
|
883
|
+
| --------- | ----------------------------------------------------- |
|
|
884
|
+
| `"error"` | Reject with `ShapeError` (default) |
|
|
885
|
+
| `"warn"` | Remove the relation field and log a warning |
|
|
886
|
+
| `"strip"` | Remove the relation field silently |
|
|
887
|
+
|
|
888
|
+
This setting is configured in the generator block:
|
|
889
|
+
```prisma
|
|
890
|
+
generator guard {
|
|
891
|
+
provider = "prisma-guard"
|
|
892
|
+
output = "generated/guard"
|
|
893
|
+
onScopeRelationWrite = "error"
|
|
894
|
+
}
|
|
895
|
+
```
|
|
896
|
+
|
|
897
|
+
---
|
|
898
|
+
|
|
899
|
+
## findUnique behavior
|
|
900
|
+
|
|
901
|
+
Prisma `findUnique` only accepts declared unique selectors.
|
|
902
|
+
|
|
903
|
+
This is valid:
|
|
904
|
+
```ts
|
|
905
|
+
await prisma.project.findUnique({
|
|
906
|
+
where: { id: { equals: projectId } },
|
|
907
|
+
})
|
|
908
|
+
```
|
|
909
|
+
|
|
910
|
+
This is not generally valid unless declared as a composite unique:
|
|
911
|
+
```ts
|
|
912
|
+
where: {
|
|
913
|
+
id: { equals: projectId },
|
|
914
|
+
tenantId: { equals: tenantId },
|
|
915
|
+
}
|
|
916
|
+
```
|
|
917
|
+
|
|
918
|
+
Because of this, `prisma-guard` supports two modes.
|
|
919
|
+
|
|
920
|
+
### `findUniqueMode = "reject"` recommended
|
|
921
|
+
|
|
922
|
+
Scoped `findUnique` and `findUniqueOrThrow` are rejected.
|
|
923
|
+
|
|
924
|
+
Use `findFirst` instead:
|
|
925
|
+
```ts
|
|
926
|
+
await prisma.project
|
|
927
|
+
.guard({ where: { id: { equals: true } } })
|
|
928
|
+
.findFirst({ where: { id: { equals: projectId } } })
|
|
929
|
+
```
|
|
930
|
+
|
|
931
|
+
This allows tenant scope to be enforced at query time.
|
|
932
|
+
|
|
933
|
+
### `findUniqueMode = "verify"`
|
|
934
|
+
|
|
935
|
+
The query runs first, then the result is verified against tenant scope.
|
|
936
|
+
|
|
937
|
+
This is weaker because:
|
|
938
|
+
|
|
939
|
+
* it is post-read verification
|
|
940
|
+
* it can require an extra query
|
|
941
|
+
* it has a TOCTOU race window
|
|
942
|
+
|
|
943
|
+
For tenant isolation, `"reject"` is the safer production default.
|
|
944
|
+
|
|
945
|
+
---
|
|
946
|
+
|
|
947
|
+
## Output shaping
|
|
948
|
+
|
|
949
|
+
`guard.model()` creates output schemas for validating and shaping returned data.
|
|
950
|
+
|
|
951
|
+
These schemas use base Prisma field types and do not apply `@zod` input constraints. This is intentional — `@zod` directives define input validation rules (e.g. `.email()`, `.min(1)`) that are not meaningful for validating data already stored in the database.
|
|
952
|
+
```ts
|
|
953
|
+
const userOutput = guard.model('User', {
|
|
954
|
+
pick: ['id', 'email', 'name'],
|
|
955
|
+
include: {
|
|
956
|
+
profile: { pick: ['bio'] },
|
|
957
|
+
},
|
|
958
|
+
strict: true,
|
|
959
|
+
})
|
|
960
|
+
```
|
|
961
|
+
|
|
962
|
+
`guard.model()` produces a non-strict schema by default, meaning extra fields in the data are passed through without error. For output validation where extra fields should be rejected, pass `strict: true` as shown above.
|
|
963
|
+
|
|
964
|
+
Notes:
|
|
965
|
+
|
|
966
|
+
* `pick` and `omit` apply to scalar fields and are mutually exclusive — passing both throws `ShapeError`
|
|
967
|
+
* relations must be added through `include`
|
|
968
|
+
* include depth defaults to 5 and can be overridden with `maxDepth`
|
|
969
|
+
* models may appear more than once in the include tree (e.g. `User → posts → author`) as long as the total depth does not exceed `maxDepth`
|
|
970
|
+
|
|
971
|
+
---
|
|
972
|
+
|
|
973
|
+
## Security model
|
|
974
|
+
|
|
975
|
+
`prisma-guard` enforces three layers.
|
|
976
|
+
|
|
977
|
+
| Layer | Purpose |
|
|
978
|
+
| -------------- | ------------------------------ |
|
|
979
|
+
| Input boundary | prevents invalid input |
|
|
980
|
+
| Query boundary | restricts allowed query shapes |
|
|
981
|
+
| Data boundary | injects tenant scope |
|
|
982
|
+
|
|
983
|
+
These layers are complementary. Together they provide a fail-closed data boundary around Prisma usage at the **top-level operation** only. Nested reads and writes are not intercepted by the scope layer — see [Limitations](#limitations).
|
|
984
|
+
|
|
985
|
+
---
|
|
986
|
+
|
|
987
|
+
## Limitations
|
|
988
|
+
|
|
989
|
+
These limitations are real and should be treated as part of the security model.
|
|
990
|
+
|
|
991
|
+
### Raw SQL bypasses guard protections
|
|
992
|
+
|
|
993
|
+
`$queryRaw` and `$executeRaw` are not intercepted.
|
|
994
|
+
|
|
995
|
+
### Nested writes are not intercepted
|
|
996
|
+
|
|
997
|
+
Prisma extension hooks operate on top-level operations. Nested writes do not trigger separate scope interception.
|
|
998
|
+
|
|
999
|
+
Use query shape rules to restrict nested write paths you do not want to expose.
|
|
1000
|
+
|
|
1001
|
+
### Nested reads via include are not scope-filtered
|
|
1002
|
+
|
|
1003
|
+
The scope extension operates on the top-level operation only. If a query uses `include` or `select` to load a relation that is itself a scoped model, the nested results are not tenant-filtered by the extension. Use forced where conditions in the include/select shape to restrict nested reads. This applies to both read operations and mutation return projections.
|
|
1004
|
+
|
|
1005
|
+
### `findUnique` cannot be safely scoped in Prisma extension mode
|
|
1006
|
+
|
|
1007
|
+
This is a Prisma API limitation, not a conceptual limitation of scoped unique lookups.
|
|
1008
|
+
|
|
1009
|
+
That is why `findUniqueMode = "reject"` is recommended.
|
|
1010
|
+
|
|
1011
|
+
### Composite foreign keys to scope roots
|
|
1012
|
+
|
|
1013
|
+
If a model references a scope root through composite foreign keys, it is excluded from the auto-scope map when `onAmbiguousScope` is `"warn"` or `"ignore"`, and causes a generation error when `onAmbiguousScope` is `"error"` (the default).
|
|
1014
|
+
|
|
1015
|
+
Handle these models explicitly via shape rules.
|
|
1016
|
+
|
|
1017
|
+
### No logical combinators in where shapes
|
|
1018
|
+
|
|
1019
|
+
Guard `where` shapes define field-level operator filters. Logical combinators (`AND`, `OR`, `NOT`) are not supported in shape definitions. These are Prisma client-side features that cannot be meaningfully restricted through a static shape.
|
|
1020
|
+
|
|
1021
|
+
If you need logical combinators, use context-dependent shapes to construct the full where condition server-side.
|
|
1022
|
+
|
|
1023
|
+
### No relation filters in where shapes
|
|
1024
|
+
|
|
1025
|
+
Guard `where` shapes only support scalar field filters. Relation-level filters (e.g. `posts: { some: { ... } }`) are not supported.
|
|
1026
|
+
|
|
1027
|
+
### Cursor fields must cover a unique constraint
|
|
1028
|
+
|
|
1029
|
+
Prisma requires cursor-based pagination to use uniquely-identifiable fields. Guard enforces this at shape construction time: cursor fields must cover at least one unique constraint from the model. Non-unique cursor shapes are rejected with `ShapeError`.
|
|
1030
|
+
|
|
1031
|
+
### `@zod` on list fields applies to the array
|
|
1032
|
+
|
|
1033
|
+
`@zod` directives on list fields (e.g. `String[]`) apply to the `z.array(...)` schema, not to individual elements. For example, `.min(1)` on a `String[]` field enforces a minimum array length of 1, not a minimum string length per element.
|
|
1034
|
+
|
|
1035
|
+
### Batch methods do not support return projection
|
|
1036
|
+
|
|
1037
|
+
`createMany`, `updateMany`, and `deleteMany` return `BatchPayload` (a count). Passing `select` or `include` in the shape or body for these methods throws `ShapeError`.
|
|
1038
|
+
|
|
1039
|
+
### Generated output is TypeScript with ESM imports
|
|
1040
|
+
|
|
1041
|
+
The generator writes `index.ts` and `client.ts` using `.js` extension imports. A TypeScript-capable build pipeline with ESM-aware module resolution is required (`"moduleResolution": "NodeNext"` or `"Bundler"` in `tsconfig.json`). The classic `"moduleResolution": "node"` setting is not compatible.
|
|
1042
|
+
|
|
1043
|
+
### `having` is limited to scalar fields
|
|
1044
|
+
|
|
1045
|
+
Guard `having` shapes support scalar field filters with their type-appropriate operators. Aggregate-level having expressions (e.g. `_count`, `_avg` inside having) are not supported.
|
|
1046
|
+
|
|
1047
|
+
### Json fields accept any JSON-serializable value
|
|
1048
|
+
|
|
1049
|
+
`Json` fields are recursively validated as JSON-serializable values (string, number, boolean, null, plain objects, arrays). Values that are not JSON-serializable — including `undefined`, functions, symbols, class instances (such as `Date`), `NaN`, and `Infinity` — are rejected. This does not enforce any particular JSON structure. If you need structured JSON validation, use a context-dependent shape or validate before calling guard.
|
|
1050
|
+
|
|
1051
|
+
### `refine` and inline refine functions replace `@zod` chains
|
|
1052
|
+
|
|
1053
|
+
When a `refine` callback is provided for a field in `guard.input()`, or when a function is used instead of `true` in a `data` shape, the callback receives the base Zod type without `@zod` chains. The `@zod` directive for that field is bypassed entirely. See [Schema annotations](#refine-replaces-zod-chains).
|
|
1054
|
+
|
|
1055
|
+
### `pick` and `omit` are mutually exclusive
|
|
1056
|
+
|
|
1057
|
+
Both `guard.input()` and `guard.model()` reject configurations that specify both `pick` and `omit`. This is enforced at both the type level and at runtime.
|
|
1058
|
+
|
|
1059
|
+
### `@zod` field modifiers interact with prisma-guard nullability
|
|
1060
|
+
|
|
1061
|
+
Using `@zod .optional()`, `.nullable()`, or `.nullish()` applies the Zod method on top of prisma-guard's own nullability/optionality handling. This can cause double-wrapping. These modifiers are available but should only be used when intentionally overriding default behavior.
|
|
1062
|
+
|
|
1063
|
+
### `@zod .default()` does not affect create completeness checks
|
|
1064
|
+
|
|
1065
|
+
A `@zod .default(...)` directive adds a Zod-level default but does not set `hasDefault` in the type map. Create completeness validation still uses Prisma schema metadata (`@default`). A field with only `@zod .default(...)` and no Prisma `@default` will still be flagged as missing from create data shapes.
|
|
1066
|
+
|
|
1067
|
+
### Inline refine functions are not cached
|
|
1068
|
+
|
|
1069
|
+
Data schemas containing inline refine functions are rebuilt on every request, since the function reference could be context-dependent (e.g. when used inside a dynamic shape that closes over context values). Static data shapes using only `true` and literal values are cached normally.
|
|
1070
|
+
|
|
1071
|
+
---
|
|
1072
|
+
|
|
1073
|
+
## Advanced: SQL-backed runtimes
|
|
1074
|
+
|
|
1075
|
+
The `findUnique` limitation exists in Prisma Client extension mode because Prisma requires a unique selector input type.
|
|
1076
|
+
|
|
1077
|
+
At the SQL level, scoped unique lookups are straightforward:
|
|
1078
|
+
```sql
|
|
1079
|
+
SELECT *
|
|
1080
|
+
FROM "Project"
|
|
1081
|
+
WHERE "id" = $1
|
|
1082
|
+
AND "tenantId" = $2
|
|
1083
|
+
LIMIT 1
|
|
1084
|
+
```
|
|
1085
|
+
|
|
1086
|
+
If your runtime controls SQL generation directly, it can enforce unique lookup plus tenant predicate in a single query.
|
|
1087
|
+
|
|
1088
|
+
Libraries like **prisma-sql** make this possible for advanced architectures.
|
|
1089
|
+
|
|
1090
|
+
---
|
|
1091
|
+
|
|
1092
|
+
## Error handling
|
|
1093
|
+
|
|
1094
|
+
`.guard(shape).method(body)` may throw:
|
|
1095
|
+
|
|
1096
|
+
* `ZodError` — Zod validation failures on data or query args (unless `wrapZodErrors` is enabled)
|
|
1097
|
+
* `ShapeError` — invalid shape config, unknown shape config keys, wrong method for shape, body format issues, unexpected body keys, incomplete create data shapes, or invalid inline refine functions
|
|
1098
|
+
* `CallerError` — missing, unknown, or ambiguous caller in named shapes
|
|
1099
|
+
* `PolicyError` — denied scope, missing tenant context, or rejected operations on scoped models (e.g. upsert, findUnique in reject mode)
|
|
1100
|
+
|
|
1101
|
+
All guard errors include `status` and `code` properties for HTTP response mapping:
|
|
1102
|
+
|
|
1103
|
+
| Error | status | code |
|
|
1104
|
+
| ------------- | ------ | ---------------- |
|
|
1105
|
+
| `ShapeError` | 400 | `SHAPE_INVALID` |
|
|
1106
|
+
| `CallerError` | 400 | `CALLER_UNKNOWN` |
|
|
1107
|
+
| `PolicyError` | 403 | `POLICY_DENIED` |
|
|
1108
|
+
|
|
1109
|
+
### ZodError wrapping
|
|
1110
|
+
|
|
1111
|
+
By default, Zod validation failures throw a raw `ZodError`. This means error handling code must check for both `ZodError` and guard error types.
|
|
1112
|
+
|
|
1113
|
+
To unify error handling, pass `wrapZodErrors: true` in the guard config:
|
|
1114
|
+
```ts
|
|
1115
|
+
const guard = createGuard({
|
|
1116
|
+
...generatedConfig,
|
|
1117
|
+
wrapZodErrors: true,
|
|
1118
|
+
})
|
|
1119
|
+
```
|
|
1120
|
+
|
|
1121
|
+
When enabled, all `ZodError` thrown during validation is caught and rethrown as `ShapeError` with `status: 400` and `code: 'SHAPE_INVALID'`. The original `ZodError` is preserved as the `cause` property. The error message includes a formatted summary of all Zod issues.
|
|
1122
|
+
|
|
1123
|
+
This applies to `guard.input().parse()`, `guard.query().parse()`, and all `.guard(shape).*` methods. `guard.model()` returns a raw `z.ZodObject` and is not affected.
|
|
1124
|
+
|
|
1125
|
+
---
|
|
1126
|
+
|
|
1127
|
+
## How it works internally
|
|
1128
|
+
|
|
1129
|
+
`prisma-guard` has two main parts.
|
|
1130
|
+
|
|
1131
|
+
### 1. Generator
|
|
1132
|
+
|
|
1133
|
+
Runs during `prisma generate`. Requires `zod` and `@prisma/client` to be installed.
|
|
1134
|
+
|
|
1135
|
+
It reads the Prisma DMMF and emits:
|
|
1136
|
+
|
|
1137
|
+
* `TYPE_MAP` — field metadata per model
|
|
1138
|
+
* `ENUM_MAP` — enum values
|
|
1139
|
+
* `SCOPE_MAP` — foreign key → scope root mappings
|
|
1140
|
+
* `ZOD_CHAINS` — `@zod` directive chains (validated against real Zod base types at generation time)
|
|
1141
|
+
* `GUARD_CONFIG` — generator config values
|
|
1142
|
+
* `UNIQUE_MAP` — unique constraint metadata per model
|
|
1143
|
+
* `client.ts` — pre-wired guard instance with typed model extensions
|
|
1144
|
+
|
|
1145
|
+
### 2. Runtime
|
|
1146
|
+
|
|
1147
|
+
At runtime, `guard.extension()` creates a Prisma extension that provides:
|
|
1148
|
+
|
|
1149
|
+
* `.guard(shape)` on every model delegate — validates input, enforces query shapes, returns typed Prisma methods
|
|
1150
|
+
* `$allOperations` query hook — injects tenant scope into every top-level database operation
|
|
1151
|
+
|
|
1152
|
+
The `.guard()` call validates against the shape, merges forced values, and delegates to the underlying Prisma method. The scope layer runs transparently underneath.
|
|
1153
|
+
|
|
1154
|
+
---
|
|
1155
|
+
|
|
1156
|
+
## Why this approach
|
|
1157
|
+
|
|
1158
|
+
Common alternatives have tradeoffs.
|
|
1159
|
+
|
|
1160
|
+
| Approach | Tradeoff |
|
|
1161
|
+
| -------------------------------- | --------------------------------- |
|
|
1162
|
+
| ad hoc route validation | repetitive and inconsistent |
|
|
1163
|
+
| middleware-only filtering | too easy to miss edge cases |
|
|
1164
|
+
| runtime reflection-heavy systems | slower and harder to reason about |
|
|
1165
|
+
| full ORM replacement | larger migration cost |
|
|
1166
|
+
|
|
1167
|
+
`prisma-guard` focuses narrowly on **data boundaries**, not ORM replacement.
|
|
1168
|
+
|
|
1169
|
+
---
|
|
1170
|
+
|
|
1171
|
+
## Performance characteristics
|
|
1172
|
+
|
|
1173
|
+
The runtime does lightweight argument rewriting and Zod validation.
|
|
1174
|
+
|
|
1175
|
+
In most real applications, overhead should be negligible relative to database round-trip time.
|
|
1176
|
+
|
|
1177
|
+
Static shapes (both single and named) are cached per guard instance and method. In a named shape map, each static entry is cached independently — a map with 9 static entries and 1 context-dependent entry will cache the 9 static entries. Context-dependent shapes (functions) resolve the function on each call because they depend on runtime context and are never cached.
|
|
1178
|
+
|
|
1179
|
+
Data schemas containing inline refine functions are also not cached, since the function could close over runtime context values. Data shapes using only `true` and literal values are cached normally. Projection schemas (select/include on mutations) are cached independently for static shapes.
|
|
1180
|
+
|
|
1181
|
+
---
|
|
1182
|
+
|
|
1183
|
+
## Security philosophy
|
|
1184
|
+
|
|
1185
|
+
`prisma-guard` is designed to fail closed.
|
|
1186
|
+
|
|
1187
|
+
| Condition | Behavior |
|
|
1188
|
+
| -------------------------------------- | ----------------------------------------------------- |
|
|
1189
|
+
| ambiguous scope mapping | error by default |
|
|
1190
|
+
| missing scope context | error by default |
|
|
1191
|
+
| `onMissingScopeContext = "ignore"` | scope bypassed for missing roots; present roots still enforced |
|
|
1192
|
+
| unsafe scoped `findUnique` | reject recommended |
|
|
1193
|
+
| invalid `@zod` directive | error by default |
|
|
1194
|
+
| missing `caller` in named shapes | error always |
|
|
1195
|
+
| `data` in read shape | error always |
|
|
1196
|
+
| missing `data` in write shape | error always |
|
|
1197
|
+
| bulk mutation without `where` shape | error always |
|
|
1198
|
+
| bulk mutation with empty `where` | error always |
|
|
1199
|
+
| upsert on scoped model | error always |
|
|
1200
|
+
| unexpected keys in mutation body | error always |
|
|
1201
|
+
| unknown keys in shape config | error always |
|
|
1202
|
+
| cursor not covering unique constraint | error always |
|
|
1203
|
+
| caller key collides with shape config | error always |
|
|
1204
|
+
| empty operator objects in where | error always |
|
|
1205
|
+
| `pick` and `omit` both specified | error always |
|
|
1206
|
+
| scope relation in mutation data | controlled by `onScopeRelationWrite` (default: error) |
|
|
1207
|
+
| incomplete create data shape | error always |
|
|
1208
|
+
| invalid inline refine function | error always (ShapeError) |
|
|
1209
|
+
| projection on batch method | error always |
|
|
1210
|
+
| body projection without shape | error always |
|
|
1211
|
+
|
|
1212
|
+
---
|
|
1213
|
+
|
|
1214
|
+
## Recommended production configuration
|
|
1215
|
+
```prisma
|
|
1216
|
+
generator guard {
|
|
1217
|
+
provider = "prisma-guard"
|
|
1218
|
+
output = "generated/guard"
|
|
1219
|
+
onInvalidZod = "error"
|
|
1220
|
+
onAmbiguousScope = "error"
|
|
1221
|
+
onMissingScopeContext = "error"
|
|
1222
|
+
findUniqueMode = "reject"
|
|
1223
|
+
onScopeRelationWrite = "error"
|
|
1224
|
+
}
|
|
1225
|
+
```
|
|
1226
|
+
|
|
1227
|
+
---
|
|
1228
|
+
|
|
1229
|
+
## When to use prisma-guard
|
|
1230
|
+
|
|
1231
|
+
Best fit:
|
|
1232
|
+
|
|
1233
|
+
* multi-tenant SaaS backends
|
|
1234
|
+
* Prisma-based microservices
|
|
1235
|
+
* RPC / internal API backends
|
|
1236
|
+
* systems that want schema-driven validation and scoping
|
|
1237
|
+
|
|
1238
|
+
Less suitable:
|
|
1239
|
+
|
|
1240
|
+
* raw SQL-heavy systems
|
|
1241
|
+
* architectures that bypass Prisma Client
|
|
1242
|
+
* cases where another layer already owns validation and authorization comprehensively
|
|
1243
|
+
|
|
1244
|
+
---
|
|
1245
|
+
|
|
1246
|
+
## Version compatibility
|
|
1247
|
+
|
|
1248
|
+
Supported Prisma versions:
|
|
1249
|
+
```text
|
|
1250
|
+
Prisma 6
|
|
1251
|
+
Prisma 7
|
|
1252
|
+
```
|
|
1253
|
+
|
|
1254
|
+
Supported Node versions:
|
|
1255
|
+
```text
|
|
1256
|
+
Node 20
|
|
1257
|
+
Node 22
|
|
1258
|
+
```
|
|
1259
|
+
|
|
1260
|
+
---
|
|
1261
|
+
|
|
1262
|
+
## Design principles
|
|
1263
|
+
|
|
1264
|
+
1. Fail closed on ambiguous security conditions
|
|
1265
|
+
2. Prefer query-time enforcement over verification
|
|
1266
|
+
3. Generate minimal runtime metadata
|
|
1267
|
+
4. Avoid automatic relation traversal
|
|
1268
|
+
5. Keep scope rules explicit and schema-driven
|
|
1269
|
+
6. One chain — shape defines the boundary, method executes
|
|
1270
|
+
|
|
1271
|
+
---
|
|
1272
|
+
|
|
1273
|
+
## Comparison
|
|
1274
|
+
|
|
1275
|
+
| Feature | prisma-guard | raw Prisma |
|
|
1276
|
+
| ------------------------------------------ | -------------- | ----------- |
|
|
1277
|
+
| Input validation | yes | no |
|
|
1278
|
+
| Query shape enforcement | yes | no |
|
|
1279
|
+
| Automatic tenant scoping (top-level) | yes | no |
|
|
1280
|
+
| Safe scoped `findUnique` in extension mode | reject | not handled |
|
|
1281
|
+
| Schema-driven rules | yes | no |
|
|
1282
|
+
| Caller-based shape routing | yes | no |
|
|
1283
|
+
| Typed method chaining | yes | n/a |
|
|
1284
|
+
| Bulk mutation safety | required where | not handled |
|
|
1285
|
+
| Mutation body validation | strict keys | no |
|
|
1286
|
+
| Shape config validation | strict keys | n/a |
|
|
1287
|
+
| Create completeness validation | yes | no |
|
|
1288
|
+
| Mutation return projection | yes | manual |
|
|
1289
|
+
| Inline field refine in data shapes | yes | n/a |
|
|
1290
|
+
| ZodError wrapping | opt-in | n/a |
|
|
1291
|
+
|
|
1292
|
+
---
|
|
1293
|
+
|
|
1294
|
+
## Roadmap
|
|
1295
|
+
|
|
1296
|
+
Possible future improvements:
|
|
1297
|
+
|
|
1298
|
+
* optional nested-write enforcement helpers
|
|
1299
|
+
* richer relation-level policies
|
|
1300
|
+
* logical combinator support in where shapes (`AND`/`OR`/`NOT`)
|
|
1301
|
+
* relation-level where filters
|
|
1302
|
+
* more query method coverage
|
|
1303
|
+
* adapter integrations for SQL-backed runtimes
|
|
1304
|
+
* model-specific generated types for stronger compile-time shape validation
|
|
1305
|
+
* structured JSON field validation via schema annotations
|
|
1306
|
+
|
|
1307
|
+
---
|
|
1308
|
+
|
|
1309
|
+
## License
|
|
1310
|
+
|
|
1311
|
+
MIT
|