@cerbos/orm-prisma 4.1.0 → 5.0.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 +598 -603
- package/lib/collections.d.ts +30 -0
- package/lib/collections.d.ts.map +1 -0
- package/lib/collections.js +421 -0
- package/lib/collections.js.map +1 -0
- package/lib/comparison.d.ts +24 -0
- package/lib/comparison.d.ts.map +1 -0
- package/lib/comparison.js +731 -0
- package/lib/comparison.js.map +1 -0
- package/lib/errors.d.ts +13 -0
- package/lib/errors.d.ts.map +1 -0
- package/lib/errors.js +17 -0
- package/lib/errors.js.map +1 -0
- package/lib/evaluate.d.ts +5 -0
- package/lib/evaluate.d.ts.map +1 -0
- package/lib/evaluate.js +118 -0
- package/lib/evaluate.js.map +1 -0
- package/lib/fields.d.ts +34 -0
- package/lib/fields.d.ts.map +1 -0
- package/lib/fields.js +253 -0
- package/lib/fields.js.map +1 -0
- package/lib/functions.d.ts +5 -0
- package/lib/functions.d.ts.map +1 -0
- package/lib/functions.js +408 -0
- package/lib/functions.js.map +1 -0
- package/lib/hierarchy.d.ts +6 -0
- package/lib/hierarchy.d.ts.map +1 -0
- package/lib/hierarchy.js +266 -0
- package/lib/hierarchy.js.map +1 -0
- package/lib/index.d.ts +23 -13
- package/lib/index.d.ts.map +1 -1
- package/lib/index.js +34 -2555
- package/lib/index.js.map +1 -1
- package/lib/literals.d.ts +20 -0
- package/lib/literals.d.ts.map +1 -0
- package/lib/literals.js +290 -0
- package/lib/literals.js.map +1 -0
- package/lib/mapping.d.ts +95 -0
- package/lib/mapping.d.ts.map +1 -0
- package/lib/mapping.js +316 -0
- package/lib/mapping.js.map +1 -0
- package/lib/narrowing.d.ts +12 -0
- package/lib/narrowing.d.ts.map +1 -0
- package/lib/narrowing.js +32 -0
- package/lib/narrowing.js.map +1 -0
- package/lib/plan.d.ts +49 -0
- package/lib/plan.d.ts.map +1 -0
- package/lib/plan.js +86 -0
- package/lib/plan.js.map +1 -0
- package/lib/presence.d.ts +25 -0
- package/lib/presence.d.ts.map +1 -0
- package/lib/presence.js +82 -0
- package/lib/presence.js.map +1 -0
- package/lib/regex.d.ts +33 -0
- package/lib/regex.d.ts.map +1 -0
- package/lib/regex.js +480 -0
- package/lib/regex.js.map +1 -0
- package/lib/relations.d.ts +58 -0
- package/lib/relations.d.ts.map +1 -0
- package/lib/relations.js +175 -0
- package/lib/relations.js.map +1 -0
- package/lib/rewrite.d.ts +36 -0
- package/lib/rewrite.d.ts.map +1 -0
- package/lib/rewrite.js +366 -0
- package/lib/rewrite.js.map +1 -0
- package/lib/solve.d.ts +20 -0
- package/lib/solve.d.ts.map +1 -0
- package/lib/solve.js +77 -0
- package/lib/solve.js.map +1 -0
- package/lib/strings.d.ts +12 -0
- package/lib/strings.d.ts.map +1 -0
- package/lib/strings.js +110 -0
- package/lib/strings.js.map +1 -0
- package/lib/ternary.d.ts +12 -0
- package/lib/ternary.d.ts.map +1 -0
- package/lib/ternary.js +192 -0
- package/lib/ternary.js.map +1 -0
- package/lib/timestamp.d.ts +45 -0
- package/lib/timestamp.d.ts.map +1 -0
- package/lib/timestamp.js +225 -0
- package/lib/timestamp.js.map +1 -0
- package/lib/translate.d.ts +20 -0
- package/lib/translate.d.ts.map +1 -0
- package/lib/translate.js +287 -0
- package/lib/translate.js.map +1 -0
- package/lib/types.d.ts +29 -0
- package/lib/types.d.ts.map +1 -0
- package/lib/types.js +980 -0
- package/lib/types.js.map +1 -0
- package/lib/value.d.ts +8 -0
- package/lib/value.d.ts.map +1 -0
- package/lib/value.js +7 -0
- package/lib/value.js.map +1 -0
- package/package.json +27 -17
- package/src/adversarial.test.ts +376 -1071
- package/src/collections.ts +738 -0
- package/src/comparison.ts +1157 -0
- package/src/corpus.ts +433 -250
- package/src/errors.ts +12 -0
- package/src/evaluate.ts +170 -0
- package/src/fields.ts +373 -0
- package/src/functions.ts +548 -0
- package/src/generated/prisma-adversarial/browser.ts +10 -0
- package/src/generated/prisma-adversarial/client.ts +10 -0
- package/src/generated/prisma-adversarial/commonInputTypes.ts +116 -127
- package/src/generated/prisma-adversarial/internal/class.ts +24 -4
- package/src/generated/prisma-adversarial/internal/prismaNamespace.ts +174 -3
- package/src/generated/prisma-adversarial/internal/prismaNamespaceBrowser.ts +23 -2
- package/src/generated/prisma-adversarial/models/AdversarialBoolListElement.ts +1272 -0
- package/src/generated/prisma-adversarial/models/AdversarialInner.ts +54 -54
- package/src/generated/prisma-adversarial/models/AdversarialNumberListElement.ts +1316 -0
- package/src/generated/prisma-adversarial/models/AdversarialParent.ts +66 -66
- package/src/generated/prisma-adversarial/models/AdversarialResource.ts +435 -87
- package/src/generated/prisma-adversarial/models.ts +2 -0
- package/src/generated/prisma-adversarial-mysql/browser.ts +64 -0
- package/src/generated/prisma-adversarial-mysql/client.ts +86 -0
- package/src/generated/prisma-adversarial-mysql/commonInputTypes.ts +349 -0
- package/src/generated/prisma-adversarial-mysql/enums.ts +15 -0
- package/src/generated/prisma-adversarial-mysql/internal/class.ts +284 -0
- package/src/generated/prisma-adversarial-mysql/internal/prismaNamespace.ts +1518 -0
- package/src/generated/prisma-adversarial-mysql/internal/prismaNamespaceBrowser.ts +274 -0
- package/src/generated/prisma-adversarial-mysql/models/AdversarialBoolListElement.ts +1157 -0
- package/src/generated/prisma-adversarial-mysql/models/AdversarialCategory.ts +1286 -0
- package/src/generated/prisma-adversarial-mysql/models/AdversarialInner.ts +1247 -0
- package/src/generated/prisma-adversarial-mysql/models/AdversarialLabel.ts +1157 -0
- package/src/generated/prisma-adversarial-mysql/models/AdversarialNumberListElement.ts +1201 -0
- package/src/generated/prisma-adversarial-mysql/models/AdversarialParent.ts +1352 -0
- package/src/generated/prisma-adversarial-mysql/models/AdversarialResource.ts +2021 -0
- package/src/generated/prisma-adversarial-mysql/models/AdversarialSubCategory.ts +1286 -0
- package/src/generated/prisma-adversarial-mysql/models/AdversarialTag.ts +1190 -0
- package/src/generated/prisma-adversarial-mysql/models.ts +20 -0
- package/src/generated/prisma-adversarial-mysql-v6/client.d.ts +1 -0
- package/src/generated/prisma-adversarial-mysql-v6/default.d.ts +1 -0
- package/src/generated/prisma-adversarial-mysql-v6/edge.d.ts +1 -0
- package/src/generated/prisma-adversarial-mysql-v6/index.d.ts +13822 -0
- package/src/generated/prisma-adversarial-mysql-v6/runtime/index-browser.d.ts +370 -0
- package/src/generated/prisma-adversarial-mysql-v6/runtime/library.d.ts +3982 -0
- package/src/generated/prisma-adversarial-mysql-v6/wasm.d.ts +1 -0
- package/src/generated/prisma-adversarial-pg/browser.ts +10 -0
- package/src/generated/prisma-adversarial-pg/client.ts +10 -0
- package/src/generated/prisma-adversarial-pg/commonInputTypes.ts +118 -129
- package/src/generated/prisma-adversarial-pg/internal/class.ts +24 -4
- package/src/generated/prisma-adversarial-pg/internal/prismaNamespace.ts +174 -3
- package/src/generated/prisma-adversarial-pg/internal/prismaNamespaceBrowser.ts +23 -2
- package/src/generated/prisma-adversarial-pg/models/AdversarialBoolListElement.ts +1275 -0
- package/src/generated/prisma-adversarial-pg/models/AdversarialInner.ts +54 -54
- package/src/generated/prisma-adversarial-pg/models/AdversarialNumberListElement.ts +1319 -0
- package/src/generated/prisma-adversarial-pg/models/AdversarialParent.ts +66 -66
- package/src/generated/prisma-adversarial-pg/models/AdversarialResource.ts +435 -87
- package/src/generated/prisma-adversarial-pg/models.ts +2 -0
- package/src/generated/prisma-adversarial-pg-v6/index.d.ts +3476 -388
- package/src/generated/prisma-adversarial-v6/index.d.ts +3465 -383
- package/src/hierarchy.ts +405 -0
- package/src/index.ts +66 -3646
- package/src/literals.ts +363 -0
- package/src/mapping.ts +474 -0
- package/src/narrowing.ts +33 -0
- package/src/plan.ts +125 -0
- package/src/presence.ts +114 -0
- package/src/regex.ts +596 -0
- package/src/relations.ts +226 -0
- package/src/rewrite.ts +524 -0
- package/src/solve.ts +96 -0
- package/src/strings.ts +152 -0
- package/src/ternary.ts +359 -0
- package/src/test-setup.adversarial-url.ts +6 -3
- package/src/test-setup.adversarial.mysql.v6.ts +13 -0
- package/src/test-setup.adversarial.mysql.v7.ts +12 -0
- package/src/test-setup.adversarial.v7.ts +1 -0
- package/src/timestamp.ts +308 -0
- package/src/translate.ts +419 -0
- package/src/translator.test.ts +497 -1317
- package/src/types.ts +1310 -0
- package/src/value.ts +16 -0
package/README.md
CHANGED
|
@@ -1,709 +1,704 @@
|
|
|
1
1
|
# Cerbos + Prisma ORM Adapter
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
Converts a [Cerbos](https://cerbos.dev) query plan ([PlanResources API](https://docs.cerbos.dev/cerbos/latest/api/index.html#resources-query-plan))
|
|
4
|
+
into a [Prisma](https://prisma.io) `where` input, so `findMany` returns only the records a principal
|
|
5
|
+
is allowed to see. Use it with the [Cerbos JavaScript SDK](https://github.com/cerbos/cerbos-sdk-javascript).
|
|
4
6
|
|
|
5
|
-
##
|
|
6
|
-
|
|
7
|
-
### Supported Operators
|
|
8
|
-
|
|
9
|
-
#### Basic Operators
|
|
10
|
-
|
|
11
|
-
- Logical operators: `and`, `or`, `not`
|
|
12
|
-
- Comparison operators: `eq`, `ne`, `lt`, `gt`, `lte`, `gte`, `in`
|
|
13
|
-
- String operations: `startsWith`, `endsWith`, `contains`
|
|
14
|
-
- Null checks: `eq`/`ne` against a null value map to `{ equals: null }` / `{ not: null }`. The
|
|
15
|
-
Cerbos planner emits no existence operator — `R.attr.x != null` arrives as a plain `ne`.
|
|
16
|
-
|
|
17
|
-
#### Relation Operators
|
|
18
|
-
|
|
19
|
-
- One-to-one: `is`, `isNot`
|
|
20
|
-
- One-to-many/Many-to-many: `some`, `none`, `every`
|
|
21
|
-
- Collection operators: `exists`, `all`, `except` (`exists_one` requires counting matches,
|
|
22
|
-
which Prisma where-filters cannot express — it throws rather than silently degrading to
|
|
23
|
-
`exists`; `filter` only appears inside other expressions)
|
|
24
|
-
- Set operations: `hasIntersection`
|
|
25
|
-
|
|
26
|
-
#### Arithmetic
|
|
27
|
-
|
|
28
|
-
- `add`, `sub`, `mult`, `div` with a constant side are solved algebraically to a plain column
|
|
29
|
-
comparison: `R.attr.aNumber + 1 > 2` → `{ aNumber: { gt: 1 } }` (Prisma where-filters cannot
|
|
30
|
-
express column arithmetic). Multiplying/dividing by a negative constant mirrors directional
|
|
31
|
-
operators. Arithmetic on both sides of a comparison, division BY a column, and equality or
|
|
32
|
-
inequality over fractional addition throw. The latter cannot be solved reversibly in IEEE-754
|
|
33
|
-
arithmetic.
|
|
34
|
-
- String concatenation solving: `P.attr.context == "projects:" + R.attr.id` → `{ id: { equals: "123" } }`
|
|
35
|
-
|
|
36
|
-
#### Field-to-field comparisons
|
|
37
|
-
|
|
38
|
-
Comparisons between two columns of the same model compile to
|
|
39
|
-
[Prisma field references](https://www.prisma.io/docs/orm/reference/prisma-client-reference#compare-columns-in-the-same-table).
|
|
40
|
-
Pass the Prisma model name via the `model` option (root columns) or `relation.model` in the
|
|
41
|
-
mapper (columns of a related model inside a collection expression). Comparisons across models
|
|
42
|
-
throw — Prisma only supports references between fields of the same model.
|
|
43
|
-
|
|
44
|
-
#### Hierarchy Operators
|
|
45
|
-
|
|
46
|
-
- `hierarchy(string)`, `hierarchy(string, delimiter)`, `hierarchy([segments])`
|
|
47
|
-
- `overlaps`: segment-wise prefix comparison between two hierarchies
|
|
48
|
-
- `ancestorOf` / `descendentOf`: strict prefix relationship between hierarchies
|
|
49
|
-
|
|
50
|
-
#### Advanced Features
|
|
51
|
-
|
|
52
|
-
- Deep nested relations support
|
|
53
|
-
- Automatic field inference
|
|
54
|
-
- Collection mapping and filtering
|
|
55
|
-
- Complex condition combinations
|
|
56
|
-
- Type-safe field mappings
|
|
57
|
-
- Timestamp comparisons against Prisma `DateTime` columns. Mark the field mapping with
|
|
58
|
-
`valueType: "dateTime"`; applying `timestamp()` to an untyped/string mapping throws.
|
|
59
|
-
Literals must be strict RFC 3339 instants in CEL's supported year 0001–9999 range and
|
|
60
|
-
exactly representable at millisecond precision (fractional digits after the third may
|
|
61
|
-
only be zero). The mapped column/database must preserve that precision.
|
|
62
|
-
- Outer-column references inside collection expressions (e.g.
|
|
63
|
-
`R.attr.tags.exists(t, t.name == "x" && R.attr.aBool)`) are hoisted or case-split so every
|
|
64
|
-
filter lands on the model it belongs to
|
|
65
|
-
- Three-valued-logic guards for nullable element columns: mark a relation field as
|
|
66
|
-
`nullable: true` in the mapper and collection macros (`all`, negated `exists`,
|
|
67
|
-
`hasIntersection` over `map`) exclude rows whose elements hold `NULL` in that column,
|
|
68
|
-
matching Cerbos's treatment of a missing attribute as a deny
|
|
69
|
-
|
|
70
|
-
#### Known limitations (loud failures, never silently-wrong filters)
|
|
71
|
-
|
|
72
|
-
- LIKE wildcards: Prisma emits `LIKE` without an `ESCAPE` clause, so `contains`/`startsWith`/
|
|
73
|
-
`endsWith` with a needle containing `%` or `_`, or with a column-valued needle, throws.
|
|
74
|
-
(A constant *receiver* with a column needle — `"a-b".startsWith(R.attr.x)` — is translated
|
|
75
|
-
exactly by enumerating candidate needles into an `in` filter.)
|
|
76
|
-
- Hierarchy prefixes: `ancestorOf`, `descendentOf` and `overlaps` narrow a column with a
|
|
77
|
-
`startsWith`, so they throw when the constant hierarchy contains `%`, `_` or `[`. `[` is
|
|
78
|
-
rejected as well as the two LIKE wildcards because SQL Server opens a character class on
|
|
79
|
-
`[` even when an `ESCAPE` clause is declared, so it cannot be matched literally at all.
|
|
80
|
-
- Counting: `exists_one`, `size()` thresholds other than empty/non-empty, and string-length
|
|
81
|
-
comparisons throw (`_count` is not supported inside Prisma `where`).
|
|
82
|
-
- Cross-model column comparisons throw (Prisma field references are same-model only).
|
|
83
|
-
This includes membership between an outer scalar column and a related collection column.
|
|
84
|
-
|
|
85
|
-
#### Database collation is an authorization invariant
|
|
86
|
-
|
|
87
|
-
Cerbos string comparisons are case-sensitive. Prisma delegates comparison semantics to the
|
|
88
|
-
database collation, so a case-insensitive or accent-insensitive collation can make a generated
|
|
89
|
-
authorization filter return rows that Cerbos would deny. Treat the database collation used by
|
|
90
|
-
mapped authorization columns as part of the policy contract:
|
|
91
|
-
|
|
92
|
-
- PostgreSQL: use a deterministic, case-sensitive collation and avoid `citext` or an
|
|
93
|
-
insensitive Prisma query mode for mapped fields.
|
|
94
|
-
- MySQL/MariaDB: choose a case-sensitive (`_cs`) or binary collation rather than the common
|
|
95
|
-
case-insensitive (`_ci`) defaults.
|
|
96
|
-
- SQL Server: use a case-sensitive (`_CS_`) collation rather than a case-insensitive (`_CI_`)
|
|
97
|
-
collation.
|
|
98
|
-
- SQLite: do not apply `COLLATE NOCASE` to mapped fields, and **also set
|
|
99
|
-
`PRAGMA case_sensitive_like = ON`** — see below.
|
|
100
|
-
|
|
101
|
-
**On SQLite, collation is not enough.** `contains`, `startsWith` and `endsWith` lower to `LIKE`,
|
|
102
|
-
and SQLite's `LIKE` is case-insensitive for ASCII *regardless of the column's collation*: a
|
|
103
|
-
`COLLATE BINARY` column answers `= 'one'` case-sensitively and `LIKE '%one%'` case-insensitively
|
|
104
|
-
in the same query. Only `PRAGMA case_sensitive_like = ON` changes it, and the pragma is
|
|
105
|
-
per-connection, so it must be set on every connection the application uses — not once at schema
|
|
106
|
-
creation.
|
|
107
|
-
|
|
108
|
-
That distinction is why this was missed for so long: the corpus's `cs-eq` action proved equality
|
|
109
|
-
was case-sensitive on SQLite, and equality is the one operator collation *does* govern. The
|
|
110
|
-
`cs-contains`, `cs-startswith` and `cs-endswith` actions now prove the string operators
|
|
111
|
-
separately, and without the pragma this adapter over-grants every one of them on SQLite.
|
|
112
|
-
|
|
113
|
-
The adapter cannot override a column's collation, or set a pragma, from inside a Prisma `where`
|
|
114
|
-
filter. See Prisma's
|
|
115
|
-
[case-sensitivity documentation](https://docs.prisma.io/docs/orm/v6/prisma-client/queries/case-sensitivity)
|
|
116
|
-
for provider-specific details.
|
|
117
|
-
|
|
118
|
-
### NULL attribute representation
|
|
119
|
-
|
|
120
|
-
`R.attr.x == null` compiles to the same `eq(x, null)` plan node however your application represents
|
|
121
|
-
a NULL column in the attributes it sends to `check()`, so the adapter cannot infer the convention
|
|
122
|
-
and has to be told which one you use.
|
|
123
|
-
|
|
124
|
-
| attributes you send for a NULL column | `check()` on that row | `IS NULL` filter |
|
|
125
|
-
| --- | --- | --- |
|
|
126
|
-
| `{"x": null}` — explicit null | allow | selects it — aligned |
|
|
127
|
-
| `{}` — attribute omitted | **deny** (CEL missing-attribute error) | selects it — **over-grants** |
|
|
128
|
-
|
|
129
|
-
`nullAttributeRepresentation` defaults to `"explicit"`, preserving the historical `IS NULL`
|
|
130
|
-
translation. If your application omits attributes for NULL columns, set it to `"omitted"`: the
|
|
131
|
-
adapter then rejects every null comparison operand instead of emitting a filter that returns rows
|
|
132
|
-
the PDP denies.
|
|
133
|
-
|
|
134
|
-
```ts
|
|
135
|
-
queryPlanToPrisma({ queryPlan, mapper, nullAttributeRepresentation: "omitted" });
|
|
136
|
-
```
|
|
137
|
-
|
|
138
|
-
The rejection is deliberately wider than the shapes that actually over-grant — `x != null` and
|
|
139
|
-
`!(x == null)` are aligned under both conventions — because Prisma applies negation by wrapping
|
|
140
|
-
(`{ NOT: ... }`) rather than pushing it into the leaf, so a leaf cannot tell whether an enclosing
|
|
141
|
-
`not` will flip `IS NOT NULL` back into a NULL-selecting predicate. Rejecting every null operand is
|
|
142
|
-
correct under any nesting. See [#302](https://github.com/cerbos/query-plan-adapters/issues/302).
|
|
143
|
-
|
|
144
|
-
### Declare the convention per attribute
|
|
145
|
-
|
|
146
|
-
The option above is a whole-call default, and one policy suite can legitimately use both
|
|
147
|
-
conventions: the same column mapped twice, sent as an explicit null under one attribute name and
|
|
148
|
-
omitted under another. Declare it on the mapping instead and the call-level option only covers what
|
|
149
|
-
the mapping does not:
|
|
150
|
-
|
|
151
|
-
```ts
|
|
152
|
-
const mapper = {
|
|
153
|
-
// sent as an explicit null when the column is NULL
|
|
154
|
-
"request.resource.attr.owner": {
|
|
155
|
-
field: "ownerId",
|
|
156
|
-
nullAttributeRepresentation: "explicit",
|
|
157
|
-
},
|
|
158
|
-
// omitted when the column is NULL — the call-level default applies
|
|
159
|
-
"request.resource.attr.department": { field: "department" },
|
|
160
|
-
};
|
|
161
|
-
```
|
|
162
|
-
|
|
163
|
-
Declaring `"explicit"` asserts two things: the column can be NULL, **and** a NULL reaches `check()`
|
|
164
|
-
as an explicit null. The equality family (`eq`, `ne`, `in`) over that attribute is then rendered so
|
|
165
|
-
it can never be SQL UNKNOWN — CEL holds a null *value* under this convention, so `null != "x"` is
|
|
166
|
-
TRUE and the row must come back, while UNKNOWN would drop it under *both* polarities. Ordering and
|
|
167
|
-
string operators are left alone: a null receiver raises a no-overload error in CEL, which denies
|
|
168
|
-
exactly as UNKNOWN does.
|
|
169
|
-
|
|
170
|
-
Leaving an attribute undeclared keeps the historical rendering — so nothing changes for a mapping
|
|
171
|
-
that says nothing, and `!=` against a constant keeps under-granting the NULL rows until you declare
|
|
172
|
-
it.
|
|
173
|
-
|
|
174
|
-
**Declare both sides of a field-to-field comparison, or neither.** Mixing the conventions across one
|
|
175
|
-
comparison has no faithful rendering — the declared side needs a definite answer for its NULL, the
|
|
176
|
-
undeclared side needs UNKNOWN — so the adapter throws rather than picking a direction. See
|
|
177
|
-
[#308](https://github.com/cerbos/query-plan-adapters/issues/308) and
|
|
178
|
-
[ADR 0004](../docs/adr/0004-the-null-convention-is-a-property-of-the-attribute.md).
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
### Conformance contract
|
|
182
|
-
|
|
183
|
-
The adapter is differentially tested against Cerbos PDP 0.54.0 `checkResource` decisions using 21 hostile seed rows, both Prisma 6 and 7, and both SQLite and PostgreSQL. The Spring Data adapter defines the reference semantics for this compatibility snapshot.
|
|
184
|
-
|
|
185
|
-
| Classification | Coverage |
|
|
186
|
-
| --- | --- |
|
|
187
|
-
| Oracle-tested | 136 reference actions |
|
|
188
|
-
| Fail-closed | 51 reference actions plus the 10 reference-unsupported shapes (61 actions total) |
|
|
189
|
-
| Representation-dependent | `null-eq-missing` — rejected under `nullAttributeRepresentation: "omitted"`; translated as `IS NULL` under the default, which over-grants if the caller omits attributes for NULL columns |
|
|
190
|
-
| Attribute NULL convention | The equality family (`eq`, `ne`, `in`) over an attribute the caller sends as an explicit null renders definitely, so a NULL row is included where CEL's null *value* says it should be. Declare it per attribute — `nullAttributeRepresentation: "explicit"` on the mapper entry — or the historical rendering applies and `!=` against a constant under-grants those rows (cerbos/query-plan-adapters#308) |
|
|
191
|
-
| Known planner divergence | `has()` on a missing attribute is folded by the Cerbos planner to `ALWAYS_ALLOWED`, while `checkResource` denies the missing-attribute rows. Until the planner is fixed, use `R.attr.x != null` for database-backed attributes instead of `has(R.attr.x)` |
|
|
192
|
-
|
|
193
|
-
The fail-closed set consists of literal `LIKE` cases Prisma cannot escape safely, cross-model field references, arbitrary relation counts and string lengths, `exists_one`, unsolved column arithmetic, sub-millisecond `now()` thresholds, the reference probes for regex, ordered indexing, and `timestamp()` over a string field, `mod`, a positional read of a scalar list, and list equality over a `map()` projection. Supported timestamp plans require a mapper entry with `valueType: "dateTime"` and a strict, millisecond-exact RFC 3339 literal in CEL's supported instant range. These shapes throw instead of producing a broader authorization filter. Every fail-closed shape's error message is pinned in the shared corpus (`conformance/actions.json`) and asserted by this adapter's conformance run, so a classification proves the throw names its declared mechanism rather than merely that something threw.
|
|
194
|
-
|
|
195
|
-
The `where` input each of these actions produces is pinned separately, in the translator unit test (`npm test`) — every corpus action, classified there exactly once as an emitted filter, an unconditional plan kind, or a throw. That is what makes a change to the emitted SQL show up as a diff even when it selects the same rows from the corpus seeds.
|
|
196
|
-
|
|
197
|
-
#### Providers the contract is proved on
|
|
198
|
-
|
|
199
|
-
The classification above holds where the corpus is **executed**, not where the emitted filter merely looks plausible. Until [#320](https://github.com/cerbos/query-plan-adapters/issues/320) it was executed on SQLite only, and the Prisma 6/7 matrix is an *engine* matrix, not a provider one — it says nothing about how a provider coerces a value or reads a `LIKE` pattern. The suite now runs on SQLite and PostgreSQL:
|
|
200
|
-
|
|
201
|
-
The store and the Prisma major are independent dimensions, so there are four runs and CI does all four:
|
|
7
|
+
## Install
|
|
202
8
|
|
|
203
9
|
```bash
|
|
204
|
-
npm
|
|
205
|
-
npm
|
|
206
|
-
npm run test:adversarial:postgres:v7 # PostgreSQL, Prisma 7 (testcontainers)
|
|
207
|
-
npm run test:adversarial:postgres:v6 # PostgreSQL, Prisma 6 (testcontainers)
|
|
10
|
+
npm install @cerbos/orm-prisma @cerbos/core
|
|
11
|
+
npm install @cerbos/grpc # or @cerbos/http — whichever client your deployment uses
|
|
208
12
|
```
|
|
209
13
|
|
|
210
|
-
|
|
14
|
+
- **Node.js** 22 or later.
|
|
15
|
+
- **`@prisma/client`** 5, 6 or 7 (peer dependency). CI runs Prisma 6 and 7.
|
|
16
|
+
- **`@cerbos/core`** `^0.32.0 || ^0.33.0` (peer dependency). It carries the query plan types, and
|
|
17
|
+
your application and the adapter must share one copy. npm 7+ installs peers automatically; pnpm
|
|
18
|
+
and Yarn expect you to declare it.
|
|
19
|
+
- **A Cerbos client** (`@cerbos/grpc` or `@cerbos/http`). The adapter depends on neither.
|
|
20
|
+
- **Cerbos PDP** 0.55 or later is recommended; the contract below is tested against 0.57.0.
|
|
21
|
+
- **A byte-exact database collation** on mapped columns, and on SQLite
|
|
22
|
+
`PRAGMA case_sensitive_like = ON`. See
|
|
23
|
+
[Database collation is an authorization invariant](#database-collation-is-an-authorization-invariant).
|
|
211
24
|
|
|
212
|
-
|
|
25
|
+
## Quick start
|
|
213
26
|
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
This adapter names a Prisma relation, so the mapping *looks* as though the ORM will apply the application's own narrowing to the nested `some`/`every`/`none`. **It will not.** Prisma has no schema-level filtered relation and no `@Where` equivalent; a `where` injected by a client extension or by `$extends`/middleware rewrites the top-level query and leaves nested relation filters untouched. In corpus terms this adapter is class 1 — a **bare-table subquery** — despite naming a relation.
|
|
27
|
+
```ts
|
|
28
|
+
import { GRPC } from "@cerbos/grpc";
|
|
29
|
+
import { PlanKind, queryPlanToPrisma } from "@cerbos/orm-prisma";
|
|
219
30
|
|
|
220
|
-
|
|
31
|
+
import { prisma } from "./db"; // your PrismaClient instance
|
|
221
32
|
|
|
222
|
-
|
|
223
|
-
|---|---|---|
|
|
224
|
-
| Filtered association | **Caller-owned**, reproducible with `subqueryFilter` | A `$extends`/`$use` client extension or middleware that injects a `where` for the related model, or a repository helper that always appends one. None of them rewrite the nested filter this adapter returns |
|
|
225
|
-
| Default scope on the target model | **Caller-owned**, reproducible with `subqueryFilter` | A soft-delete column (`deletedAt: null`), a tenant column, a `published` flag — anything every application read of the related model filters on. Prisma has no default-scope construct, so the convention lives in your own query code and only you can see it |
|
|
226
|
-
| Subtype discrimination | **Caller-owned**, reproducible with `subqueryFilter` | A `type`/`kind` discriminator column where one model holds several row kinds. Declare `{ type: "…" }` |
|
|
227
|
-
| To-one relation used as a collection | **Rejected by Prisma** | A `type: "one"` mapping compiles to `is`, an argument Prisma only accepts on a relation its own schema declares to-one, and `@relation(references: …)` must already point at a unique field. Mapping a to-many relation as `type: "one"` is therefore a query-validation error from Prisma, not a silently wider subquery |
|
|
228
|
-
| Composite association key | **Reproduced by Prisma** | Prisma resolves multi-column foreign keys itself from `@relation(fields: […], references: […])`. The mapping names the relation, never its columns, so there is no key for the adapter to get wrong |
|
|
229
|
-
| Absent to-one parent | **Reproduced**, and proved by the corpus (`w1-all-chain`, `rel-not-bool-hop` and siblings) | None — every operator reached through a relation requires its to-one hops separately, so a missing parent stays denied under both polarities ([#309](https://github.com/cerbos/query-plan-adapters/issues/309), [#315](https://github.com/cerbos/query-plan-adapters/issues/315), [#375](https://github.com/cerbos/query-plan-adapters/issues/375)). **Behaviour change in #375:** this previously held only for a chain of two or more relations. A negation over a SINGLE to-one hop — `!R.attr.parent.aBool` against a `type: "one"` mapping — returned every row whose relation was absent. It now emits `AND: [{ parent: { is: {} } }, { NOT: … }]` and returns fewer rows: an over-grant fix, and consumer-visible for any policy with that shape |
|
|
33
|
+
const cerbos = new GRPC("localhost:3593", { tls: false });
|
|
230
34
|
|
|
231
|
-
|
|
35
|
+
const queryPlan = await cerbos.planResources({
|
|
36
|
+
principal: { id: "user1", roles: ["USER"] },
|
|
37
|
+
resource: { kind: "document" },
|
|
38
|
+
action: "view",
|
|
39
|
+
});
|
|
232
40
|
|
|
233
|
-
```ts
|
|
234
41
|
const result = queryPlanToPrisma({
|
|
235
42
|
queryPlan,
|
|
236
43
|
mapper: {
|
|
237
|
-
"request.resource.attr.
|
|
238
|
-
|
|
239
|
-
name: "tags",
|
|
240
|
-
type: "many",
|
|
241
|
-
field: "name",
|
|
242
|
-
// Exactly the predicate your own reads of `Tag` apply.
|
|
243
|
-
subqueryFilter: { deletedAt: null, kind: "label" },
|
|
244
|
-
},
|
|
245
|
-
},
|
|
44
|
+
"request.resource.attr.ownerId": { field: "ownerId" },
|
|
45
|
+
"request.resource.attr.status": { field: "status" },
|
|
246
46
|
},
|
|
247
47
|
});
|
|
48
|
+
|
|
49
|
+
let documents;
|
|
50
|
+
switch (result.kind) {
|
|
51
|
+
case PlanKind.ALWAYS_DENIED:
|
|
52
|
+
documents = [];
|
|
53
|
+
break;
|
|
54
|
+
case PlanKind.ALWAYS_ALLOWED:
|
|
55
|
+
documents = await prisma.document.findMany({ where: { archived: false } });
|
|
56
|
+
break;
|
|
57
|
+
case PlanKind.CONDITIONAL:
|
|
58
|
+
documents = await prisma.document.findMany({
|
|
59
|
+
where: { AND: [result.filters, { archived: false }] },
|
|
60
|
+
});
|
|
61
|
+
break;
|
|
62
|
+
}
|
|
248
63
|
```
|
|
249
64
|
|
|
250
|
-
`
|
|
65
|
+
`queryPlanToPrisma` returns `{ kind }` for `ALWAYS_ALLOWED` / `ALWAYS_DENIED` and
|
|
66
|
+
`{ kind: CONDITIONAL, filters }` otherwise. `filters` is a plain where-input for the model, so you
|
|
67
|
+
can `AND` it with your own predicates, paginate, or add `select`/`orderBy`. Short-circuit
|
|
68
|
+
`ALWAYS_DENIED` instead of querying. A shape the adapter cannot express throws an `Error`; it never
|
|
69
|
+
returns a broader filter.
|
|
251
70
|
|
|
252
|
-
|
|
71
|
+
### Arguments
|
|
253
72
|
|
|
254
|
-
|
|
73
|
+
| Argument | Required | Description |
|
|
74
|
+
| --- | --- | --- |
|
|
75
|
+
| `queryPlan` | yes | The `PlanResourcesResponse` from `planResources`. |
|
|
76
|
+
| `mapper` | no | Maps Cerbos attribute paths to Prisma fields and relations. Defaults to `{}`. |
|
|
77
|
+
| `model` | no | Prisma model name of the queried model (e.g. `"Resource"`). Needed only for [field-to-field comparisons](#field-to-field-comparisons) between root columns. |
|
|
78
|
+
| `nullAttributeRepresentation` | no | `"omitted"` (default) or `"explicit"`. See [NULL attribute representation](#null-attribute-representation). |
|
|
255
79
|
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
- Prisma >= v6.0 (v7 supported)
|
|
80
|
+
Exported types: `QueryPlanToPrismaArgs`, `QueryPlanToPrismaResult`, `Mapper`, `MapperConfig`,
|
|
81
|
+
`NullAttributeRepresentation`, `PrismaFilter`, and `PlanKind` (re-exported from `@cerbos/core`).
|
|
259
82
|
|
|
260
|
-
##
|
|
83
|
+
## Mapping attributes
|
|
261
84
|
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
- A database supported by Prisma (SQLite/PostgreSQL/MySQL/etc.) so the Prisma client can communicate with stored data
|
|
85
|
+
Cerbos attribute paths are not column names, so you map them. The mapper is an object keyed by the
|
|
86
|
+
full attribute path, or a function from path to config:
|
|
265
87
|
|
|
266
|
-
|
|
88
|
+
```ts
|
|
89
|
+
// Object
|
|
90
|
+
mapper: {
|
|
91
|
+
"request.resource.attr.title": { field: "title" },
|
|
92
|
+
"request.resource.attr.createdAt": { field: "createdAt", valueType: "dateTime" },
|
|
93
|
+
}
|
|
267
94
|
|
|
268
|
-
|
|
269
|
-
|
|
95
|
+
// Function
|
|
96
|
+
mapper: (path) => ({ field: path.replace("request.resource.attr.", "") })
|
|
270
97
|
```
|
|
271
98
|
|
|
272
|
-
|
|
99
|
+
An unmapped path is used verbatim as the Prisma field name, which makes the query fail. Without a
|
|
100
|
+
mapping of its own, a path resolves through its longest mapped prefix only when that prefix names a
|
|
101
|
+
relation; a key read beneath a prefix mapped to a scalar field throws `UnsupportedQueryPlanError`.
|
|
102
|
+
|
|
103
|
+
### `MapperConfig`
|
|
273
104
|
|
|
274
|
-
|
|
105
|
+
| Key | Description |
|
|
106
|
+
| --- | --- |
|
|
107
|
+
| `field` | Prisma field name. |
|
|
108
|
+
| `valueType` | `"string"`, `"number"`, `"boolean"` or `"dateTime"`. Lets the adapter settle comparisons the type already decides before Prisma sees them: CEL answers `R.attr.aNumber == "5"` false for every row and `R.attr.aNumber.contains("2")` with an error, so neither literal is bound for the store to coerce. Required (`"dateTime"`) for `timestamp()` comparisons. Undeclared keeps the untyped behaviour; the adapter cannot read your schema. |
|
|
109
|
+
| `nullable` | `false` declares the column cannot be NULL, which drops the NULL guards on [relation elements](#relation-element-nullability) and hierarchy segments. |
|
|
110
|
+
| `nullAttributeRepresentation` | Per-attribute NULL convention. See [Declare the convention per attribute](#declare-the-convention-per-attribute). |
|
|
111
|
+
| `relation.name` | Prisma relation field name. |
|
|
112
|
+
| `relation.type` | `"one"` (compiles to `is`/`isNot`) or `"many"` (`some`/`every`/`none`). |
|
|
113
|
+
| `relation.field` | Column to compare when the policy compares the relation itself (e.g. `"x" in R.attr.tags`). |
|
|
114
|
+
| `relation.fields` | Mappings for fields of the related model, recursively (a nested entry can itself have a `relation`). Unmapped fields are inferred from the path. |
|
|
115
|
+
| `relation.model` | Prisma model name of the related model. Needed only for field-to-field comparisons inside a collection expression. |
|
|
116
|
+
| `relation.subqueryFilter` | A where-input over the related model that your application applies to its own reads. See [Declaring the application's own predicate](#declaring-the-applications-own-predicate). |
|
|
117
|
+
|
|
118
|
+
### Relations
|
|
275
119
|
|
|
276
120
|
```ts
|
|
277
|
-
|
|
121
|
+
mapper: {
|
|
122
|
+
// to-one; nested fields are inferred from the path (owner.id -> owner.is.id)
|
|
123
|
+
"request.resource.attr.owner": { relation: { name: "owner", type: "one" } },
|
|
278
124
|
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
mapper, // Map Cerbos field names to Prisma field names
|
|
282
|
-
}): {
|
|
283
|
-
kind: PlanKind,
|
|
284
|
-
filters?: any // Prisma where conditions
|
|
285
|
-
}
|
|
286
|
-
```
|
|
125
|
+
// to-many compared as a list of names: "x" in R.attr.tags
|
|
126
|
+
"request.resource.attr.tags": { relation: { name: "tags", type: "many", field: "name" } },
|
|
287
127
|
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
condition:
|
|
302
|
-
match:
|
|
303
|
-
expr: request.resource.attr.status == "active"
|
|
128
|
+
// to-many used in collection macros: R.attr.comments.exists(c, c.status == "approved")
|
|
129
|
+
"request.resource.attr.comments": {
|
|
130
|
+
relation: {
|
|
131
|
+
name: "comments",
|
|
132
|
+
type: "many",
|
|
133
|
+
fields: {
|
|
134
|
+
id: { field: "id", nullable: false },
|
|
135
|
+
status: { field: "status" },
|
|
136
|
+
author: { relation: { name: "author", type: "one" } },
|
|
137
|
+
},
|
|
138
|
+
},
|
|
139
|
+
},
|
|
140
|
+
}
|
|
304
141
|
```
|
|
305
142
|
|
|
306
|
-
|
|
143
|
+
A policy like `R.attr.status == "active" && R.attr.owner.id == P.id && "tag1" in R.attr.tags`
|
|
144
|
+
translates to:
|
|
307
145
|
|
|
308
|
-
```
|
|
309
|
-
|
|
146
|
+
```ts
|
|
147
|
+
{
|
|
148
|
+
AND: [
|
|
149
|
+
{ status: { equals: "active" } },
|
|
150
|
+
{ owner: { is: { id: { equals: "user1" } } } },
|
|
151
|
+
{ tags: { some: { name: { equals: "tag1" } } } },
|
|
152
|
+
],
|
|
153
|
+
}
|
|
310
154
|
```
|
|
311
155
|
|
|
312
|
-
|
|
156
|
+
The nested filters read the related model **unfiltered**. If your own reads of that model apply a
|
|
157
|
+
predicate (soft delete, tenant, subtype), declare it as `subqueryFilter`. See
|
|
158
|
+
[Mapping hazards](#mapping-hazards).
|
|
313
159
|
|
|
314
|
-
|
|
315
|
-
datasource db {
|
|
316
|
-
provider = "sqlite"
|
|
317
|
-
url = env("DATABASE_URL")
|
|
318
|
-
}
|
|
160
|
+
### Field-to-field comparisons
|
|
319
161
|
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
162
|
+
Comparisons between two columns of the same model compile to
|
|
163
|
+
[Prisma field references](https://www.prisma.io/docs/orm/reference/prisma-client-reference#compare-columns-in-the-same-table).
|
|
164
|
+
Pass `model` for root columns, or `relation.model` for columns of a related model inside a
|
|
165
|
+
collection expression. Comparisons across models throw: Prisma field references are same-model only.
|
|
323
166
|
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
title String
|
|
327
|
-
status String
|
|
328
|
-
}
|
|
167
|
+
```ts
|
|
168
|
+
queryPlanToPrisma({ queryPlan, mapper, model: "Resource" });
|
|
329
169
|
```
|
|
330
170
|
|
|
331
|
-
|
|
171
|
+
### Timestamps
|
|
332
172
|
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
|
|
173
|
+
Mark `DateTime` columns with `valueType: "dateTime"` and compare them with `timestamp()`.
|
|
174
|
+
`timestamp()` over an untyped or string mapping throws, and so does a bare comparison between two
|
|
175
|
+
mapped `DateTime` columns (use `timestamp()` on both sides). Literals must be strict RFC 3339
|
|
176
|
+
instants in CEL's year 0001–9999 range. Your column must preserve millisecond precision, and the
|
|
177
|
+
attribute you send must be the millisecond Prisma returns for it. An instant between two
|
|
178
|
+
milliseconds — what the planner folds `now()` into — is compared against the next millisecond,
|
|
179
|
+
which is exact for a whole-millisecond attribute: `a < T` and `a <= T` become `< ceil(T)`, `a > T`
|
|
180
|
+
and `a >= T` become `>= ceil(T)`, and `a == T` never holds. Anywhere else (a list, arithmetic, two
|
|
181
|
+
literals) such an instant still throws. `timeSince(timestamp(x))` against a duration reads the
|
|
182
|
+
clock when `queryPlanToPrisma` runs, so translate the plan for the query you are about to run.
|
|
337
183
|
|
|
338
|
-
|
|
339
|
-
const cerbos = new Cerbos("localhost:3592", { tls: false });
|
|
184
|
+
## NULL attribute representation
|
|
340
185
|
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
principal: { id: "user1", roles: ["USER"] },
|
|
344
|
-
resource: { kind: "resource" },
|
|
345
|
-
action: "view",
|
|
346
|
-
});
|
|
347
|
-
|
|
348
|
-
// Convert query plan to Prisma filters
|
|
349
|
-
const result = queryPlanToPrisma({
|
|
350
|
-
queryPlan,
|
|
351
|
-
mapper: {
|
|
352
|
-
"request.resource.attr.title": { field: "title" },
|
|
353
|
-
"request.resource.attr.status": { field: "status" },
|
|
354
|
-
},
|
|
355
|
-
});
|
|
186
|
+
`R.attr.x == null` produces the same plan however your application represents a NULL column in the
|
|
187
|
+
attributes it sends to `check()`, so tell the adapter which convention you use:
|
|
356
188
|
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
}
|
|
189
|
+
| Attributes you send for a NULL column | `check()` on that row | `IS NULL` filter |
|
|
190
|
+
| --- | --- | --- |
|
|
191
|
+
| `{"x": null}` — explicit null | allow | selects it — aligned |
|
|
192
|
+
| `{}` — attribute omitted | **deny** (CEL missing-attribute error) | selects it — **over-grants** |
|
|
360
193
|
|
|
361
|
-
|
|
362
|
-
|
|
363
|
-
where: result.filters,
|
|
364
|
-
});
|
|
194
|
+
The default is `"omitted"`, the convention that fails closed when the guess is wrong. If you send
|
|
195
|
+
NULL columns as explicit nulls, set `"explicit"` and `x == null` renders as `IS NULL`:
|
|
365
196
|
|
|
366
|
-
|
|
367
|
-
|
|
368
|
-
where: {
|
|
369
|
-
AND: [
|
|
370
|
-
{
|
|
371
|
-
status: "DRAFT"
|
|
372
|
-
},
|
|
373
|
-
result.filters,
|
|
374
|
-
]
|
|
375
|
-
});
|
|
197
|
+
```ts
|
|
198
|
+
queryPlanToPrisma({ queryPlan, mapper, nullAttributeRepresentation: "explicit" });
|
|
376
199
|
```
|
|
377
200
|
|
|
378
|
-
|
|
201
|
+
Under `"omitted"` the adapter never emits a filter that selects the NULL rows. `x == null` is FALSE for a
|
|
202
|
+
present value and an error for an absent one, so it becomes `NOT startsWith(x, "")` (for a string
|
|
203
|
+
column): FALSE when present, UNKNOWN when NULL, under either polarity. `x != null` is the
|
|
204
|
+
presence test itself. Every other null comparison operand is rejected.
|
|
379
205
|
|
|
380
|
-
|
|
206
|
+
A typed column read through to-one relations (`parent.x`) is also missing when a relation on the way is
|
|
207
|
+
absent, and a relation filter cannot promise UNKNOWN on the rows it fails. So the rendering follows
|
|
208
|
+
the comparison's polarity, which the enclosing `!`, `&&`, `||`, `exists` and `all` fix: where the
|
|
209
|
+
comparison needs to be TRUE, `parent.x != null` is the relation-reached presence test (the parent
|
|
210
|
+
exists and `x` is not NULL) and `parent.x == null` is never true; where it needs to be FALSE,
|
|
211
|
+
`parent.x == null` is the negated presence test and `parent.x != null` is never false. A missing
|
|
212
|
+
parent or a NULL `x` is then denied under any nesting of `!`. A null comparison whose polarity is
|
|
213
|
+
not fixed (a ternary condition, an operand of `==`, the body of `exists_one`) is still rejected.
|
|
381
214
|
|
|
382
|
-
|
|
383
|
-
|
|
384
|
-
|
|
385
|
-
mapper: {
|
|
386
|
-
"request.resource.attr.categories": {
|
|
387
|
-
relation: {
|
|
388
|
-
name: "categories",
|
|
389
|
-
type: "many",
|
|
390
|
-
fields: {
|
|
391
|
-
subCategories: {
|
|
392
|
-
relation: {
|
|
393
|
-
name: "subCategories",
|
|
394
|
-
type: "many",
|
|
395
|
-
fields: {
|
|
396
|
-
name: { field: "name" },
|
|
397
|
-
},
|
|
398
|
-
},
|
|
399
|
-
},
|
|
400
|
-
},
|
|
401
|
-
},
|
|
402
|
-
},
|
|
403
|
-
},
|
|
404
|
-
});
|
|
405
|
-
```
|
|
406
|
-
|
|
407
|
-
`queryPlanToPrisma` emits the necessary nested `NOT` structure so Prisma receives a valid filter for the entire relation chain.
|
|
215
|
+
The rejection covers every other null operand, including `x != null`, which is aligned under both
|
|
216
|
+
conventions: Prisma negates by wrapping in `{ NOT: … }`, so a leaf cannot tell whether an enclosing
|
|
217
|
+
`not` will flip it back. See [#302](https://github.com/cerbos/query-plan-adapters/issues/302).
|
|
408
218
|
|
|
409
|
-
###
|
|
219
|
+
### Declare the convention per attribute
|
|
410
220
|
|
|
411
|
-
|
|
221
|
+
One policy suite can use both conventions (the same column mapped under two attribute names). Set
|
|
222
|
+
the convention on the mapper entry; the call-level option covers only undeclared attributes:
|
|
412
223
|
|
|
413
224
|
```ts
|
|
414
|
-
|
|
415
|
-
|
|
416
|
-
|
|
417
|
-
|
|
418
|
-
|
|
419
|
-
|
|
420
|
-
});
|
|
421
|
-
|
|
422
|
-
// Function mapping
|
|
423
|
-
const result = queryPlanToPrisma({
|
|
424
|
-
queryPlan,
|
|
425
|
-
mapper: (fieldName) => ({
|
|
426
|
-
field: fieldName.replace("request.resource.attr.", ""),
|
|
427
|
-
}),
|
|
428
|
-
});
|
|
225
|
+
const mapper = {
|
|
226
|
+
// sent as an explicit null when the column is NULL
|
|
227
|
+
"request.resource.attr.owner": { field: "ownerId", nullAttributeRepresentation: "explicit" },
|
|
228
|
+
// omitted when the column is NULL — the call-level default applies
|
|
229
|
+
"request.resource.attr.department": { field: "department" },
|
|
230
|
+
};
|
|
429
231
|
```
|
|
430
232
|
|
|
431
|
-
|
|
233
|
+
Declaring `"explicit"` asserts the column can be NULL **and** that NULL reaches `check()` as an
|
|
234
|
+
explicit null. The equality family (`eq`, `ne`, `in`) is then rendered so it is never SQL UNKNOWN:
|
|
235
|
+
`null != "x"` is true in CEL, so the row is returned. Ordering and string operators are unchanged,
|
|
236
|
+
because CEL denies them on a null receiver anyway. An undeclared attribute follows the call-level
|
|
237
|
+
convention; if that is `"omitted"` and the caller in fact sends an explicit null, `!=` against a
|
|
238
|
+
constant under-grants the NULL rows, which fails closed.
|
|
432
239
|
|
|
433
|
-
|
|
240
|
+
A field-to-field `==`/`!=` between an explicit-null attribute and one that is not keeps both: the
|
|
241
|
+
explicit side's NULL compares as a value, the other side's NULL stays an error. See
|
|
242
|
+
[#308](https://github.com/cerbos/query-plan-adapters/issues/308) and
|
|
243
|
+
[ADR 0004](../docs/adr/0004-the-null-convention-is-a-property-of-the-attribute.md).
|
|
434
244
|
|
|
435
|
-
|
|
245
|
+
> [!WARNING]
|
|
246
|
+
> **On Cerbos 0.56.0 and earlier, do not guard with `has()`: write `R.attr.x != null`.** An
|
|
247
|
+
> attribute the plan request omits is unknown to those planners, which assume the data layer
|
|
248
|
+
> supplies it, so they fold `has(R.attr.x)` to true: alone it plans as `ALWAYS_ALLOWED`, and
|
|
249
|
+
> `has(R.attr.x) && R.attr.y > 0` plans as `R.attr.y > 0`, which never excludes a row missing `x`.
|
|
250
|
+
> From 0.57.0 the planner keeps `has(R.attr.x)` in the plan, and the adapter translates it from
|
|
251
|
+
> `x`'s null convention or refuses it: under `omitted` a row has `x` when its value is not null, and
|
|
252
|
+
> under `explicit` every row has it. The planner still drops a `has(R.attr.x)` that only guards an
|
|
253
|
+
> access to `x`, as in `has(R.attr.x) && R.attr.x == "a"`, because that access already denies a row
|
|
254
|
+
> without `x`.
|
|
436
255
|
|
|
437
|
-
|
|
438
|
-
const result = queryPlanToPrisma({
|
|
439
|
-
queryPlan,
|
|
440
|
-
mapper: {
|
|
441
|
-
// Simple relation mapping - fields will be inferred
|
|
442
|
-
"request.resource.attr.owner": {
|
|
443
|
-
relation: {
|
|
444
|
-
name: "owner",
|
|
445
|
-
type: "one", // "one" for one-to-one, "many" for one-to-many
|
|
446
|
-
},
|
|
447
|
-
},
|
|
256
|
+
## Relation element nullability
|
|
448
257
|
|
|
449
|
-
|
|
450
|
-
|
|
451
|
-
|
|
452
|
-
|
|
453
|
-
type: "many",
|
|
454
|
-
field: "name", // Optional: specify field for direct comparisons
|
|
455
|
-
},
|
|
456
|
-
},
|
|
258
|
+
Collection macros lower to `some`/`every`/`none`, which treat SQL UNKNOWN as false. Without a guard,
|
|
259
|
+
`!R.attr.tags.exists(t, t.name == "x")`, `all()` and `hasIntersection()` over `map()` would return
|
|
260
|
+
rows holding a NULL element that `check()` denies. So a relation element column is treated as
|
|
261
|
+
nullable and guarded **unless its mapping says `nullable: false`**.
|
|
457
262
|
|
|
458
|
-
|
|
459
|
-
|
|
460
|
-
|
|
461
|
-
|
|
462
|
-
|
|
463
|
-
|
|
464
|
-
|
|
465
|
-
|
|
466
|
-
|
|
467
|
-
|
|
468
|
-
},
|
|
263
|
+
Declare `nullable: false` on every required element column a policy reaches through a macro:
|
|
264
|
+
|
|
265
|
+
```ts
|
|
266
|
+
"request.resource.attr.tags": {
|
|
267
|
+
relation: {
|
|
268
|
+
name: "tags",
|
|
269
|
+
type: "many",
|
|
270
|
+
fields: {
|
|
271
|
+
id: { field: "id", nullable: false }, // String — required
|
|
272
|
+
name: { field: "name" }, // String? — nullable, guarded
|
|
469
273
|
},
|
|
470
274
|
},
|
|
471
|
-
}
|
|
275
|
+
},
|
|
472
276
|
```
|
|
473
277
|
|
|
474
|
-
|
|
475
|
-
|
|
476
|
-
|
|
278
|
+
If you leave a required column undeclared, Prisma rejects the guard's `null` comparison
|
|
279
|
+
(``Argument `id` must not be null``) and the query fails. This fails closed, but it still fails.
|
|
280
|
+
Declarations are read from nested `relation.fields` for chained collections
|
|
281
|
+
(`R.attr.a.b.exists(...)`).
|
|
282
|
+
|
|
283
|
+
## Database collation is an authorization invariant
|
|
284
|
+
|
|
285
|
+
Cerbos string comparisons are byte-exact. Prisma leaves comparison semantics to the database
|
|
286
|
+
collation, so a case- or accent-insensitive collation makes the filter return rows Cerbos denies.
|
|
287
|
+
The adapter cannot set a collation or a pragma from inside a `where`.
|
|
288
|
+
|
|
289
|
+
- **PostgreSQL:** a deterministic, case-sensitive collation. No `citext`, no `mode: "insensitive"`
|
|
290
|
+
on mapped fields. **String ordering** (`<`, `<=`, `>`, `>=`) also needs a byte-order collation,
|
|
291
|
+
`"C"`: CEL orders strings by code point, and a linguistic collation such as glibc's
|
|
292
|
+
`en_US.UTF-8` (the usual default on Debian images and managed services) or ICU's `en-US` sorts
|
|
293
|
+
`"One"` after `"a"`, so `R.attr.name > "a"` over-grants it
|
|
294
|
+
([#489](https://github.com/cerbos/query-plan-adapters/issues/489)). Create the database with
|
|
295
|
+
`LC_COLLATE 'C'`, or set `COLLATE "C"` in a migration on each column a policy orders. The conformance PostgreSQL legs initialise with `--lc-collate=C`;
|
|
296
|
+
`ADAPTER_TEST_POSTGRES_INITDB_ARGS="--locale-provider=icu --icu-locale=en-US"` replays them
|
|
297
|
+
under a linguistic order and fails both `comparison/*/string-code-point-order` cases.
|
|
298
|
+
- **MySQL:** `utf8mb4_0900_bin` (MySQL 8.0.17+), the only collation that is byte-exact and NO PAD,
|
|
299
|
+
and it orders by code point.
|
|
300
|
+
`utf8mb4_0900_as_cs` is not enough: it ignores default-ignorable code points, so
|
|
301
|
+
`'one' = 'one'` ([#474](https://github.com/cerbos/query-plan-adapters/issues/474)).
|
|
302
|
+
`utf8mb4_bin` is PAD SPACE, so `'a' = 'a '`. On MariaDB the equivalent is `utf8mb4_nopad_bin`
|
|
303
|
+
(not executed here).
|
|
304
|
+
|
|
305
|
+
**You must convert the tables yourself.** Prisma's migration engine writes
|
|
306
|
+
`COLLATE utf8mb4_unicode_ci` into every `CREATE TABLE`, ignores the server default, and has no
|
|
307
|
+
schema attribute to override it, so a Prisma-managed MySQL database is case- and
|
|
308
|
+
accent-insensitive out of the box. After migrating:
|
|
309
|
+
|
|
310
|
+
```sql
|
|
311
|
+
ALTER TABLE `YourModel` CONVERT TO CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_bin;
|
|
312
|
+
```
|
|
313
|
+
|
|
314
|
+
Measured on `mysql:8.4` against PDP 0.55.0's goldens: under `utf8mb4_unicode_ci`, **58 of the
|
|
315
|
+
184 compared cases disagree with the PDP** (`string/equals/case-sensitive` returns `"One"` for a
|
|
316
|
+
policy that allowed `"one"`); under `utf8mb4_0900_as_cs`, **17** disagree, all on the
|
|
317
|
+
soft-hyphen seed `h6`.
|
|
318
|
+
- **SQL Server:** a case-sensitive (`_CS_`) collation, not `_CI_`.
|
|
319
|
+
- **SQLite:** no `COLLATE NOCASE` on mapped fields (the default `BINARY` orders by code point, as
|
|
320
|
+
CEL does), **and** `PRAGMA case_sensitive_like = ON` on
|
|
321
|
+
every connection. `contains`/`startsWith`/`endsWith` lower to `LIKE`, which is ASCII
|
|
322
|
+
case-insensitive on SQLite regardless of column collation. The pragma is per connection, not per
|
|
323
|
+
schema, and Prisma 6's query engine pools SQLite connections, so one `$executeRawUnsafe` reaches
|
|
324
|
+
only one of them: pin `connection_limit=1` in the datasource url, as this repository's Prisma 6
|
|
325
|
+
SQLite leg does. Without it the adapter over-grants the corpus's
|
|
326
|
+
`string/contains/case-sensitive`, `string/starts-with/case-sensitive` and
|
|
327
|
+
`string/ends-with/case-sensitive` cases.
|
|
328
|
+
|
|
329
|
+
A mapped string column must not hold a NUL character. SQLite's `LIKE` stops at the first NUL,
|
|
330
|
+
so `size()` and every string filter answer for the prefix before it: `size(x) < 2` returns
|
|
331
|
+
`"a\0x"`, which Cerbos denies. PostgreSQL `text` cannot store a NUL at all. The corpus's NUL seed
|
|
332
|
+
is lacked on both legs ([#675](https://github.com/cerbos/query-plan-adapters/issues/675)).
|
|
333
|
+
|
|
334
|
+
See Prisma's [case-sensitivity documentation](https://docs.prisma.io/docs/orm/v6/prisma-client/queries/case-sensitivity).
|
|
335
|
+
|
|
336
|
+
## Supported operators
|
|
337
|
+
|
|
338
|
+
| Category | Operators |
|
|
339
|
+
| --- | --- |
|
|
340
|
+
| Logical | `and`, `or`, `not` |
|
|
341
|
+
| Comparison | `eq`, `ne`, `lt`, `gt`, `lte`, `gte`, `in`; `in` over a list built from columns (`R.attr.a in [R.attr.b, "x"]`) is the OR of the equalities, a missing element denying the row |
|
|
342
|
+
| Null checks | `== null` / `!= null` → `{ equals: null }` / `{ not: null }` (the planner has no existence operator) |
|
|
343
|
+
| Strings | `startsWith`, `endsWith`, `contains`; a `startsWith` needle holding `%`, `_` or `\` is spelled as the range of strings it bounds (`gte` the prefix and `lt` the prefix with its last code point stepped up), which needs the code-point order the [collation](#database-collation-is-an-authorization-invariant) already requires; a constant receiver with a column needle (`"a-b".startsWith(R.attr.x)`) becomes an `in` over the candidate needles; `x.upperAscii() == "LIT"` becomes an `in` over the spellings with each ASCII capital in either case (a store's `UPPER` would also fold non-ASCII letters) |
|
|
344
|
+
| Relations | to-one `is`/`isNot`; to-many `some`/`every`/`none` |
|
|
345
|
+
| Collections | `exists`, `all`, lambda `except`, `hasIntersection`, `map`/`filter` inside another expression, emptiness of a mapped relation; `isSubset` and the emptiness of the `except`/`intersect` list functions against a literal list; `"x" in c.map(...)` and `"x" in c.filter(...)` (an element whose projection or predicate errors denies the row); `exists`/`all` over a list built from columns (`[R.attr.a, R.attr.b].exists(...)`, a missing element denying the row) |
|
|
346
|
+
| Maps | `R.attr.m["key"]` reads like `R.attr.m.key`; `{"k1": v1, ...}[R.attr.x] == v` becomes an `in` over the keys whose value matches (a missing key denies under both polarities); `"key" in R.attr.parent` and `R.attr.parent.exists(k, k == "key")` over a to-one relation test the related row for a key its mapping declares, as `has(R.attr.parent.key)` does |
|
|
347
|
+
| Arithmetic | `add`, `sub`, `mult`, `div` against a constant, solved exactly over IEEE-754 doubles to a range of the column (`R.attr.n + 0.5 == 0.75` → `0.24999999999999994 <= n <= 0.25000000000000006`, every double whose rounded sum is 0.75; negative multipliers flip the direction); `x / x` and `x / ±0` split on the sign of `x`; string concatenation solving (`P.attr.ctx == "projects:" + R.attr.id` → `{ id: { equals: "…" } }`), and a concatenation of string columns and literals against a literal as one arm per split of it; list concatenation around a ternary over literal lists (`["a"] + (R.attr.b ? ["c"] : []) == [...]`) |
|
|
348
|
+
| Regex | `matches` against a literal RE2 pattern whose language LIKE decides exactly: literals, `^`/`$`, alternation, groups, `?` and bounded `{n,m}`, finite classes, `\d`, `[[:digit:]]`, leading `(?i)` over ASCII, and `.`/one `.*` in a pattern anchored at both ends (the value then holds no newline, as RE2's `.` requires). A pattern RE2 rejects (lookaround, a backreference) is an error on every row, as decided by [re2js](https://github.com/le0pard/re2js), a port of RE2 |
|
|
349
|
+
| Hierarchy | `hierarchy(string)`, `hierarchy(string, delimiter)` (an empty delimiter splits per code point, so a descendant is `startsWith(path + "_")`), `hierarchy([segments])`, `overlaps`, `ancestorOf`, `descendentOf`; a prefix holding a LIKE metacharacter is matched as a range, like a `startsWith` needle |
|
|
350
|
+
| Timestamps | `timestamp()` over `valueType: "dateTime"` columns; `timestamp(x) ± duration(d)` against a timestamp literal, solved for the column (a shift past CEL's 0001–9999 range denies); `timeSince(timestamp(x))` against a duration, as `x` against `now - d` read when the plan is translated. A bare `dateTime` attribute is the RFC 3339 string, which never orders against a timestamp: that comparison settles as CEL's error |
|
|
351
|
+
|
|
352
|
+
Outer-column references inside a macro (`R.attr.tags.exists(t, t.name == "x" && R.attr.aBool)`)
|
|
353
|
+
are hoisted or case-split so each filter lands on its own model.
|
|
354
|
+
|
|
355
|
+
### What throws
|
|
356
|
+
|
|
357
|
+
Loud failures, never silently wrong filters. A shape the adapter cannot express throws
|
|
358
|
+
`UnsupportedQueryPlanError`. It is exported and extends `Error`, so existing `catch` blocks keep
|
|
359
|
+
working:
|
|
477
360
|
|
|
478
361
|
```ts
|
|
479
|
-
|
|
480
|
-
|
|
481
|
-
|
|
482
|
-
|
|
483
|
-
|
|
484
|
-
|
|
485
|
-
|
|
486
|
-
|
|
487
|
-
}
|
|
488
|
-
}
|
|
489
|
-
}
|
|
490
|
-
}
|
|
491
|
-
|
|
492
|
-
// Shorter version - aNumber will be inferred from the path
|
|
493
|
-
{
|
|
494
|
-
"request.resource.attr.nested": {
|
|
495
|
-
relation: {
|
|
496
|
-
name: "nested",
|
|
497
|
-
type: "one"
|
|
498
|
-
}
|
|
362
|
+
import { queryPlanToPrisma, UnsupportedQueryPlanError } from "@cerbos/orm-prisma";
|
|
363
|
+
|
|
364
|
+
try {
|
|
365
|
+
const result = queryPlanToPrisma({ queryPlan, mapper, model: "Resource" });
|
|
366
|
+
} catch (error) {
|
|
367
|
+
if (error instanceof UnsupportedQueryPlanError) {
|
|
368
|
+
// The policy uses a shape this adapter cannot translate faithfully: deny, or fall back to
|
|
369
|
+
// per-row check() calls.
|
|
499
370
|
}
|
|
371
|
+
throw error;
|
|
500
372
|
}
|
|
501
373
|
```
|
|
502
374
|
|
|
503
|
-
|
|
504
|
-
|
|
505
|
-
|
|
375
|
+
A mapper misconfiguration, such as a field-to-field comparison without the `model` option or
|
|
376
|
+
`relation.model`, stays a plain `Error`. The refused shapes include:
|
|
377
|
+
|
|
378
|
+
- **LIKE metacharacters.** Prisma emits `LIKE` without `ESCAPE`, so `contains`/`endsWith` with a
|
|
379
|
+
needle containing `%`, `_` or `\`, or a column-valued needle of any string operator, throws. A
|
|
380
|
+
`startsWith` needle and a hierarchy prefix (`ancestorOf`, `descendentOf`, `overlaps`) holding
|
|
381
|
+
one are matched as a range instead, so they never reach `LIKE`. If you match on a backslash
|
|
382
|
+
suffix or substring, compare the whole value with `==`.
|
|
383
|
+
- **Counting.** `exists_one` over a relation, `size()` of a mapped relation other than empty, non-empty or (on
|
|
384
|
+
a chain) reachable, and `size()` of a derived list such as `intersect()`.
|
|
385
|
+
- **Two-variable comprehensions** (`list.exists(i, v, ...)`, `map.all(k, v, ...)`): a relation
|
|
386
|
+
filter tests related rows, which carry no position, and enumerates no map keys.
|
|
387
|
+
- **Cross-model column comparisons**, including membership between an outer scalar column and a
|
|
388
|
+
related collection column, and membership in a list holding an expression over a column
|
|
389
|
+
(`R.attr.a in ["x", R.attr.b + "y"]`, `R.attr.a in R.attr.list + ["x"]`).
|
|
390
|
+
- **Unsolvable arithmetic.** Arithmetic on both sides, and division *by* a column other than
|
|
391
|
+
`x / x`.
|
|
392
|
+
- **Other malformed shapes:** the two-list `except` function compared to a non-empty list or
|
|
393
|
+
counted past emptiness, non-scalar comparison literals, empty `and`/`or`, and
|
|
394
|
+
negating a sub-condition that translates to `{}` (Prisma reads `{ NOT: {} }` as true).
|
|
395
|
+
|
|
396
|
+
#### Operators Prisma `where` cannot express
|
|
397
|
+
|
|
398
|
+
Prisma 6.19 and 7.9 expose the same filter surface on SQLite, PostgreSQL and MySQL
|
|
399
|
+
([#224](https://github.com/cerbos/query-plan-adapters/issues/224)): scalar filters take a constant
|
|
400
|
+
or a same-model field reference, never an expression. These shapes throw:
|
|
401
|
+
|
|
402
|
+
| CEL shape | Corpus cases | Why Prisma cannot express it |
|
|
403
|
+
| --- | --- | --- |
|
|
404
|
+
| `a % b` | `arithmetic/modulo/*` | No modulo operator, and `%` is not invertible, so it cannot be solved into a plain comparison. |
|
|
405
|
+
| Arithmetic on both sides | `arithmetic/add/on-both-sides` | Field references compare two columns as they are; no operand is an expression. |
|
|
406
|
+
| `matches` beyond the LIKE-decidable subset | `regex/matches/lucene-reserved-characters-in-class` | No regex filter ([prisma/prisma#18481](https://github.com/prisma/prisma/issues/18481)), and no provider here has RE2. A repeated class (`[ab]+`), a negated class, a wildcard in a pattern not anchored at both ends, or a literal `%`/`_`/`\` inside a LIKE pattern has no exact LIKE spelling. |
|
|
407
|
+
| List index `l[i]` | `collection/index/*` (every positional case) | List filters test membership, emptiness or equality, never a position. |
|
|
408
|
+
| `int()`, `double()` or `timestamp()` parsing a string; an `int()` threshold | `cast/int/malformed-string`, `cast/double/malformed-string`, `cast/timestamp/malformed-string` and their negations | No cast operator, and SQL `CAST` would not reproduce CEL's conversion errors (SQLite reads `CAST('abc' AS INTEGER)` as `0`). `int()` of a number is translated only as `==` (or `!=` under a `!`): a threshold would need CEL's ±2^63 overflow bound spelled out, which an `Int` column rejects. The invertible casts are solved for the column instead — `string()` of a boolean column, a boolean-valued expression or a number column, `string(int(d)) == "k"` as `int(d) == k`, `int(d) == k` as the interval truncation maps to `k`, and `string()`/`double()` of a column already of that type. |
|
|
409
|
+
| `size()` of `filter()` over a relation | `size/equals/filtered-collection` | No count filter; `_count` exists only in `orderBy`, `select` and aggregates ([prisma/prisma#8935](https://github.com/prisma/prisma/issues/8935)). |
|
|
410
|
+
|
|
411
|
+
Raw SQL fragments, an id subquery and an in-memory post-filter were considered and rejected: Prisma
|
|
412
|
+
5–7 has no raw predicate inside `where` ([prisma/prisma#5560](https://github.com/prisma/prisma/issues/5560),
|
|
413
|
+
[prisma/prisma#11568](https://github.com/prisma/prisma/issues/11568)), and the other two would stop
|
|
414
|
+
the result being a composable where-input.
|
|
415
|
+
|
|
416
|
+
**Workaround: a derived column.** Compute the value when you write the row and have the policy read
|
|
417
|
+
it: `isEven` instead of `R.attr.n % 2 == 0`, `tagCount` instead of `size(R.attr.tags) > 1`,
|
|
418
|
+
`primaryTag` instead of `R.attr.tags[0]`.
|
|
419
|
+
|
|
420
|
+
Prisma 8 (a release candidate as of September 2026) replaces `findMany({ where })` with a new client
|
|
421
|
+
API and is out of scope.
|
|
422
|
+
|
|
423
|
+
## Conformance contract
|
|
424
|
+
|
|
425
|
+
The adapter is replayed against the shared [conformance corpus](../conformance/README.md): the plans
|
|
426
|
+
and `check()` decisions recorded from Cerbos PDP 0.57.0 (and 0.56.0), executed as real Prisma
|
|
427
|
+
queries over 43 of the corpus's 44 seed rows with Prisma 6 and 7 on SQLite, PostgreSQL and MySQL
|
|
428
|
+
(under `utf8mb4_0900_bin`). The ledger lacks `null-list`, so j4 is not loaded, and `nul-in-string` on PostgreSQL and SQLite, so k3 is not loaded there. Passed cases on the
|
|
429
|
+
current PDP, 0.57.0, identical on all six combinations, out of every golden case in the tier:
|
|
430
|
+
|
|
431
|
+
| Tier | Passed / total |
|
|
432
|
+
| --- | --- |
|
|
433
|
+
| core | 32 / 32 |
|
|
434
|
+
| extended | 84 / 110 |
|
|
435
|
+
| adversarial | 378 / 546 |
|
|
436
|
+
|
|
437
|
+
Every case that does not pass is refused with `UnsupportedQueryPlanError`; none returns wrong rows
|
|
438
|
+
on 0.56.0. [`conformance-ledger.json`](conformance-ledger.json) lists each one with its reason.
|
|
439
|
+
Cases the corpus marks as a planner divergence are skipped, not failed, and count in the total but
|
|
440
|
+
never as passed. On 0.57.0 that is three extended cases and four adversarial cases.
|
|
441
|
+
On 0.56.0, `null/has/missing-attribute` and `null/has/composed-with-comparison` are skipped too: that planner folds `has()` on an omitted attribute to true, while `check()` denies the row; 0.57.0 keeps `has()` in the plan. `arithmetic/add/int-literal-plus-constant` and `arithmetic/add/int-literal-negated`: the planner drops the int type of the literal in `R.attr.x + 1`, so the plan is the double spelling's, while `check()` has no double + int overload and denies every row; write `1.0`. The three `composition/*` cases with a conditional `DENY` rule: the deny
|
|
442
|
+
condition reads `aNumber`, which j2 lacks: the plan's `not(...)` of it denies j2, while
|
|
443
|
+
`checkResource` receives `aNumber` as absent and treats the erroring deny as not matching
|
|
444
|
+
([#530](https://github.com/cerbos/query-plan-adapters/issues/530)). And
|
|
445
|
+
`type-mismatch/in/number-field-in-scalar-principal` and `type-mismatch/in/string-field-in-dyn-string`:
|
|
446
|
+
CEL has no `in` overload for a scalar container, so `check()` errors on that disjunct, while the
|
|
447
|
+
planner rewrites the `in` to an equality.
|
|
448
|
+
|
|
449
|
+
**Providers.** `ADAPTER_TEST_MYSQL_COLLATION` replays the MySQL legs under another collation, which
|
|
450
|
+
is how the figures in the collation section were measured, and `ADAPTER_TEST_POSTGRES_INITDB_ARGS`
|
|
451
|
+
replays the PostgreSQL legs under another initdb locale. MySQL adds no refused shape. SQL Server
|
|
452
|
+
and CockroachDB are **not** executed: refusal reasons naming them are reasoned from documented
|
|
453
|
+
`LIKE` behaviour.
|
|
454
|
+
|
|
455
|
+
## Mapping hazards
|
|
456
|
+
|
|
457
|
+
The contract above proves the *plan* side. The other half is the *mapping*: **the records the
|
|
458
|
+
nested filter reads must be the records your application put into the resource attributes.** The
|
|
459
|
+
shared corpus catalogues six ways that can break.
|
|
460
|
+
|
|
461
|
+
Prisma has no filtered relation and no `@Where` equivalent, and a `where` injected by a client
|
|
462
|
+
extension or middleware (`$extends`/`$use`) rewrites only the top-level query. So although the
|
|
463
|
+
mapping names a relation, this adapter is corpus class 1 — a **bare-table subquery**. Declare your
|
|
464
|
+
own narrowing as `subqueryFilter`; the adapter cannot detect an omission.
|
|
506
465
|
|
|
507
|
-
|
|
508
|
-
|
|
509
|
-
|
|
466
|
+
| Hazard | Position | Mechanism to check |
|
|
467
|
+
|---|---|---|
|
|
468
|
+
| Filtered association | **Caller-owned**, reproducible with `subqueryFilter` | A `$extends`/`$use` client extension or middleware that injects a `where` for the related model, or a repository helper that always appends one. None of them rewrite the nested filter this adapter returns |
|
|
469
|
+
| Default scope on the target model | **Caller-owned**, reproducible with `subqueryFilter` | A soft-delete column (`deletedAt: null`), a tenant column, a `published` flag — anything every application read of the related model filters on. Prisma has no default-scope construct |
|
|
470
|
+
| Subtype discrimination | **Caller-owned**, reproducible with `subqueryFilter` | A `type`/`kind` discriminator column where one model holds several row kinds. Declare `{ type: "…" }` |
|
|
471
|
+
| To-one relation used as a collection | **Rejected by Prisma** | `type: "one"` compiles to `is`, which Prisma accepts only on a relation its schema declares to-one. Mapping a to-many relation as `type: "one"` is a Prisma validation error, not a wider subquery |
|
|
472
|
+
| Composite association key | **Reproduced by Prisma** | Prisma resolves multi-column keys from `@relation(fields: […], references: […])`. The mapping names the relation, never its columns |
|
|
473
|
+
| Absent to-one parent | **Reproduced**, and proved by the corpus (`relation/all/to-one-chain`, `relation/bare-attribute/negated-one-hop-boolean` and siblings) | None — every operator reached through a relation requires its to-one hops separately, so a missing parent stays denied under both polarities ([#309](https://github.com/cerbos/query-plan-adapters/issues/309), [#315](https://github.com/cerbos/query-plan-adapters/issues/315), [#375](https://github.com/cerbos/query-plan-adapters/issues/375)) |
|
|
510
474
|
|
|
511
|
-
###
|
|
475
|
+
### Declaring the application's own predicate
|
|
512
476
|
|
|
513
477
|
```ts
|
|
514
|
-
|
|
478
|
+
queryPlanToPrisma({
|
|
515
479
|
queryPlan,
|
|
516
480
|
mapper: {
|
|
517
|
-
"request.resource.attr.status": { field: "status" },
|
|
518
|
-
"request.resource.attr.owner": {
|
|
519
|
-
relation: {
|
|
520
|
-
name: "owner",
|
|
521
|
-
type: "one",
|
|
522
|
-
},
|
|
523
|
-
},
|
|
524
481
|
"request.resource.attr.tags": {
|
|
525
482
|
relation: {
|
|
526
483
|
name: "tags",
|
|
527
484
|
type: "many",
|
|
528
485
|
field: "name",
|
|
486
|
+
// Exactly the predicate your own reads of `Tag` apply.
|
|
487
|
+
subqueryFilter: { deletedAt: null, kind: "label" },
|
|
529
488
|
},
|
|
530
489
|
},
|
|
531
490
|
},
|
|
532
491
|
});
|
|
533
|
-
|
|
534
|
-
// Results in Prisma filters like:
|
|
535
|
-
const result = await primsa.resource.findMany({
|
|
536
|
-
where: {
|
|
537
|
-
AND: [
|
|
538
|
-
{ status: { equals: "active" } },
|
|
539
|
-
{ owner: { is: { id: { equals: "user1" } } } },
|
|
540
|
-
{ tags: { some: { name: { in: ["tag1", "tag2"] } } } },
|
|
541
|
-
];
|
|
542
|
-
}
|
|
543
|
-
})
|
|
544
|
-
```
|
|
545
|
-
|
|
546
|
-
### Complex Examples
|
|
547
|
-
|
|
548
|
-
#### Lambda Expression Examples
|
|
549
|
-
|
|
550
|
-
```ts
|
|
551
|
-
// Using exists with lambda expressions
|
|
552
|
-
const result = queryPlanToPrisma({
|
|
553
|
-
queryPlan,
|
|
554
|
-
mapper: {
|
|
555
|
-
"request.resource.attr.comments": {
|
|
556
|
-
relation: {
|
|
557
|
-
name: "comments",
|
|
558
|
-
type: "many",
|
|
559
|
-
fields: {
|
|
560
|
-
author: {
|
|
561
|
-
relation: {
|
|
562
|
-
name: "author",
|
|
563
|
-
type: "one",
|
|
564
|
-
},
|
|
565
|
-
},
|
|
566
|
-
status: { field: "status" },
|
|
567
|
-
},
|
|
568
|
-
},
|
|
569
|
-
},
|
|
570
|
-
},
|
|
571
|
-
});
|
|
572
|
-
|
|
573
|
-
// This can handle complex exists queries like:
|
|
574
|
-
// "Does the resource have any approved comments by specific users?"
|
|
575
|
-
const result = await primsa.resource.findMany({
|
|
576
|
-
where: {
|
|
577
|
-
comments: {
|
|
578
|
-
some: {
|
|
579
|
-
AND: [
|
|
580
|
-
{ status: { equals: "approved" } },
|
|
581
|
-
{
|
|
582
|
-
author: {
|
|
583
|
-
is: {
|
|
584
|
-
id: { in: ["user1", "user2"] },
|
|
585
|
-
},
|
|
586
|
-
},
|
|
587
|
-
},
|
|
588
|
-
],
|
|
589
|
-
},
|
|
590
|
-
},
|
|
591
|
-
},
|
|
592
|
-
});
|
|
593
|
-
```
|
|
594
|
-
|
|
595
|
-
## Development
|
|
596
|
-
|
|
597
|
-
### Running Tests
|
|
598
|
-
|
|
599
|
-
```bash
|
|
600
|
-
npm test
|
|
601
|
-
```
|
|
602
|
-
|
|
603
|
-
This is the **translator unit test**: for every action in the shared conformance corpus, the `where` input this adapter emits. It reads its plans from `conformance/wire-fixtures/` — the golden `PlanResources` responses captured against the pinned Cerbos version — so it needs no Cerbos sidecar, no database, and no generated Prisma client, and it is engine-agnostic (there is no v6/v7 split). It also pins the shapes the adapter refuses, with the message `conformance/actions.json` records, and the parts of the mapper contract no policy can reach: the `nullAttributeRepresentation` boundary, `subqueryFilter`, and malformed input.
|
|
604
|
-
|
|
605
|
-
Every wire fixture must be classified there exactly once, so adding a corpus action fails this suite until someone records the filter it produces. See [ADR 0006](../docs/adr/0006-translator-unit-tests-take-their-plans-from-wire-fixtures.md).
|
|
606
|
-
|
|
607
|
-
Whether those filters return the rows the PDP allows is a separate question, answered by the adversarial suite — see [Conformance contract](#conformance-contract) above, which lists the four runs and what each one covers. That suite does need a Cerbos sidecar, and resets `prisma/dev-adversarial.db` with `prisma db push --force-reset`, so run it only against disposable development databases.
|
|
608
|
-
|
|
609
|
-
## Types
|
|
610
|
-
|
|
611
|
-
### Query Plan Response Types
|
|
612
|
-
|
|
613
|
-
The adapter is fully typed and provides clear type definitions for all responses:
|
|
614
|
-
|
|
615
|
-
```ts
|
|
616
|
-
import { PlanKind, QueryPlanToPrismaResult } from "@cerbos/orm-prisma";
|
|
617
|
-
|
|
618
|
-
// The result will be one of these types:
|
|
619
|
-
type QueryPlanToPrismaResult =
|
|
620
|
-
| {
|
|
621
|
-
kind: PlanKind.ALWAYS_ALLOWED | PlanKind.ALWAYS_DENIED;
|
|
622
|
-
}
|
|
623
|
-
| {
|
|
624
|
-
kind: PlanKind.CONDITIONAL;
|
|
625
|
-
filters: Record<string, any>;
|
|
626
|
-
};
|
|
627
|
-
|
|
628
|
-
// Example usage with type narrowing:
|
|
629
|
-
const result = queryPlanToPrisma({ queryPlan });
|
|
630
|
-
|
|
631
|
-
if (result.kind === PlanKind.CONDITIONAL) {
|
|
632
|
-
// TypeScript knows `filters` exists here
|
|
633
|
-
const records = await prisma.resource.findMany({
|
|
634
|
-
where: result.filters,
|
|
635
|
-
});
|
|
636
|
-
} else if (result.kind === PlanKind.ALWAYS_ALLOWED) {
|
|
637
|
-
// No filters needed
|
|
638
|
-
const records = await prisma.resource.findMany();
|
|
639
|
-
} else {
|
|
640
|
-
// Must be ALWAYS_DENIED
|
|
641
|
-
return [];
|
|
642
|
-
}
|
|
643
|
-
```
|
|
644
|
-
|
|
645
|
-
### Mapper Types
|
|
646
|
-
|
|
647
|
-
The mapper configuration is also fully typed:
|
|
648
|
-
|
|
649
|
-
```ts
|
|
650
|
-
type MapperConfig = {
|
|
651
|
-
field?: string;
|
|
652
|
-
valueType?: "dateTime";
|
|
653
|
-
nullable?: boolean;
|
|
654
|
-
relation?: {
|
|
655
|
-
name: string;
|
|
656
|
-
type: "one" | "many";
|
|
657
|
-
model?: string;
|
|
658
|
-
field?: string;
|
|
659
|
-
fields?: {
|
|
660
|
-
[key: string]: MapperConfig; // Recursive for nested fields
|
|
661
|
-
};
|
|
662
|
-
};
|
|
663
|
-
};
|
|
664
|
-
|
|
665
|
-
type Mapper = { [key: string]: MapperConfig } | ((key: string) => MapperConfig);
|
|
666
492
|
```
|
|
667
493
|
|
|
668
|
-
|
|
669
|
-
|
|
670
|
-
|
|
671
|
-
|
|
672
|
-
|
|
673
|
-
|
|
674
|
-
|
|
494
|
+
`subqueryFilter` is a where-input over the related model, ANDed into the nested filter. It narrows
|
|
495
|
+
the records the subquery *examines*, for every operator reached through the relation (`exists`,
|
|
496
|
+
`all`, `except`, membership, `hasIntersection`, emptiness) and for the hop-existence guard.
|
|
497
|
+
`all()` is rewritten from `every: AND(declared, P)` to `none: AND(declared, NOT P)`, so hidden
|
|
498
|
+
records are ignored rather than required. If the declaration hides every record, `all()` is
|
|
499
|
+
vacuously true, matching the empty list your application would send to `check()`.
|
|
500
|
+
|
|
501
|
+
## Behaviour changes
|
|
502
|
+
|
|
503
|
+
- **Breaking:** a field declared with a `valueType` other than `"boolean"` read where CEL needs a boolean (`!R.attr.aString`) throws `UnsupportedQueryPlanError`: it is CEL's type error under both polarities, which no Prisma filter keeps out. Prisma rejected the filter before.
|
|
504
|
+
- Whether `matches()` raises an error on a pattern (which denies every row) is now decided by
|
|
505
|
+
re2js, a port of RE2, where a hand-written check read `\1`–`\9` as a backreference. RE2 reads
|
|
506
|
+
`\1`–`\7` followed by another octal digit as an octal escape (`\11` is a tab), so such a
|
|
507
|
+
pattern used to deny every row and now matches the character it names. `re2js` is a new
|
|
508
|
+
dependency.
|
|
509
|
+
- `startsWith` needles and hierarchy prefixes containing `%`, `_` or `\` no longer throw: they are
|
|
510
|
+
matched as a range of the column (`gte` the prefix, `lt` the first string past it), exact under
|
|
511
|
+
the code-point collation the adapter already requires. `R.attr.a in [R.attr.b]` compiles to the
|
|
512
|
+
field-to-field equality, `string(<boolean expression>) == "true"` to the expression,
|
|
513
|
+
`string(int(x)) == "1000000"` to `int(x) == 1000000`, `"key" in R.attr.parent` and
|
|
514
|
+
`R.attr.parent.exists(k, k == "key")` to `has(R.attr.parent.key)`, and an ordering between a
|
|
515
|
+
string column and an arithmetic result settles as CEL's no-overload error.
|
|
516
|
+
- **Breaking:** `nullAttributeRepresentation` defaults to `"omitted"`, where it defaulted to
|
|
517
|
+
`"explicit"`. A caller that sends explicit nulls and set nothing now gets a refusal on most null
|
|
518
|
+
comparisons, and `x == null` selects no row where it rendered `IS NULL`; pass
|
|
519
|
+
`nullAttributeRepresentation: "explicit"` to keep the old output
|
|
520
|
+
([#499](https://github.com/cerbos/query-plan-adapters/issues/499),
|
|
521
|
+
[ADR 0011](../docs/adr/0011-denial-fails-closed-in-the-result.md)).
|
|
522
|
+
- **Fix ([#597](https://github.com/cerbos/query-plan-adapters/issues/597)):** `matches()` reads a
|
|
523
|
+
repetition count as RE2 does. A count with a leading zero opens no repetition, so `^one{01}$` is
|
|
524
|
+
the literal `one{01}`; it used to be read as `{1}` and returned the row `"one"`. Nested counts
|
|
525
|
+
whose copies multiply past 1000 (`(a{2}){600}`) are a pattern RE2 rejects, an error on every row;
|
|
526
|
+
the pattern used to be expanded into one long literal, so its negation returned every row
|
|
527
|
+
(over-grant fixes). An unbounded count (`a{2,}`) is no longer mistaken for one past 1000.
|
|
528
|
+
- **Fix:** a string function (`startsWith`, `endsWith`, `contains`) over the element
|
|
529
|
+
of a projected list (`R.attr.tagNames.all(t, t.startsWith("p"))`) is an error on a null element,
|
|
530
|
+
and `all()` and a negated `exists()` now deny the row holding one; they used to admit it.
|
|
531
|
+
- **Fix:** `!hasIntersection(R.attr.tags.map(t, t.name), [...])` denies a row holding an element
|
|
532
|
+
whose projected column is NULL: building the mapped list is an error, which CEL denies under
|
|
533
|
+
the negation too. The null-projection guard used to be ANDed onto the positive filter, so the
|
|
534
|
+
negation turned the error into a match and admitted those rows.
|
|
535
|
+
- **Fix, breaking:** a two-variable comprehension (`R.attr.list.exists(i, v, ...)`) throws
|
|
536
|
+
`UnsupportedQueryPlanError`. It used to drop the second variable and emit a filter over the
|
|
537
|
+
first, which returned the wrong rows.
|
|
538
|
+
- **Fix:** a bare `dateTime` column ordered against a timestamp (`R.attr.createdAt < now()`) settles
|
|
539
|
+
as the no-overload error CEL raises on the RFC 3339 string; it used to be compared as a timestamp,
|
|
540
|
+
admitting rows the PDP denies.
|
|
541
|
+
- New translations: `m["key"]` member reads, lookups into a map literal, `upperAscii() == "LIT"`,
|
|
542
|
+
`timeSince()` and `timestamp ± duration` comparisons, `isSubset`, the emptiness of the
|
|
543
|
+
`except`/`intersect` list functions, membership in `map()`/`filter()` results, a ternary inside
|
|
544
|
+
a list concatenation or a membership list, `exists`/`all` over a list built from columns, and a
|
|
545
|
+
concatenation of more than two string terms against a literal.
|
|
546
|
+
- An ordering of a boolean column against a boolean constant (`R.attr.flag < true`) translates:
|
|
547
|
+
CEL orders `false < true`, and Prisma's Boolean filter has only `equals`, so it becomes the bools
|
|
548
|
+
that satisfy it (`< true` is `equals: false`). It used to emit `lt` on a Boolean field, which the
|
|
549
|
+
Prisma client rejected with a validation error.
|
|
550
|
+
- A macro (`exists`, `all`, `exists_one`, `filter`, `map`) over a to-one relation throws
|
|
551
|
+
`UnsupportedQueryPlanError`: CEL ranges it over the related row's attribute names. It used to
|
|
552
|
+
emit `some` on a to-one relation, which the Prisma client rejected with a validation error.
|
|
553
|
+
|
|
554
|
+
- A comparison against NaN settles (`==` and orderings false, `!=` true, as the current PDP
|
|
555
|
+
evaluates them even against a string), and a comparison over `c ? a : b` with `c` a boolean
|
|
556
|
+
column that is never missing distributes over the branches when one of them then settles.
|
|
557
|
+
- `hierarchy(x, "")` translates: Cerbos splits on an empty delimiter per code point, so a
|
|
558
|
+
descendant of `C` is `startsWith(C + "_")`, and the empty path is an ancestor of every other.
|
|
559
|
+
- `matches()` translates for the RE2 patterns a combination of LIKE filters decides exactly (see
|
|
560
|
+
[Supported operators](#supported-operators)); `b == true` over a boolean expression is `b`.
|
|
561
|
+
- Arithmetic against a constant is solved exactly over IEEE-754 doubles: `x + c CMP v` (and `-`,
|
|
562
|
+
`*`, `/` by a constant) becomes the range of doubles `x` for which the rounded result compares,
|
|
563
|
+
found by bisecting the doubles in order, where it used to compute `v - c` — inexact for a
|
|
564
|
+
fraction, and refused for `==`/`!=`. `x + 1 == 3` now also admits `1.9999999999999998`, which CEL
|
|
565
|
+
rounds to 3. A comparison whose one column appears only as `x / x` or `x / ±0` splits on the sign
|
|
566
|
+
of `x` (those divisions are 1, ±Inf or NaN, never an error, on doubles).
|
|
567
|
+
- `all()` over a multi-hop chain translates (the hops must exist, and the predicate holds at the
|
|
568
|
+
end of every path). **Fix:** a negated `all()` over a chain put its negation outside the inner
|
|
569
|
+
hops (`some { NOT { some P } }`), which admitted a hop with no elements — where `all()` is
|
|
570
|
+
vacuously true — and missed a hop whose elements only partly matched; the false witness is now
|
|
571
|
+
an element at the end of the chain.
|
|
572
|
+
- A field-to-field `==`/`!=` across mixed null conventions translates instead of throwing, and
|
|
573
|
+
`a + b == "lit"` over two string columns is one arm per split of the literal.
|
|
574
|
+
- Macros over a literal list (a folded principal attribute) are expanded before translation:
|
|
575
|
+
`exists`/`all` over a `list(...)` of structs as well as of values, and `exists_one`,
|
|
576
|
+
`size(filter(...))` and `size(except(list, [x]))` whose body is `t == x` or `t != x`, which count
|
|
577
|
+
the multiplicity of `x` and become membership. `size(except(R.attr.list, literals))` against an
|
|
578
|
+
emptiness threshold becomes `exists`/`all` over the relation.
|
|
579
|
+
- Casts that can be inverted are solved for the column rather than refused: `string(aBool) ==
|
|
580
|
+
"true"` is `aBool == true`, `string(aDouble) == "-0.6"` is `aDouble == -0.6` (and false when no
|
|
581
|
+
double prints as the literal, as cel-go's `%g` decides), `int(aDouble) == 0` is
|
|
582
|
+
`-1 < aDouble < 1`, and `string()`/`double()` of a column of that type is the column, with a NULL
|
|
583
|
+
kept an error.
|
|
584
|
+
- A timestamp literal with digits past the millisecond — what the planner folds `now()` into — is
|
|
585
|
+
no longer refused when compared against a column: it is compared against the next millisecond
|
|
586
|
+
(see [Timestamps](#timestamps)).
|
|
587
|
+
- A list-valued `filter()`, `map()` or `except()` where a boolean is required is a CEL error, and
|
|
588
|
+
settles as one: a whole condition that is one returns `ALWAYS_DENIED` rather than throwing. Under
|
|
589
|
+
`nullAttributeRepresentation: "omitted"`, `x == null` and `x != null` over a typed column
|
|
590
|
+
translate to presence tests instead of being refused. A column reached through to-one relations
|
|
591
|
+
(`parent.x != null`) now translates too, by the comparison's polarity (see
|
|
592
|
+
[NULL attribute representation](#null-attribute-representation)), where it used to be refused
|
|
593
|
+
([#551](https://github.com/cerbos/query-plan-adapters/issues/551)).
|
|
594
|
+
- `size()` of a `valueType: "string"` column translates for any threshold, as `LIKE` patterns of
|
|
595
|
+
`_` (`size(x) > 4` is `startsWith: "_____"`). A threshold of 2^32 or more, which no store can
|
|
596
|
+
hold, needs no pattern; one past 1024 characters short of that is refused. Fractional and
|
|
597
|
+
negative thresholds are normalised to the integer count they mean for relations too, and
|
|
598
|
+
`size(chain) >= 0` on a multi-hop chain is "the chain exists".
|
|
599
|
+
- A comparison whose outcome the declared `valueType` settles is no longer refused. CEL's
|
|
600
|
+
heterogeneous equality answers `==` false and `!=` true across types, so `aNumber == "5"`,
|
|
601
|
+
`aString == {"a": 1}` and `"2" in aNumberList` settle, and an `in`/`hasIntersection`
|
|
602
|
+
list drops the literals that can never match (`aNumber in ["5", 2]` is `aNumber in [2]`). A
|
|
603
|
+
no-overload call (`aNumber.contains("2")`, `size(aBool)`, `hierarchy(aNumber)`) is a CEL error,
|
|
604
|
+
which is settled only where the enclosing `!`, `&&`, `||`, `exists` and `all` fix how an error
|
|
605
|
+
decides the row. A column is trusted never to be missing only when its mapping says
|
|
606
|
+
`nullable: false` (or it is an explicit-null attribute); otherwise the settled comparison is a
|
|
607
|
+
presence test on it (`startsWith(x, "")`, `x > 0 || x <= 0`, `x || !x`), TRUE when the value is
|
|
608
|
+
there and UNKNOWN on NULL, so a missing attribute stays denied under a negation. A column reached
|
|
609
|
+
through `relation.fields` and a projected list element now carry the `valueType` declared for
|
|
610
|
+
them. A conditional plan that folds to `false` returns `ALWAYS_DENIED` instead of
|
|
611
|
+
throwing. This supersedes the `in value type does not match mapped <type> field` refusal below
|
|
612
|
+
for scalar literals.
|
|
613
|
+
- A shape the adapter refuses now throws `UnsupportedQueryPlanError`, an exported subclass of
|
|
614
|
+
`Error`. What it translates is unchanged, and existing `catch` blocks keep working; mapper
|
|
615
|
+
misconfiguration stays a plain `Error`.
|
|
616
|
+
- **Breaking:** `hasIntersection` over a relation-backed list checks its literals against the
|
|
617
|
+
`valueType` declared on the list's mapper entry, as `in` already did, and throws `in value type
|
|
618
|
+
does not match mapped <type> field` on a mismatch. It used to drop the declared type and hand the
|
|
619
|
+
literal to Prisma.
|
|
620
|
+
- **Breaking ([#495](https://github.com/cerbos/query-plan-adapters/issues/495)):** relation element
|
|
621
|
+
columns are now guarded as nullable unless declared `nullable: false` (previously opt-in with
|
|
622
|
+
`nullable: true`, which over-granted on NULL elements). Nullable columns left undeclared now return
|
|
623
|
+
fewer rows; required columns left undeclared need `nullable: false`. `nullable: true` mappings
|
|
624
|
+
are unchanged. Declarations now reach chained collections via nested `relation.fields`.
|
|
625
|
+
- **Breaking ([#495](https://github.com/cerbos/query-plan-adapters/issues/495)):** an empty
|
|
626
|
+
`and`/`or` (previously `{ AND: [] }` / `{ OR: [] }`, or folded to an unconditional filter) and a
|
|
627
|
+
negated sub-condition that translates to `{}` now throw. Two constants related by a hierarchy
|
|
628
|
+
operator, or an `overlaps` whose segments are all `nullable: false`, produce `{}`.
|
|
629
|
+
- **Breaking ([#414](https://github.com/cerbos/query-plan-adapters/issues/414)):** non-scalar
|
|
630
|
+
comparison/membership literals and bare comparisons between mapped `DateTime` columns now throw
|
|
631
|
+
(use `timestamp()` on both operands). Negated ternary comparisons and unsolvable string
|
|
632
|
+
concatenation now keep CEL's error behaviour.
|
|
633
|
+
- **Breaking:** `contains`/`startsWith`/`endsWith` needles and hierarchy prefixes containing a
|
|
634
|
+
backslash now throw. A backslash is a `LIKE` escape on PostgreSQL and MySQL and literal on SQLite,
|
|
635
|
+
so `contains("a\\b")` matched `"ab"` on PostgreSQL and `endsWith("\\")` failed with
|
|
636
|
+
`SQLSTATE 22025`.
|
|
637
|
+
- **Breaking:** a hierarchy with an empty delimiter (`hierarchy(R.attr.scope, "")`) now throws. It
|
|
638
|
+
was lowered as a `startsWith` that also matched the path itself (corpus case
|
|
639
|
+
`hierarchy/descendent-of/empty-delimiter` over-granted).
|
|
640
|
+
- **Breaking (Cerbos 0.55 compatibility):** ordered comparisons involving NaN evaluate to false, so
|
|
641
|
+
their negation can allow a row; Cerbos 0.54 denied it. Use Cerbos 0.55 when a policy can negate a
|
|
642
|
+
NaN comparison. Missing attributes and nulls are unchanged.
|
|
643
|
+
- **[#375](https://github.com/cerbos/query-plan-adapters/issues/375):** a negation over a single
|
|
644
|
+
to-one hop (`!R.attr.parent.aBool` with `type: "one"`) no longer returns rows whose relation is
|
|
645
|
+
absent. It emits `AND: [{ parent: { is: {} } }, { NOT: … }]` and returns fewer rows (over-grant
|
|
646
|
+
fix).
|
|
647
|
+
- **Fix:** a negated `&&`/`||` that reads a single to-one hop is pushed down with De Morgan, so each
|
|
648
|
+
operand requires only the hops it reads. `!(!aBool && parent.aString == "one")` used to require
|
|
649
|
+
the parent around the whole negation and denied a parentless row with `aBool` true, which CEL
|
|
650
|
+
allows because `!aBool` is false and decides the `&&` (under-grant fix).
|
|
651
|
+
- **Breaking:** a `matches()` pattern opening with a repetition count (`{2}a`, or one right after
|
|
652
|
+
`(` or `|`) is a pattern RE2 rejects, and settles as a CEL error on every row. It used to be read
|
|
653
|
+
as a literal brace, so its negation matched every row (over-grant fix).
|
|
654
|
+
- Projection relations resolve the scalar lambda variable to the mapped column. Negated equality in
|
|
655
|
+
`tagNames.exists(name, !(name == "public"))` now includes null list elements, as CEL does.
|
|
656
|
+
Function mappers may call `queryPlanToPrisma` recursively without clobbering the outer call's
|
|
657
|
+
model, null convention or collection scope.
|
|
658
|
+
|
|
659
|
+
## Example application
|
|
660
|
+
|
|
661
|
+
[`example/`](example/) installs the packed adapter and runs it against a live PDP over the shared
|
|
662
|
+
[demo domain](../demo/README.md), including pagination and composition with an application filter:
|
|
675
663
|
|
|
676
664
|
```bash
|
|
677
665
|
# from the repository root
|
|
678
666
|
demo/scripts/run-example.sh prisma
|
|
679
667
|
```
|
|
680
668
|
|
|
681
|
-
|
|
682
|
-
map, `types`, the `files` allowlist, and the peer range — and covers usage shapes past a single
|
|
683
|
-
flat query: pagination, and the adapter's filter composed with an application-owned filter.
|
|
669
|
+
A fuller app: [cerbos/express-prisma-cerbos](https://github.com/cerbos/express-prisma-cerbos).
|
|
684
670
|
|
|
685
|
-
##
|
|
686
|
-
|
|
687
|
-
### Documentation
|
|
688
|
-
|
|
689
|
-
- [Cerbos Documentation](https://docs.cerbos.dev)
|
|
690
|
-
- [Prisma Documentation](https://www.prisma.io/docs)
|
|
691
|
-
- [Query Plan API Reference](https://docs.cerbos.dev/cerbos/latest/api/index.html#resources-query-plan)
|
|
692
|
-
|
|
693
|
-
### Examples and Tutorials
|
|
694
|
-
|
|
695
|
-
- [Express + Prisma + Cerbos Example](https://github.com/cerbos/express-prisma-cerbos)
|
|
696
|
-
- [Cerbos Query Planning Guide](https://docs.cerbos.dev/cerbos/latest/policies/compile.html)
|
|
697
|
-
- [Prisma Filtering Guide](https://www.prisma.io/docs/concepts/components/prisma-client/filtering-and-sorting)
|
|
671
|
+
## Development
|
|
698
672
|
|
|
699
|
-
|
|
673
|
+
Edit `src/`. `npm run build` (`tsc --build`) emits the published surface to `lib/`, which is
|
|
674
|
+
gitignored and leaves the tests out; `npm run typecheck` is the check that covers `src/` and every
|
|
675
|
+
`*.test.ts`.
|
|
700
676
|
|
|
701
|
-
|
|
677
|
+
| Command | What it runs | Needs |
|
|
678
|
+
| --- | --- | --- |
|
|
679
|
+
| `npm test` | Offline unit tests: caller-supplied options the corpus cannot vary (mapper forms, `subqueryFilter`, element nullability, `nullAttributeRepresentation`), the refusal type, the timestamp literal contract and malformed input | Nothing |
|
|
680
|
+
| `npm run typecheck` | `tsc` against Prisma 7 and 6 | Nothing |
|
|
681
|
+
| `npm run test:adversarial:v7` / `:v6` | Conformance harness on SQLite, Prisma 7 / 6 | Nothing |
|
|
682
|
+
| `npm run test:adversarial:postgres:v7` / `:v6` | Conformance harness on PostgreSQL | Docker |
|
|
683
|
+
| `npm run test:adversarial:mysql:v7` / `:v6` | Conformance harness on MySQL | Docker |
|
|
684
|
+
|
|
685
|
+
`npm run test:adversarial`, `…:postgres` and `…:mysql` alias the v7 leg. CI runs all six
|
|
686
|
+
store/Prisma combinations. The SQLite legs delete `prisma/dev-adversarial.db` and push the schema
|
|
687
|
+
into a fresh file, so they need no `--force-reset` (which Prisma refuses to run under an AI agent).
|
|
688
|
+
|
|
689
|
+
No suite starts a PDP. The harness reads the golden files under `../conformance/golden/` for both
|
|
690
|
+
pinned PDPs and applies [`conformance-ledger.json`](conformance-ledger.json): a case with no entry
|
|
691
|
+
must return exactly the recorded allowed ids, and an `unsupported` case must throw
|
|
692
|
+
`UnsupportedQueryPlanError`. See "The harness contract" in
|
|
693
|
+
[conformance/README.md](../conformance/README.md). `npm test` reads its plans from the same golden
|
|
694
|
+
files but pins no corpus case's filter; it needs no database or generated client, and has no v6/v7
|
|
695
|
+
split.
|
|
702
696
|
|
|
703
|
-
|
|
697
|
+
## Resources
|
|
704
698
|
|
|
705
|
-
- [Cerbos
|
|
706
|
-
- [
|
|
699
|
+
- [Cerbos documentation](https://docs.cerbos.dev) and the [query plan API](https://docs.cerbos.dev/cerbos/latest/api/index.html#resources-query-plan)
|
|
700
|
+
- [Prisma filtering guide](https://www.prisma.io/docs/concepts/components/prisma-client/filtering-and-sorting)
|
|
701
|
+
- [Cerbos Slack](https://community.cerbos.dev) · [GitHub Discussions](https://github.com/cerbos/cerbos/discussions)
|
|
707
702
|
|
|
708
703
|
## License
|
|
709
704
|
|