@cerbos/orm-prisma 3.2.1 → 4.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 +91 -3
- package/lib/index.d.ts +29 -2
- package/lib/index.d.ts.map +1 -1
- package/lib/index.js +1792 -150
- package/lib/index.js.map +1 -1
- package/package.json +24 -11
- package/src/adversarial.test.ts +586 -0
- package/src/generated/prisma/client.ts +3 -1
- package/src/generated/prisma/commonInputTypes.ts +26 -0
- package/src/generated/prisma/internal/class.ts +22 -10
- package/src/generated/prisma/internal/prismaNamespace.ts +89 -19
- package/src/generated/prisma/internal/prismaNamespaceBrowser.ts +2 -1
- package/src/generated/prisma/models/Category.ts +6 -1
- package/src/generated/prisma/models/Label.ts +6 -1
- package/src/generated/prisma/models/NestedResource.ts +6 -1
- package/src/generated/prisma/models/NextLevelNestedResource.ts +6 -1
- package/src/generated/prisma/models/Resource.ts +67 -2
- package/src/generated/prisma/models/SubCategory.ts +6 -1
- package/src/generated/prisma/models/Tag.ts +6 -1
- package/src/generated/prisma/models/User.ts +6 -1
- package/src/generated/prisma-adversarial/browser.ts +44 -0
- package/src/generated/prisma-adversarial/client.ts +66 -0
- package/src/generated/prisma-adversarial/commonInputTypes.ts +352 -0
- package/src/generated/prisma-adversarial/enums.ts +15 -0
- package/src/generated/prisma-adversarial/internal/class.ts +244 -0
- package/src/generated/prisma-adversarial/internal/prismaNamespace.ts +1156 -0
- package/src/generated/prisma-adversarial/internal/prismaNamespaceBrowser.ts +140 -0
- package/src/generated/prisma-adversarial/models/AdversarialCategory.ts +1401 -0
- package/src/generated/prisma-adversarial/models/AdversarialLabel.ts +1272 -0
- package/src/generated/prisma-adversarial/models/AdversarialResource.ts +1665 -0
- package/src/generated/prisma-adversarial/models/AdversarialSubCategory.ts +1401 -0
- package/src/generated/prisma-adversarial/models/AdversarialTag.ts +1307 -0
- package/src/generated/prisma-adversarial/models.ts +16 -0
- package/src/generated/prisma-adversarial-v6/client.d.ts +1 -0
- package/src/generated/prisma-adversarial-v6/default.d.ts +1 -0
- package/src/generated/prisma-adversarial-v6/edge.d.ts +1 -0
- package/src/generated/prisma-adversarial-v6/index.d.ts +8640 -0
- package/src/generated/prisma-adversarial-v6/runtime/index-browser.d.ts +370 -0
- package/src/generated/prisma-adversarial-v6/runtime/library.d.ts +3982 -0
- package/src/generated/prisma-adversarial-v6/wasm.d.ts +1 -0
- package/src/generated/prisma-v6/index.d.ts +90 -3
- package/src/index.test.ts +3611 -241
- package/src/index.ts +2544 -174
- package/src/test-setup.adversarial.v6.ts +5 -0
- package/src/test-setup.adversarial.v7.ts +12 -0
|
@@ -0,0 +1,586 @@
|
|
|
1
|
+
import * as fs from "fs";
|
|
2
|
+
import * as path from "path";
|
|
3
|
+
|
|
4
|
+
import { GRPC as Cerbos } from "@cerbos/grpc";
|
|
5
|
+
import type { Principal, Resource, Value } from "@cerbos/core";
|
|
6
|
+
|
|
7
|
+
import { queryPlanToPrisma, PlanKind, MapperConfig } from ".";
|
|
8
|
+
import { prisma } from "./test-setup.adversarial";
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* Adversarial differential suite: every action in the shared `../conformance/` corpus is planned
|
|
12
|
+
* against a REAL Cerbos PDP (the sidecar started by `npm run test:adversarial`, loaded with
|
|
13
|
+
* `conformance/policies/adversarial.yaml`), translated by this adapter, and executed against
|
|
14
|
+
* seeded rows — then the filtered id set is compared against an oracle computed by calling the
|
|
15
|
+
* check API for each row with attributes mirroring that row exactly.
|
|
16
|
+
*
|
|
17
|
+
* No hand-computed expectations: if this adapter's filter semantics diverge from Cerbos's own
|
|
18
|
+
* evaluation for any row, the mismatch surfaces mechanically. See `conformance/README.md` for the
|
|
19
|
+
* oracle recipe (NULL-as-missing-attribute, the degeneracy guard) — this file only owns the
|
|
20
|
+
* Prisma-specific translation (seeding, field mapping, executing the query).
|
|
21
|
+
*/
|
|
22
|
+
|
|
23
|
+
const cerbos = new Cerbos("127.0.0.1:3593", { tls: false });
|
|
24
|
+
|
|
25
|
+
const CONFORMANCE_DIR = path.join(__dirname, "..", "..", "conformance");
|
|
26
|
+
|
|
27
|
+
interface Tag {
|
|
28
|
+
id: string;
|
|
29
|
+
name: string | null;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
interface Seed {
|
|
33
|
+
id: string;
|
|
34
|
+
aBool: boolean;
|
|
35
|
+
aString: string;
|
|
36
|
+
aNumber: number;
|
|
37
|
+
aOptionalString: string | null;
|
|
38
|
+
tags: Tag[];
|
|
39
|
+
subCategoryNames: string[];
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
interface SeedsFile {
|
|
43
|
+
principal: Principal;
|
|
44
|
+
resourceKind: string;
|
|
45
|
+
seeds: Seed[];
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
interface UnsupportedShape {
|
|
49
|
+
action: string;
|
|
50
|
+
shape: string;
|
|
51
|
+
springDataMessage: string;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
interface AdapterUnsupportedEntry {
|
|
55
|
+
action: string;
|
|
56
|
+
reason: string;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
interface KnownDivergence {
|
|
60
|
+
action: string;
|
|
61
|
+
adapters: string[];
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
interface ActionsFile {
|
|
65
|
+
conformance: string[];
|
|
66
|
+
adapterUnsupported?: Record<string, AdapterUnsupportedEntry[]>;
|
|
67
|
+
adapterSupportedExpected?: Record<string, AdapterUnsupportedEntry[]>;
|
|
68
|
+
expectedUnsupported: UnsupportedShape[];
|
|
69
|
+
knownDivergences?: KnownDivergence[];
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
type ThrowingAction = readonly [action: string, reason: string];
|
|
73
|
+
|
|
74
|
+
interface ActionClassification {
|
|
75
|
+
oracleActions: string[];
|
|
76
|
+
throwingActions: ThrowingAction[];
|
|
77
|
+
supportedExpected: Set<string>;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
function classifyActionsForAdapter(
|
|
81
|
+
manifest: ActionsFile,
|
|
82
|
+
adapter: string
|
|
83
|
+
): ActionClassification {
|
|
84
|
+
const unsupported = manifest.adapterUnsupported?.[adapter] ?? [];
|
|
85
|
+
const unsupportedActions = new Set(unsupported.map((entry) => entry.action));
|
|
86
|
+
const supportedExpected = new Set(
|
|
87
|
+
(manifest.adapterSupportedExpected?.[adapter] ?? []).map(
|
|
88
|
+
(entry) => entry.action
|
|
89
|
+
)
|
|
90
|
+
);
|
|
91
|
+
const oracleActions = [
|
|
92
|
+
...manifest.conformance.filter(
|
|
93
|
+
(action) => !unsupportedActions.has(action)
|
|
94
|
+
),
|
|
95
|
+
...supportedExpected,
|
|
96
|
+
];
|
|
97
|
+
const throwingActions: ThrowingAction[] = [
|
|
98
|
+
...unsupported.map(
|
|
99
|
+
(entry): ThrowingAction => [entry.action, entry.reason]
|
|
100
|
+
),
|
|
101
|
+
...manifest.expectedUnsupported
|
|
102
|
+
.filter((entry) => !supportedExpected.has(entry.action))
|
|
103
|
+
.map((entry): ThrowingAction => [entry.action, entry.shape]),
|
|
104
|
+
];
|
|
105
|
+
|
|
106
|
+
return {
|
|
107
|
+
oracleActions: [...new Set(oracleActions)].sort(),
|
|
108
|
+
throwingActions: throwingActions.sort(([left], [right]) =>
|
|
109
|
+
left.localeCompare(right)
|
|
110
|
+
),
|
|
111
|
+
supportedExpected,
|
|
112
|
+
};
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
const seedsFile: SeedsFile = JSON.parse(
|
|
116
|
+
fs.readFileSync(path.join(CONFORMANCE_DIR, "seeds.json"), "utf8")
|
|
117
|
+
);
|
|
118
|
+
const actionsFile: ActionsFile = JSON.parse(
|
|
119
|
+
fs.readFileSync(path.join(CONFORMANCE_DIR, "actions.json"), "utf8")
|
|
120
|
+
);
|
|
121
|
+
const SEEDS = seedsFile.seeds;
|
|
122
|
+
|
|
123
|
+
const {
|
|
124
|
+
oracleActions: ORACLE_ACTIONS,
|
|
125
|
+
throwingActions: THROWING_ACTIONS,
|
|
126
|
+
supportedExpected: PRISMA_SUPPORTED_EXPECTED,
|
|
127
|
+
} = classifyActionsForAdapter(actionsFile, "prisma");
|
|
128
|
+
const PRISMA_KNOWN_DIVERGENCES = new Set(
|
|
129
|
+
(actionsFile.knownDivergences ?? [])
|
|
130
|
+
.filter((entry) => entry.adapters.includes("prisma"))
|
|
131
|
+
.map((entry) => entry.action)
|
|
132
|
+
);
|
|
133
|
+
const EXPECTED_UNSUPPORTED_ACTIONS = new Set(
|
|
134
|
+
actionsFile.expectedUnsupported.map((entry) => entry.action)
|
|
135
|
+
);
|
|
136
|
+
const MANIFEST_ACTIONS = new Set([
|
|
137
|
+
...actionsFile.conformance,
|
|
138
|
+
...EXPECTED_UNSUPPORTED_ACTIONS,
|
|
139
|
+
...PRISMA_KNOWN_DIVERGENCES,
|
|
140
|
+
]);
|
|
141
|
+
|
|
142
|
+
function doubleFor(seed: Seed): number | null {
|
|
143
|
+
switch (seed.id) {
|
|
144
|
+
case "a1":
|
|
145
|
+
return -0.6;
|
|
146
|
+
case "a2":
|
|
147
|
+
return 0.25;
|
|
148
|
+
case "a3":
|
|
149
|
+
return null;
|
|
150
|
+
default:
|
|
151
|
+
return seed.aNumber + 0.3;
|
|
152
|
+
}
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
function labelsFor(seed: Seed): (string | null)[] {
|
|
156
|
+
switch (seed.id) {
|
|
157
|
+
case "a1":
|
|
158
|
+
return ["gold", "silver"];
|
|
159
|
+
case "a6":
|
|
160
|
+
return [null, "silver"];
|
|
161
|
+
case "a8":
|
|
162
|
+
return ["silver"];
|
|
163
|
+
case "c1":
|
|
164
|
+
return ["Gold"];
|
|
165
|
+
default:
|
|
166
|
+
return [];
|
|
167
|
+
}
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
/** Deterministic ISO instant per seed for the timestamp probe: split around 2025-01-01. */
|
|
171
|
+
function isoFor(seed: Seed): string {
|
|
172
|
+
return seed.aNumber >= 2 ? "2024-06-01T00:00:00Z" : "2026-06-01T00:00:00Z";
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
function timestampFor(seed: Seed): string | null {
|
|
176
|
+
switch (seed.id) {
|
|
177
|
+
case "a1":
|
|
178
|
+
return "2020-03-15T10:30:00Z";
|
|
179
|
+
case "a2":
|
|
180
|
+
return "2037-01-01T00:00:00Z";
|
|
181
|
+
case "a3":
|
|
182
|
+
return null;
|
|
183
|
+
case "a4":
|
|
184
|
+
return "2024-06-01T00:00:00Z";
|
|
185
|
+
case "a5":
|
|
186
|
+
return "2020-03-15T10:30:00.123456Z";
|
|
187
|
+
default:
|
|
188
|
+
return seed.aNumber >= 2
|
|
189
|
+
? "2036-06-06T06:06:06Z"
|
|
190
|
+
: "2021-05-05T05:05:05Z";
|
|
191
|
+
}
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
function scopeFor(seed: Seed): string | null {
|
|
195
|
+
switch (seed.id) {
|
|
196
|
+
case "a1":
|
|
197
|
+
return "dept";
|
|
198
|
+
case "a2":
|
|
199
|
+
return "dept.eng";
|
|
200
|
+
case "a3":
|
|
201
|
+
return "dept.eng.platform";
|
|
202
|
+
case "a4":
|
|
203
|
+
return "dept.eng.platform.obs";
|
|
204
|
+
case "a5":
|
|
205
|
+
return "dept.engineering";
|
|
206
|
+
case "a6":
|
|
207
|
+
return "dept.sales";
|
|
208
|
+
case "a8":
|
|
209
|
+
return "";
|
|
210
|
+
case "a9":
|
|
211
|
+
return "50%";
|
|
212
|
+
case "b1":
|
|
213
|
+
return "50%:a_b:x";
|
|
214
|
+
case "b2":
|
|
215
|
+
return "50x:a_b:y";
|
|
216
|
+
case "b3":
|
|
217
|
+
return "50%:aXb:y";
|
|
218
|
+
case "b4":
|
|
219
|
+
return "50%:a_b";
|
|
220
|
+
case "b5":
|
|
221
|
+
return "dept.eng.platform2";
|
|
222
|
+
case "b6":
|
|
223
|
+
return "50%.a_b";
|
|
224
|
+
case "c1":
|
|
225
|
+
return "Dept.Eng";
|
|
226
|
+
case "c2":
|
|
227
|
+
return "dept.eng.";
|
|
228
|
+
case "d1":
|
|
229
|
+
return "[env]:prod:eu";
|
|
230
|
+
case "d2":
|
|
231
|
+
return "e:prod:eu";
|
|
232
|
+
default:
|
|
233
|
+
return null;
|
|
234
|
+
}
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
const MAPPER: Record<string, MapperConfig> = {
|
|
238
|
+
"request.resource.attr.aBool": { field: "aBool" },
|
|
239
|
+
"request.resource.attr.aString": { field: "aString" },
|
|
240
|
+
"request.resource.attr.aNumber": { field: "aNumber" },
|
|
241
|
+
"request.resource.attr.aDouble": { field: "aDouble" },
|
|
242
|
+
"request.resource.attr.aOptionalString": { field: "aOptionalString" },
|
|
243
|
+
"request.resource.attr.createdBy": { field: "createdBy" },
|
|
244
|
+
"request.resource.attr.scope": { field: "scope" },
|
|
245
|
+
"request.resource.attr.createdAt": {
|
|
246
|
+
field: "createdAt",
|
|
247
|
+
valueType: "dateTime",
|
|
248
|
+
},
|
|
249
|
+
"request.resource.attr.owner": {
|
|
250
|
+
field: "aOptionalString",
|
|
251
|
+
nullable: true,
|
|
252
|
+
},
|
|
253
|
+
// obj.inner is not a real nested column — mirrors aString, same trick the spring-data
|
|
254
|
+
// reference harness uses for the p-struct probe.
|
|
255
|
+
"request.resource.attr.obj.inner": { field: "aString" },
|
|
256
|
+
"request.resource.attr.tags": {
|
|
257
|
+
relation: {
|
|
258
|
+
name: "tags",
|
|
259
|
+
type: "many",
|
|
260
|
+
// Model name enables field-to-field comparisons between tag columns; the nullable
|
|
261
|
+
// flag on `name` enables the adapter's three-valued-logic guards for collection
|
|
262
|
+
// macros over elements whose name column is NULL (a missing attribute — a CEL
|
|
263
|
+
// error, hence deny — on the check side).
|
|
264
|
+
model: "AdversarialTag",
|
|
265
|
+
fields: {
|
|
266
|
+
id: { field: "tagId" },
|
|
267
|
+
name: { field: "name", nullable: true },
|
|
268
|
+
},
|
|
269
|
+
},
|
|
270
|
+
},
|
|
271
|
+
"request.resource.attr.tagNames": {
|
|
272
|
+
relation: {
|
|
273
|
+
name: "tags",
|
|
274
|
+
type: "many",
|
|
275
|
+
field: "name",
|
|
276
|
+
fields: { name: { field: "name", nullable: true } },
|
|
277
|
+
},
|
|
278
|
+
},
|
|
279
|
+
"request.resource.attr.categories": {
|
|
280
|
+
relation: {
|
|
281
|
+
name: "categories",
|
|
282
|
+
type: "many",
|
|
283
|
+
fields: {
|
|
284
|
+
name: { field: "name" },
|
|
285
|
+
subCategories: {
|
|
286
|
+
relation: {
|
|
287
|
+
name: "subCategories",
|
|
288
|
+
type: "many",
|
|
289
|
+
fields: {
|
|
290
|
+
name: { field: "name" },
|
|
291
|
+
labels: {
|
|
292
|
+
relation: {
|
|
293
|
+
name: "labels",
|
|
294
|
+
type: "many",
|
|
295
|
+
fields: {
|
|
296
|
+
name: { field: "name", nullable: true },
|
|
297
|
+
},
|
|
298
|
+
},
|
|
299
|
+
},
|
|
300
|
+
},
|
|
301
|
+
},
|
|
302
|
+
},
|
|
303
|
+
},
|
|
304
|
+
},
|
|
305
|
+
},
|
|
306
|
+
// Multi-hop chain probe (W1): mainCategory mirrors the SAME categories/subCategories relation
|
|
307
|
+
// as a single-object dotted chain on the check side (every seed holds at most one category),
|
|
308
|
+
// pinning that the adapter joins through every intermediate hop, never off the root.
|
|
309
|
+
"request.resource.attr.mainCategory": {
|
|
310
|
+
relation: {
|
|
311
|
+
name: "categories",
|
|
312
|
+
type: "many",
|
|
313
|
+
fields: {
|
|
314
|
+
name: { field: "name" },
|
|
315
|
+
subCategories: {
|
|
316
|
+
relation: {
|
|
317
|
+
name: "subCategories",
|
|
318
|
+
type: "many",
|
|
319
|
+
fields: { name: { field: "name" } },
|
|
320
|
+
},
|
|
321
|
+
},
|
|
322
|
+
// subNames: the same 2-hop chain but with a bare `field`, so plain `in` membership
|
|
323
|
+
// compares the flattened tail's name column directly.
|
|
324
|
+
subNames: {
|
|
325
|
+
relation: {
|
|
326
|
+
name: "subCategories",
|
|
327
|
+
type: "many",
|
|
328
|
+
field: "name",
|
|
329
|
+
},
|
|
330
|
+
},
|
|
331
|
+
},
|
|
332
|
+
},
|
|
333
|
+
},
|
|
334
|
+
};
|
|
335
|
+
|
|
336
|
+
beforeAll(async () => {
|
|
337
|
+
await prisma.adversarialLabel.deleteMany();
|
|
338
|
+
await prisma.adversarialSubCategory.deleteMany();
|
|
339
|
+
await prisma.adversarialCategory.deleteMany();
|
|
340
|
+
await prisma.adversarialTag.deleteMany();
|
|
341
|
+
await prisma.adversarialResource.deleteMany();
|
|
342
|
+
|
|
343
|
+
// Distinct sub-category/category graphs per seed so no rows share relations by accident.
|
|
344
|
+
for (const seed of SEEDS) {
|
|
345
|
+
await prisma.adversarialResource.create({
|
|
346
|
+
data: {
|
|
347
|
+
id: seed.id,
|
|
348
|
+
aBool: seed.aBool,
|
|
349
|
+
aString: seed.aString,
|
|
350
|
+
aNumber: seed.aNumber,
|
|
351
|
+
aDouble: doubleFor(seed),
|
|
352
|
+
aOptionalString: seed.aOptionalString,
|
|
353
|
+
createdBy: isoFor(seed),
|
|
354
|
+
scope: scopeFor(seed),
|
|
355
|
+
createdAt: timestampFor(seed),
|
|
356
|
+
tags: {
|
|
357
|
+
create: seed.tags.map((t) => ({ tagId: t.id, name: t.name })),
|
|
358
|
+
},
|
|
359
|
+
categories: {
|
|
360
|
+
create: seed.subCategoryNames.map((subName) => ({
|
|
361
|
+
name: "business",
|
|
362
|
+
subCategories: {
|
|
363
|
+
create: [
|
|
364
|
+
{
|
|
365
|
+
name: subName,
|
|
366
|
+
labels: {
|
|
367
|
+
create: labelsFor(seed).map((name) => ({ name })),
|
|
368
|
+
},
|
|
369
|
+
},
|
|
370
|
+
],
|
|
371
|
+
},
|
|
372
|
+
})),
|
|
373
|
+
},
|
|
374
|
+
},
|
|
375
|
+
});
|
|
376
|
+
}
|
|
377
|
+
});
|
|
378
|
+
|
|
379
|
+
afterAll(async () => {
|
|
380
|
+
await prisma.$disconnect();
|
|
381
|
+
});
|
|
382
|
+
|
|
383
|
+
function principal(): Principal {
|
|
384
|
+
return seedsFile.principal;
|
|
385
|
+
}
|
|
386
|
+
|
|
387
|
+
/** A NULL tag name in the DB is a missing element attribute on the check side. */
|
|
388
|
+
function asTagAttribute(tag: Tag): Record<string, Value> {
|
|
389
|
+
const attr: Record<string, Value> = { id: tag.id };
|
|
390
|
+
if (tag.name !== null) {
|
|
391
|
+
attr["name"] = tag.name;
|
|
392
|
+
}
|
|
393
|
+
return attr;
|
|
394
|
+
}
|
|
395
|
+
|
|
396
|
+
/** Cerbos attributes mirroring exactly what the seeded DB row holds. */
|
|
397
|
+
function asCheckResource(seed: Seed): Resource {
|
|
398
|
+
const attr: Record<string, Value> = {
|
|
399
|
+
aBool: seed.aBool,
|
|
400
|
+
aString: seed.aString,
|
|
401
|
+
aNumber: seed.aNumber,
|
|
402
|
+
createdBy: isoFor(seed),
|
|
403
|
+
owner: seed.aOptionalString,
|
|
404
|
+
tagNames: seed.tags.map((tag) => tag.name),
|
|
405
|
+
obj: { inner: seed.aString },
|
|
406
|
+
tags: seed.tags.map(asTagAttribute),
|
|
407
|
+
categories: seed.subCategoryNames.map((subName) => ({
|
|
408
|
+
name: "business",
|
|
409
|
+
subCategories: [
|
|
410
|
+
{
|
|
411
|
+
name: subName,
|
|
412
|
+
labels: labelsFor(seed).map((name): Record<string, Value> =>
|
|
413
|
+
name === null ? {} : { name }
|
|
414
|
+
),
|
|
415
|
+
},
|
|
416
|
+
],
|
|
417
|
+
})),
|
|
418
|
+
};
|
|
419
|
+
// A DB NULL is a missing attribute on the check side — conditions touching it must deny
|
|
420
|
+
// (CEL error), matching SQL three-valued logic excluding the row.
|
|
421
|
+
if (seed.aOptionalString !== null) {
|
|
422
|
+
attr["aOptionalString"] = seed.aOptionalString;
|
|
423
|
+
}
|
|
424
|
+
const aDouble = doubleFor(seed);
|
|
425
|
+
if (aDouble !== null) {
|
|
426
|
+
attr["aDouble"] = aDouble;
|
|
427
|
+
}
|
|
428
|
+
const scope = scopeFor(seed);
|
|
429
|
+
if (scope !== null) {
|
|
430
|
+
attr["scope"] = scope;
|
|
431
|
+
}
|
|
432
|
+
const createdAt = timestampFor(seed);
|
|
433
|
+
if (createdAt !== null) {
|
|
434
|
+
attr["createdAt"] = createdAt;
|
|
435
|
+
}
|
|
436
|
+
// mainCategory mirrors the row's single category as ONE nested object (the seeder creates
|
|
437
|
+
// at most one category per seed), so direct dotted-chain CEL expressions evaluate cleanly;
|
|
438
|
+
// rows without a category get NO attribute — a CEL missing-attr error (deny), matching the
|
|
439
|
+
// adapter's empty join chain excluding the row.
|
|
440
|
+
if (seed.subCategoryNames.length > 0) {
|
|
441
|
+
attr["mainCategory"] = {
|
|
442
|
+
name: "business",
|
|
443
|
+
subCategories: seed.subCategoryNames.map((name) => ({ name })),
|
|
444
|
+
subNames: seed.subCategoryNames,
|
|
445
|
+
};
|
|
446
|
+
}
|
|
447
|
+
return { kind: seedsFile.resourceKind, id: seed.id, attr };
|
|
448
|
+
}
|
|
449
|
+
|
|
450
|
+
// -- oracle: ask the PDP itself, row by row --
|
|
451
|
+
|
|
452
|
+
async function oracleAllowedIds(action: string): Promise<string[]> {
|
|
453
|
+
const ids: string[] = [];
|
|
454
|
+
for (const seed of SEEDS) {
|
|
455
|
+
const result = await cerbos.checkResource({
|
|
456
|
+
principal: principal(),
|
|
457
|
+
resource: asCheckResource(seed),
|
|
458
|
+
actions: [action],
|
|
459
|
+
});
|
|
460
|
+
if (result.isAllowed(action)) {
|
|
461
|
+
ids.push(seed.id);
|
|
462
|
+
}
|
|
463
|
+
}
|
|
464
|
+
return ids.sort();
|
|
465
|
+
}
|
|
466
|
+
|
|
467
|
+
// -- adapter execution through the public queryPlanToPrisma path --
|
|
468
|
+
|
|
469
|
+
async function adapterFilteredIds(action: string): Promise<string[]> {
|
|
470
|
+
const queryPlan = await cerbos.planResources({
|
|
471
|
+
principal: principal(),
|
|
472
|
+
resource: { kind: seedsFile.resourceKind },
|
|
473
|
+
action,
|
|
474
|
+
});
|
|
475
|
+
const result = queryPlanToPrisma({
|
|
476
|
+
queryPlan,
|
|
477
|
+
mapper: MAPPER,
|
|
478
|
+
model: "AdversarialResource",
|
|
479
|
+
});
|
|
480
|
+
if (result.kind === PlanKind.ALWAYS_DENIED) {
|
|
481
|
+
return [];
|
|
482
|
+
}
|
|
483
|
+
const where = result.kind === PlanKind.CONDITIONAL ? result.filters : {};
|
|
484
|
+
const rows = await prisma.adversarialResource.findMany({
|
|
485
|
+
where,
|
|
486
|
+
select: { id: true },
|
|
487
|
+
});
|
|
488
|
+
return rows.map((r) => r.id).sort();
|
|
489
|
+
}
|
|
490
|
+
|
|
491
|
+
describe("adversarial conformance corpus", () => {
|
|
492
|
+
test("adapter-supported expected shapes move from throwing to oracle", () => {
|
|
493
|
+
const promotedAction = "promoted-shape";
|
|
494
|
+
const classification = classifyActionsForAdapter(
|
|
495
|
+
{
|
|
496
|
+
conformance: [],
|
|
497
|
+
adapterSupportedExpected: {
|
|
498
|
+
prisma: [{ action: promotedAction, reason: "supported by Prisma" }],
|
|
499
|
+
},
|
|
500
|
+
expectedUnsupported: [
|
|
501
|
+
{
|
|
502
|
+
action: promotedAction,
|
|
503
|
+
shape: "synthetic globally unsupported shape",
|
|
504
|
+
springDataMessage: "unsupported",
|
|
505
|
+
},
|
|
506
|
+
],
|
|
507
|
+
},
|
|
508
|
+
"prisma"
|
|
509
|
+
);
|
|
510
|
+
|
|
511
|
+
expect(classification.oracleActions).toContain(promotedAction);
|
|
512
|
+
expect(
|
|
513
|
+
classification.throwingActions.map(([action]) => action)
|
|
514
|
+
).not.toContain(promotedAction);
|
|
515
|
+
});
|
|
516
|
+
|
|
517
|
+
test("manifest assigns all 118 policy actions exactly one Prisma outcome", () => {
|
|
518
|
+
const oracle = new Set(ORACLE_ACTIONS);
|
|
519
|
+
const throwing = new Set(THROWING_ACTIONS.map(([action]) => action));
|
|
520
|
+
const misclassified = [...MANIFEST_ACTIONS].filter((action) => {
|
|
521
|
+
const classificationCount = [
|
|
522
|
+
oracle.has(action),
|
|
523
|
+
throwing.has(action),
|
|
524
|
+
PRISMA_KNOWN_DIVERGENCES.has(action),
|
|
525
|
+
].filter(Boolean).length;
|
|
526
|
+
return classificationCount !== 1;
|
|
527
|
+
});
|
|
528
|
+
|
|
529
|
+
expect(MANIFEST_ACTIONS.size).toBe(118);
|
|
530
|
+
expect(misclassified).toEqual([]);
|
|
531
|
+
expect(
|
|
532
|
+
[...PRISMA_SUPPORTED_EXPECTED].filter(
|
|
533
|
+
(action) => !EXPECTED_UNSUPPORTED_ACTIONS.has(action)
|
|
534
|
+
)
|
|
535
|
+
).toEqual([]);
|
|
536
|
+
});
|
|
537
|
+
|
|
538
|
+
test.each(ORACLE_ACTIONS)(
|
|
539
|
+
"%s matches the check() oracle",
|
|
540
|
+
async (action) => {
|
|
541
|
+
const [oracle, filtered] = await Promise.all([
|
|
542
|
+
oracleAllowedIds(action),
|
|
543
|
+
adapterFilteredIds(action),
|
|
544
|
+
]);
|
|
545
|
+
expect(filtered).toEqual(oracle);
|
|
546
|
+
}
|
|
547
|
+
);
|
|
548
|
+
|
|
549
|
+
// Shapes the adapter does not support (globally unsupported planner shapes plus Prisma's
|
|
550
|
+
// declared adapterUnsupported list): translation must fail loudly, never produce a
|
|
551
|
+
// silently-wrong filter.
|
|
552
|
+
test.each(THROWING_ACTIONS)(
|
|
553
|
+
"%s fails loudly instead of silently mistranslating (%s)",
|
|
554
|
+
async (action) => {
|
|
555
|
+
await expect(adapterFilteredIds(action)).rejects.toThrow();
|
|
556
|
+
}
|
|
557
|
+
);
|
|
558
|
+
|
|
559
|
+
test("pins the upstream has() planner over-grant", async () => {
|
|
560
|
+
const action = "p-has";
|
|
561
|
+
expect(PRISMA_KNOWN_DIVERGENCES.has(action)).toBe(true);
|
|
562
|
+
const queryPlan = await cerbos.planResources({
|
|
563
|
+
principal: principal(),
|
|
564
|
+
resource: { kind: seedsFile.resourceKind },
|
|
565
|
+
action,
|
|
566
|
+
});
|
|
567
|
+
const oracle = await oracleAllowedIds(action);
|
|
568
|
+
const allIds = SEEDS.map((seed) => seed.id).sort();
|
|
569
|
+
|
|
570
|
+
expect(queryPlan.kind).toBe(PlanKind.ALWAYS_ALLOWED);
|
|
571
|
+
expect(oracle.length).toBeGreaterThan(0);
|
|
572
|
+
expect(oracle.length).toBeLessThan(allIds.length);
|
|
573
|
+
expect(oracle).toContain("a1");
|
|
574
|
+
expect(await adapterFilteredIds(action)).toEqual(allIds);
|
|
575
|
+
});
|
|
576
|
+
|
|
577
|
+
test("oracle is not degenerate", async () => {
|
|
578
|
+
// Guard the guard: at least one action must produce a non-empty, non-total oracle set,
|
|
579
|
+
// otherwise the differential comparison could pass vacuously (e.g. PDP denying all).
|
|
580
|
+
for (const action of ["vf-le", "like-percent", "all-on-empty"]) {
|
|
581
|
+
const ids = await oracleAllowedIds(action);
|
|
582
|
+
expect(ids.length).toBeGreaterThan(0);
|
|
583
|
+
expect(ids.length).toBeLessThan(SEEDS.length);
|
|
584
|
+
}
|
|
585
|
+
});
|
|
586
|
+
});
|
|
@@ -26,7 +26,9 @@ export * from "./enums.js"
|
|
|
26
26
|
* Type-safe database client for TypeScript
|
|
27
27
|
* @example
|
|
28
28
|
* ```
|
|
29
|
-
* const prisma = new PrismaClient(
|
|
29
|
+
* const prisma = new PrismaClient({
|
|
30
|
+
* adapter: new PrismaPg({ connectionString: process.env.DATABASE_URL })
|
|
31
|
+
* })
|
|
30
32
|
* // Fetch zero or more Resources
|
|
31
33
|
* const resources = await prisma.resource.findMany()
|
|
32
34
|
* ```
|
|
@@ -58,6 +58,11 @@ export type StringNullableFilter<$PrismaModel = never> = {
|
|
|
58
58
|
not?: Prisma.NestedStringNullableFilter<$PrismaModel> | string | null
|
|
59
59
|
}
|
|
60
60
|
|
|
61
|
+
export type BoolNullableFilter<$PrismaModel = never> = {
|
|
62
|
+
equals?: boolean | Prisma.BooleanFieldRefInput<$PrismaModel> | null
|
|
63
|
+
not?: Prisma.NestedBoolNullableFilter<$PrismaModel> | boolean | null
|
|
64
|
+
}
|
|
65
|
+
|
|
61
66
|
export type SortOrderInput = {
|
|
62
67
|
sort: Prisma.SortOrder
|
|
63
68
|
nulls?: Prisma.NullsOrder
|
|
@@ -121,6 +126,14 @@ export type StringNullableWithAggregatesFilter<$PrismaModel = never> = {
|
|
|
121
126
|
_max?: Prisma.NestedStringNullableFilter<$PrismaModel>
|
|
122
127
|
}
|
|
123
128
|
|
|
129
|
+
export type BoolNullableWithAggregatesFilter<$PrismaModel = never> = {
|
|
130
|
+
equals?: boolean | Prisma.BooleanFieldRefInput<$PrismaModel> | null
|
|
131
|
+
not?: Prisma.NestedBoolNullableWithAggregatesFilter<$PrismaModel> | boolean | null
|
|
132
|
+
_count?: Prisma.NestedIntNullableFilter<$PrismaModel>
|
|
133
|
+
_min?: Prisma.NestedBoolNullableFilter<$PrismaModel>
|
|
134
|
+
_max?: Prisma.NestedBoolNullableFilter<$PrismaModel>
|
|
135
|
+
}
|
|
136
|
+
|
|
124
137
|
export type NestedStringFilter<$PrismaModel = never> = {
|
|
125
138
|
equals?: string | Prisma.StringFieldRefInput<$PrismaModel>
|
|
126
139
|
in?: string[]
|
|
@@ -165,6 +178,11 @@ export type NestedStringNullableFilter<$PrismaModel = never> = {
|
|
|
165
178
|
not?: Prisma.NestedStringNullableFilter<$PrismaModel> | string | null
|
|
166
179
|
}
|
|
167
180
|
|
|
181
|
+
export type NestedBoolNullableFilter<$PrismaModel = never> = {
|
|
182
|
+
equals?: boolean | Prisma.BooleanFieldRefInput<$PrismaModel> | null
|
|
183
|
+
not?: Prisma.NestedBoolNullableFilter<$PrismaModel> | boolean | null
|
|
184
|
+
}
|
|
185
|
+
|
|
168
186
|
export type NestedStringWithAggregatesFilter<$PrismaModel = never> = {
|
|
169
187
|
equals?: string | Prisma.StringFieldRefInput<$PrismaModel>
|
|
170
188
|
in?: string[]
|
|
@@ -245,4 +263,12 @@ export type NestedIntNullableFilter<$PrismaModel = never> = {
|
|
|
245
263
|
not?: Prisma.NestedIntNullableFilter<$PrismaModel> | number | null
|
|
246
264
|
}
|
|
247
265
|
|
|
266
|
+
export type NestedBoolNullableWithAggregatesFilter<$PrismaModel = never> = {
|
|
267
|
+
equals?: boolean | Prisma.BooleanFieldRefInput<$PrismaModel> | null
|
|
268
|
+
not?: Prisma.NestedBoolNullableWithAggregatesFilter<$PrismaModel> | boolean | null
|
|
269
|
+
_count?: Prisma.NestedIntNullableFilter<$PrismaModel>
|
|
270
|
+
_min?: Prisma.NestedBoolNullableFilter<$PrismaModel>
|
|
271
|
+
_max?: Prisma.NestedBoolNullableFilter<$PrismaModel>
|
|
272
|
+
}
|
|
273
|
+
|
|
248
274
|
|