@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/src/corpus.ts
CHANGED
|
@@ -1,289 +1,437 @@
|
|
|
1
1
|
import * as fs from "fs";
|
|
2
2
|
import * as path from "path";
|
|
3
|
+
import { isDeepStrictEqual } from "util";
|
|
3
4
|
|
|
4
|
-
import {
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
} from "@cerbos/
|
|
9
|
-
import type {
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
} from "@
|
|
5
|
+
import { fromBinary, fromJson, fromJsonString, toBinary } from "@bufbuild/protobuf";
|
|
6
|
+
import type { JsonObject, JsonValue } from "@bufbuild/protobuf";
|
|
7
|
+
import type { PlanResourcesRequest } from "@cerbos/api/cerbos/request/v1/request_pb";
|
|
8
|
+
import type { PlanResourcesResponse as PlanResourcesResponseMessage } from "@cerbos/api/cerbos/response/v1/response_pb";
|
|
9
|
+
import { CerbosService } from "@cerbos/api/cerbos/svc/v1/svc_pb";
|
|
10
|
+
import type { PlanResourcesResponse } from "@cerbos/core";
|
|
11
|
+
import { GRPC } from "@cerbos/grpc";
|
|
12
|
+
import { HTTP } from "@cerbos/http";
|
|
13
|
+
import { Server, ServerCredentials } from "@grpc/grpc-js";
|
|
14
|
+
import type { sendUnaryData, ServerUnaryCall } from "@grpc/grpc-js";
|
|
15
|
+
import { afterAll } from "@jest/globals";
|
|
14
16
|
|
|
15
|
-
import {
|
|
17
|
+
import type { MapperConfig } from ".";
|
|
18
|
+
import { isObject, isString } from "./narrowing";
|
|
16
19
|
|
|
17
20
|
/**
|
|
18
|
-
*
|
|
19
|
-
*
|
|
20
|
-
* `
|
|
21
|
-
*
|
|
22
|
-
* but the emitted filter. They must agree on two things or they prove less than they appear to:
|
|
23
|
-
*
|
|
24
|
-
* - **the mapper.** The unit test pins the filter this adapter emits for a mapping; the harness
|
|
25
|
-
* proves that same filter returns the rows the PDP allows. Two copies that drifted would leave
|
|
26
|
-
* the pinned filters describing a mapping no harness ever executes.
|
|
27
|
-
* - **the classification.** Which actions this adapter must refuse, and with which message, is a
|
|
28
|
-
* corpus decision (`actions.json`), not a per-suite one.
|
|
21
|
+
* What both of this adapter's suites read from the shared `../conformance/` corpus: the recorded
|
|
22
|
+
* golden plans and the one mapper every case is translated through. `adversarial.test.ts` replays
|
|
23
|
+
* the goldens against a real store; `translator.test.ts` uses the same mapper for the caller-option
|
|
24
|
+
* tests the corpus cannot vary.
|
|
29
25
|
*
|
|
26
|
+
* Duplicated across adapters on purpose — adapters share data, not code (ADR 0007).
|
|
30
27
|
* Test-only: excluded from `tsc --build` by `tsconfig.json`, so nothing here reaches `lib/`.
|
|
31
28
|
*/
|
|
32
29
|
|
|
33
|
-
export const CONFORMANCE_DIR = path.join(
|
|
34
|
-
__dirname,
|
|
35
|
-
"..",
|
|
36
|
-
"..",
|
|
37
|
-
"conformance"
|
|
38
|
-
);
|
|
30
|
+
export const CONFORMANCE_DIR = path.join(__dirname, "..", "..", "conformance");
|
|
39
31
|
|
|
40
|
-
|
|
32
|
+
/** A file the conformance generator wrote: its shape is the generator's contract with these types. */
|
|
33
|
+
function readJson<T>(file: string): T {
|
|
34
|
+
return JSON.parse(fs.readFileSync(file, "utf8"));
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
export function readCorpusJson<T>(file: string): T {
|
|
38
|
+
return readJson(path.join(CONFORMANCE_DIR, file));
|
|
39
|
+
}
|
|
41
40
|
|
|
42
41
|
/** The Prisma model the corpus's resource kind maps onto. */
|
|
43
42
|
export const MODEL = "AdversarialResource";
|
|
44
43
|
|
|
45
|
-
// --
|
|
44
|
+
// -- the golden files ----------------------------------------------------------------------------
|
|
45
|
+
|
|
46
|
+
export interface Golden {
|
|
47
|
+
id: string;
|
|
48
|
+
pdp: string;
|
|
49
|
+
tier: "core" | "extended" | "adversarial";
|
|
50
|
+
plan: JsonObject;
|
|
51
|
+
allowed: string[];
|
|
52
|
+
plannerDivergence: { pdp?: string[]; issue: string; reason?: string } | null;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/** The PDP tags the goldens were recorded against: current first, then previous. */
|
|
56
|
+
export function pdpTags(): string[] {
|
|
57
|
+
const versions = readCorpusJson<Record<
|
|
58
|
+
"current" | "previous",
|
|
59
|
+
{ tag: string }
|
|
60
|
+
>>("pdp-versions.json");
|
|
46
61
|
|
|
47
|
-
|
|
48
|
-
action: string;
|
|
49
|
-
shape: string;
|
|
50
|
-
/** One entry per adapter that must reject the shape; the corpus asserts the key set. */
|
|
51
|
-
messages: Record<string, string>;
|
|
62
|
+
return [versions.current.tag, versions.previous.tag];
|
|
52
63
|
}
|
|
53
64
|
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
65
|
+
/** Every golden file recorded against `tag`, sorted by case id. */
|
|
66
|
+
export function readGoldens(tag: string): Golden[] {
|
|
67
|
+
const root = FUZZ_DIR ? path.join(FUZZ_DIR, tag) : path.join(CONFORMANCE_DIR, "golden", tag);
|
|
68
|
+
|
|
69
|
+
const files = fs.readdirSync(root, { recursive: true, encoding: "utf8" })
|
|
70
|
+
.filter((file) => file.endsWith(".json"))
|
|
71
|
+
.map((file) => readJson<Golden>(path.join(root, file)));
|
|
72
|
+
|
|
73
|
+
return files.sort((a, b) => a.id.localeCompare(b.id));
|
|
59
74
|
}
|
|
60
75
|
|
|
76
|
+
export function readGolden(tag: string, id: string): Golden {
|
|
77
|
+
return readJson(path.join(CONFORMANCE_DIR, "golden", tag, `${id}.json`));
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
// -- fuzz mode ------------------------------------------------------------------------------------
|
|
81
|
+
|
|
61
82
|
/**
|
|
62
|
-
*
|
|
63
|
-
*
|
|
64
|
-
*
|
|
83
|
+
* The directory a fuzz run was recorded into (conformance/README.md, "Fuzzing"). When it is set,
|
|
84
|
+
* `readGoldens` reads the generated cases from it instead of the corpus, and the harness judges
|
|
85
|
+
* each case by `fuzzVerdict` instead of the ledger: a refusal is always acceptable, since the
|
|
86
|
+
* ledger has no entry for a shape nobody has written down yet.
|
|
65
87
|
*/
|
|
66
|
-
export
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
88
|
+
export const FUZZ_DIR = process.env["QPA_FUZZ_DIR"] || undefined;
|
|
89
|
+
|
|
90
|
+
export interface FuzzVerdict {
|
|
91
|
+
pdp: string;
|
|
92
|
+
id: string;
|
|
93
|
+
verdict: "match" | "refused" | "over-grant" | "under-grant" | "error";
|
|
94
|
+
expected?: string[];
|
|
95
|
+
returned?: string[];
|
|
96
|
+
detail?: string;
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
/** Judges the ids a translated filter returned against the ids the PDP allowed. */
|
|
100
|
+
export function fuzzVerdict(golden: Golden, expected: string[], returned: string[]): FuzzVerdict {
|
|
101
|
+
const base = { pdp: golden.pdp, id: golden.id };
|
|
102
|
+
|
|
103
|
+
// Compared element by element: under jest the arrays come from different realms, which
|
|
104
|
+
// isDeepStrictEqual tells apart.
|
|
105
|
+
if (returned.length === expected.length && returned.every((id, i) => id === expected[i])) {
|
|
106
|
+
return { ...base, verdict: "match" };
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
const allowed = new Set(expected);
|
|
110
|
+
const got = new Set(returned);
|
|
111
|
+
|
|
112
|
+
const verdict = returned.some((id) => !allowed.has(id))
|
|
113
|
+
? "over-grant"
|
|
114
|
+
: expected.some((id) => !got.has(id))
|
|
115
|
+
? "under-grant"
|
|
116
|
+
: "error";
|
|
117
|
+
|
|
118
|
+
if (verdict === "error") {
|
|
119
|
+
return { ...base, verdict, expected, returned, detail: "the right rows, but repeated or out of order" };
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
return { ...base, verdict, expected, returned };
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
/** A translation or query that threw, judged: the adapter's refusal type is the invariant working. */
|
|
126
|
+
export function fuzzThrown(golden: Golden, cause: unknown, refusal: abstract new (...args: never[]) => Error): FuzzVerdict {
|
|
127
|
+
const base = { pdp: golden.pdp, id: golden.id };
|
|
128
|
+
|
|
129
|
+
if (cause instanceof refusal) return { ...base, verdict: "refused" };
|
|
130
|
+
|
|
131
|
+
return { ...base, verdict: "error", detail: cause instanceof Error ? `${cause.name}: ${cause.message}` : String(cause) };
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
/** Fails the case unless its verdict is a match or a refusal. */
|
|
135
|
+
export function assertFuzzVerdict(verdict: FuzzVerdict): void {
|
|
136
|
+
if (verdict.verdict === "match" || verdict.verdict === "refused") return;
|
|
137
|
+
throw new Error(
|
|
138
|
+
`${verdict.verdict}: expected [${verdict.expected?.join(", ") ?? ""}], returned [${verdict.returned?.join(", ") ?? ""}]${verdict.detail ? `: ${verdict.detail}` : ""}`,
|
|
139
|
+
);
|
|
70
140
|
}
|
|
71
141
|
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
142
|
+
/** Writes `<FUZZ_DIR>/results/<adapter>-<store>.json`, which `fuzz-report` reads. */
|
|
143
|
+
export function writeFuzzResults(adapter: string, store: string, results: FuzzVerdict[]): void {
|
|
144
|
+
if (!FUZZ_DIR) return;
|
|
145
|
+
const dir = path.join(FUZZ_DIR, "results");
|
|
146
|
+
fs.mkdirSync(dir, { recursive: true });
|
|
147
|
+
const sorted = [...results].sort((a, b) => a.id.localeCompare(b.id) || a.pdp.localeCompare(b.pdp));
|
|
148
|
+
fs.writeFileSync(
|
|
149
|
+
path.join(dir, `${adapter}-${store}.json`),
|
|
150
|
+
`${JSON.stringify({ adapter, store, results: sorted }, null, 2)}\n`,
|
|
151
|
+
);
|
|
75
152
|
}
|
|
76
153
|
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
154
|
+
/**
|
|
155
|
+
* The instant substituted for `__NOW_MINUS_24H__`, the literal the planner folds
|
|
156
|
+
* `now() - duration("24h")` into. The PDP folds its clock at nanosecond precision, so the
|
|
157
|
+
* substitute carries sub-millisecond digits too: a tidy millisecond instant would translate here
|
|
158
|
+
* while the same case refuses in production (`Timestamp value exceeds millisecond precision`).
|
|
159
|
+
*/
|
|
160
|
+
export function nowMinus24h(): string {
|
|
161
|
+
const ms = new Date(Date.now() - 24 * 60 * 60 * 1000).toISOString();
|
|
162
|
+
|
|
163
|
+
return `${ms.slice(0, -1)}456789Z`;
|
|
84
164
|
}
|
|
85
165
|
|
|
166
|
+
// -- the plans, as the SDK returns them ----------------------------------------------------------
|
|
167
|
+
|
|
168
|
+
/*
|
|
169
|
+
* A golden reaches the adapter through both official JS clients, `@cerbos/http` and
|
|
170
|
+
* `@cerbos/grpc`, so it arrives as an application's `planResources` call returns it. Only the PDP
|
|
171
|
+
* is replaced: each transport has a stub that answers PlanResources with the response registered
|
|
172
|
+
* under the request's `requestId`, a token unique to one call. A plan the two clients decode
|
|
173
|
+
* differently fails the case, so every case also proves the adapter is handed the same input
|
|
174
|
+
* whichever transport the application uses.
|
|
175
|
+
*/
|
|
176
|
+
|
|
86
177
|
/**
|
|
87
|
-
*
|
|
88
|
-
*
|
|
89
|
-
* The message is what turns "it threw" into "it threw for the declared reason": without it a
|
|
90
|
-
* mapper typo or an unrelated validation satisfies the assertion just as well as the limitation
|
|
91
|
-
* the corpus documents (cerbos/query-plan-adapters#326).
|
|
178
|
+
* The base URL of the stubbed HTTP PDP. `.invalid` never resolves, so a request that escapes the
|
|
179
|
+
* stub fails rather than reaching a real server.
|
|
92
180
|
*/
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
181
|
+
const STUB_HTTP_PDP = "http://stub-pdp.invalid";
|
|
182
|
+
|
|
183
|
+
const planResources = CerbosService.method.planResources;
|
|
184
|
+
|
|
185
|
+
/** The response bodies waiting to be served, by the `requestId` of the call that will read them. */
|
|
186
|
+
const pendingResponses = new Map<string, JsonObject>();
|
|
187
|
+
let requestCount = 0;
|
|
188
|
+
|
|
189
|
+
function takeResponse(requestId: string): JsonObject {
|
|
190
|
+
const body = pendingResponses.get(requestId);
|
|
191
|
+
pendingResponses.delete(requestId);
|
|
192
|
+
|
|
193
|
+
if (body === undefined) throw new Error(`stub PDP: no response for request ${requestId}`);
|
|
194
|
+
|
|
195
|
+
return body;
|
|
103
196
|
}
|
|
104
197
|
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
): string {
|
|
110
|
-
if (message === undefined || message === "") {
|
|
111
|
-
throw new Error(
|
|
112
|
-
`actions.json pins no throw message for ${label}: the throw suite would accept a failure for any reason`
|
|
113
|
-
);
|
|
114
|
-
}
|
|
115
|
-
return message;
|
|
198
|
+
function requestUrl(input: Parameters<typeof fetch>[0]): string {
|
|
199
|
+
if (isString(input)) return input;
|
|
200
|
+
|
|
201
|
+
return input instanceof URL ? input.href : input.url;
|
|
116
202
|
}
|
|
117
203
|
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
const
|
|
124
|
-
|
|
125
|
-
(
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
),
|
|
133
|
-
...supportedExpected,
|
|
134
|
-
];
|
|
135
|
-
const throwingActions: ThrowingAction[] = [
|
|
136
|
-
...unsupported.map(
|
|
137
|
-
(entry): ThrowingAction => [
|
|
138
|
-
entry.action,
|
|
139
|
-
entry.reason,
|
|
140
|
-
requireMessage(`adapterUnsupported.${adapter}.${entry.action}`, entry.message),
|
|
141
|
-
]
|
|
142
|
-
),
|
|
143
|
-
...manifest.expectedUnsupported
|
|
144
|
-
.filter((entry) => !supportedExpected.has(entry.action))
|
|
145
|
-
.map((entry): ThrowingAction => [
|
|
146
|
-
entry.action,
|
|
147
|
-
entry.shape,
|
|
148
|
-
requireMessage(
|
|
149
|
-
`expectedUnsupported.${entry.action}.messages.${adapter}`,
|
|
150
|
-
entry.messages?.[adapter]
|
|
151
|
-
),
|
|
152
|
-
]),
|
|
153
|
-
];
|
|
154
|
-
|
|
155
|
-
return {
|
|
156
|
-
oracleActions: [...new Set(oracleActions)].sort(),
|
|
157
|
-
throwingActions: throwingActions.sort(([left], [right]) =>
|
|
158
|
-
left.localeCompare(right)
|
|
159
|
-
),
|
|
160
|
-
supportedExpected,
|
|
204
|
+
/**
|
|
205
|
+
* An `@cerbos/http` client whose PlanResources call is answered here. Only requests to
|
|
206
|
+
* `STUB_HTTP_PDP` are intercepted; every other `fetch` goes through untouched.
|
|
207
|
+
*/
|
|
208
|
+
function httpClient(): HTTP {
|
|
209
|
+
const passthrough = globalThis.fetch;
|
|
210
|
+
globalThis.fetch = async (input, init) => {
|
|
211
|
+
if (!requestUrl(input).startsWith(STUB_HTTP_PDP)) return passthrough(input, init);
|
|
212
|
+
const { requestId } = fromJsonString(planResources.input, String(init?.body), { ignoreUnknownFields: true });
|
|
213
|
+
|
|
214
|
+
return new Response(toProtoJson(takeResponse(requestId)), {
|
|
215
|
+
status: 200,
|
|
216
|
+
headers: { "Content-Type": "application/json" },
|
|
217
|
+
});
|
|
161
218
|
};
|
|
162
|
-
}
|
|
163
219
|
|
|
164
|
-
|
|
220
|
+
return new HTTP(STUB_HTTP_PDP);
|
|
221
|
+
}
|
|
165
222
|
|
|
166
223
|
/**
|
|
167
|
-
*
|
|
168
|
-
*
|
|
169
|
-
* `ts-window` and `ts-vf` compare against `now() - duration("24h")`, which the planner folds to a
|
|
170
|
-
* literal timestamp: a different value on every capture, so the script rewrites it to
|
|
171
|
-
* `__NOW_MINUS_24H__` to keep the drift check deterministic. Reading the fixture back therefore
|
|
172
|
-
* means choosing a value, and the choice is load-bearing rather than arbitrary — Cerbos emits the
|
|
173
|
-
* PDP's clock at nanosecond precision, which is exactly why both actions are `adapterUnsupported`
|
|
174
|
-
* for this adapter (`Timestamp value exceeds millisecond precision`). A tidy millisecond instant
|
|
175
|
-
* here would translate cleanly and quietly contradict `actions.json`, so the fraction carries the
|
|
176
|
-
* nine digits a real plan carries. `translator.test.ts` pins both sides of that boundary, which is
|
|
177
|
-
* what the `plannedAt` override on `planFromWireFixture` is for.
|
|
224
|
+
* An `@cerbos/grpc` client connected to an in-process gRPC server on a loopback port, which
|
|
225
|
+
* answers PlanResources with the registered response encoded as the protobuf a PDP sends.
|
|
178
226
|
*/
|
|
179
|
-
|
|
227
|
+
async function grpcClient(): Promise<{ client: GRPC; server: Server }> {
|
|
228
|
+
const server = new Server();
|
|
229
|
+
server.addService(
|
|
230
|
+
{
|
|
231
|
+
planResources: {
|
|
232
|
+
path: `/${CerbosService.typeName}/${planResources.name}`,
|
|
233
|
+
requestStream: false,
|
|
234
|
+
responseStream: false,
|
|
235
|
+
requestSerialize: (message: PlanResourcesRequest) =>
|
|
236
|
+
Buffer.from(toBinary(planResources.input, message)),
|
|
237
|
+
requestDeserialize: (bytes: Buffer) => fromBinary(planResources.input, bytes),
|
|
238
|
+
responseSerialize: (message: PlanResourcesResponseMessage) =>
|
|
239
|
+
Buffer.from(toBinary(planResources.output, message)),
|
|
240
|
+
responseDeserialize: (bytes: Buffer) => fromBinary(planResources.output, bytes),
|
|
241
|
+
},
|
|
242
|
+
},
|
|
243
|
+
{
|
|
244
|
+
planResources: (
|
|
245
|
+
call: ServerUnaryCall<PlanResourcesRequest, PlanResourcesResponseMessage>,
|
|
246
|
+
respond: sendUnaryData<PlanResourcesResponseMessage>
|
|
247
|
+
) => {
|
|
248
|
+
try {
|
|
249
|
+
respond(null, fromJson(planResources.output, takeResponse(call.request.requestId)));
|
|
250
|
+
} catch (error) {
|
|
251
|
+
respond(error instanceof Error ? error : new Error(String(error)), null);
|
|
252
|
+
}
|
|
253
|
+
},
|
|
254
|
+
}
|
|
255
|
+
);
|
|
180
256
|
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
257
|
+
const port = await new Promise<number>((resolve, reject) =>
|
|
258
|
+
server.bindAsync("127.0.0.1:0", ServerCredentials.createInsecure(), (error, bound) =>
|
|
259
|
+
error ? reject(error) : resolve(bound)
|
|
260
|
+
)
|
|
261
|
+
);
|
|
262
|
+
|
|
263
|
+
// The stub is on loopback; never route it through an ambient proxy.
|
|
264
|
+
const client = new GRPC(`127.0.0.1:${port}`, {
|
|
265
|
+
tls: false,
|
|
266
|
+
channelOptions: { "grpc.enable_http_proxy": 0 },
|
|
267
|
+
});
|
|
268
|
+
|
|
269
|
+
return { client, server };
|
|
185
270
|
}
|
|
186
271
|
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
272
|
+
let stubClients: Promise<{ http: HTTP; grpc: GRPC; server: Server }> | undefined;
|
|
273
|
+
|
|
274
|
+
function sdkClients(): Promise<{ http: HTTP; grpc: GRPC; server: Server }> {
|
|
275
|
+
stubClients ??= grpcClient().then(({ client, server }) => ({
|
|
276
|
+
http: httpClient(),
|
|
277
|
+
grpc: client,
|
|
278
|
+
server,
|
|
279
|
+
}));
|
|
280
|
+
|
|
281
|
+
return stubClients;
|
|
191
282
|
}
|
|
192
283
|
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
)
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
284
|
+
afterAll(async () => {
|
|
285
|
+
if (!stubClients) return;
|
|
286
|
+
const { grpc, server } = await stubClients;
|
|
287
|
+
grpc.close();
|
|
288
|
+
server.forceShutdown();
|
|
289
|
+
});
|
|
290
|
+
|
|
291
|
+
/**
|
|
292
|
+
* JSON as the PDP writes it. `JSON.stringify` writes -0 as `0`, where protojson keeps the sign, so
|
|
293
|
+
* a stub serialising with it would hand the SDK a different number from the one the PDP sends.
|
|
294
|
+
*/
|
|
295
|
+
function toProtoJson(node: JsonValue): string {
|
|
296
|
+
if (Object.is(node, -0)) return "-0";
|
|
297
|
+
|
|
298
|
+
if (Array.isArray(node)) return `[${node.map(toProtoJson).join(",")}]`;
|
|
299
|
+
|
|
300
|
+
if (isObject(node)) {
|
|
301
|
+
const members = Object.entries(node).map(
|
|
302
|
+
([key, child]) => `${JSON.stringify(key)}:${toProtoJson(child)}`
|
|
201
303
|
);
|
|
304
|
+
|
|
305
|
+
return `{${members.join(",")}}`;
|
|
202
306
|
}
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
307
|
+
|
|
308
|
+
return JSON.stringify(node);
|
|
309
|
+
}
|
|
310
|
+
|
|
311
|
+
function substituteNow(node: JsonValue, now: string): JsonValue {
|
|
312
|
+
if (node === "__NOW_MINUS_24H__") return now;
|
|
313
|
+
|
|
314
|
+
if (Array.isArray(node)) return node.map((child) => substituteNow(child, now));
|
|
315
|
+
|
|
316
|
+
if (isObject(node)) {
|
|
317
|
+
return Object.fromEntries(
|
|
318
|
+
Object.entries(node).map(([key, child]) => [key, substituteNow(child, now)])
|
|
209
319
|
);
|
|
210
320
|
}
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
// file the corpus workflow regenerates and diffs would assert nothing new.
|
|
214
|
-
return new PlanExpressionValue(
|
|
215
|
-
(node.value === "__NOW_MINUS_24H__" ? plannedAt : node.value) as Value
|
|
216
|
-
);
|
|
321
|
+
|
|
322
|
+
return node;
|
|
217
323
|
}
|
|
218
324
|
|
|
219
|
-
/**
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
325
|
+
/** One PlanResources call through `client`, answered with `response`. */
|
|
326
|
+
function fetchPlan(
|
|
327
|
+
client: HTTP | GRPC,
|
|
328
|
+
golden: Golden,
|
|
329
|
+
response: JsonObject
|
|
330
|
+
): Promise<PlanResourcesResponse> {
|
|
331
|
+
const requestId = `${golden.id}#${++requestCount}`;
|
|
332
|
+
pendingResponses.set(requestId, response);
|
|
333
|
+
|
|
334
|
+
return client.planResources({
|
|
335
|
+
requestId,
|
|
336
|
+
principal: { id: "stub", roles: ["USER"] },
|
|
337
|
+
resource: { kind: "conformance" },
|
|
338
|
+
action: golden.id,
|
|
339
|
+
});
|
|
226
340
|
}
|
|
227
341
|
|
|
228
342
|
/**
|
|
229
|
-
*
|
|
230
|
-
*
|
|
231
|
-
*
|
|
232
|
-
* — `{expression|variable|value}` nodes into `PlanExpression` / `PlanExpressionVariable` /
|
|
233
|
-
* `PlanExpressionValue`. It is deliberately not a hand-built plan: a plan somebody typed is a
|
|
234
|
-
* belief about what the planner emits, and this repository keeps fixtures precisely because that
|
|
235
|
-
* belief has been wrong before. See docs/adr/0006.
|
|
343
|
+
* A golden's plan as the SDK returns it: the recorded `filter` is served as the response to a
|
|
344
|
+
* stubbed PlanResources call over each transport, so the SDK's own decoding produces what the
|
|
345
|
+
* adapter receives.
|
|
236
346
|
*/
|
|
237
|
-
export function
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
): PlanResourcesResponse {
|
|
241
|
-
const
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
validationErrors: [],
|
|
248
|
-
metadata: undefined,
|
|
347
|
+
export async function planOf(
|
|
348
|
+
golden: Golden,
|
|
349
|
+
now: string = nowMinus24h()
|
|
350
|
+
): Promise<PlanResourcesResponse> {
|
|
351
|
+
const response: JsonObject = {
|
|
352
|
+
requestId: golden.id,
|
|
353
|
+
action: golden.id,
|
|
354
|
+
resourceKind: "conformance",
|
|
355
|
+
policyVersion: "default",
|
|
356
|
+
filter: substituteNow(golden.plan, now),
|
|
249
357
|
};
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
case PlanKind.ALWAYS_ALLOWED:
|
|
261
|
-
case PlanKind.ALWAYS_DENIED:
|
|
262
|
-
return { ...base, kind: fixture.filter.kind };
|
|
263
|
-
default:
|
|
264
|
-
throw new Error(
|
|
265
|
-
`Wire fixture ${action} has an unrecognised filter kind ${fixture.filter.kind}`
|
|
266
|
-
);
|
|
358
|
+
|
|
359
|
+
const { http, grpc } = await sdkClients();
|
|
360
|
+
|
|
361
|
+
const [overHttp, overGrpc] = await Promise.all([
|
|
362
|
+
fetchPlan(http, golden, response),
|
|
363
|
+
fetchPlan(grpc, golden, response),
|
|
364
|
+
]);
|
|
365
|
+
|
|
366
|
+
if (!isDeepStrictEqual(overHttp, overGrpc)) {
|
|
367
|
+
throw new Error(`${golden.id}: @cerbos/http and @cerbos/grpc decode the plan differently`);
|
|
267
368
|
}
|
|
369
|
+
|
|
370
|
+
return overGrpc;
|
|
268
371
|
}
|
|
269
372
|
|
|
270
373
|
// -- the mapper ----------------------------------------------------------------------------------
|
|
271
374
|
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
375
|
+
interface ConformanceMapper {
|
|
376
|
+
[reference: string]: MapperConfig;
|
|
377
|
+
}
|
|
378
|
+
|
|
379
|
+
export const MAPPER: ConformanceMapper = {
|
|
380
|
+
// The primary key, reached as `request.resource.id` rather than through `attr` (the
|
|
381
|
+
// `identifier/*` cases). It is a mapping like any other here, which is the point: an adapter that resolves
|
|
275
382
|
// references by stripping a `request.resource.attr.` prefix never sees this name.
|
|
276
|
-
"request.resource.id": { field: "id" },
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
"request.resource.attr.
|
|
282
|
-
|
|
283
|
-
|
|
383
|
+
"request.resource.id": { field: "id", valueType: "string" },
|
|
384
|
+
// Every attribute resources.json OMITS when its column is NULL follows the omitted convention:
|
|
385
|
+
// aBool, aString and aNumber (NULL on j3, j1, j2), aOptionalString, aDouble, scope, createdAt,
|
|
386
|
+
// updatedAt, obj.inner and every `parent` field. A null literal against one is then a
|
|
387
|
+
// missing-attribute error the PDP denies, never an IS NULL match (#528).
|
|
388
|
+
"request.resource.attr.aBool": {
|
|
389
|
+
field: "aBool",
|
|
390
|
+
valueType: "boolean",
|
|
391
|
+
nullable: true,
|
|
392
|
+
nullAttributeRepresentation: "omitted",
|
|
393
|
+
},
|
|
394
|
+
"request.resource.attr.aString": {
|
|
395
|
+
field: "aString",
|
|
396
|
+
valueType: "string",
|
|
397
|
+
nullable: true,
|
|
398
|
+
nullAttributeRepresentation: "omitted",
|
|
399
|
+
},
|
|
400
|
+
"request.resource.attr.aNumber": {
|
|
401
|
+
field: "aNumber",
|
|
402
|
+
valueType: "number",
|
|
403
|
+
nullable: true,
|
|
404
|
+
nullAttributeRepresentation: "omitted",
|
|
405
|
+
},
|
|
406
|
+
"request.resource.attr.aDouble": {
|
|
407
|
+
field: "aDouble",
|
|
408
|
+
valueType: "number",
|
|
409
|
+
nullAttributeRepresentation: "omitted",
|
|
410
|
+
},
|
|
411
|
+
// resources.json OMITS aOptionalString when the column is NULL, so `== null` against it is a
|
|
412
|
+
// missing-attribute error the PDP denies, never an IS NULL match.
|
|
413
|
+
"request.resource.attr.aOptionalString": {
|
|
414
|
+
field: "aOptionalString",
|
|
415
|
+
valueType: "string",
|
|
416
|
+
nullable: true,
|
|
417
|
+
nullAttributeRepresentation: "omitted",
|
|
418
|
+
},
|
|
419
|
+
"request.resource.attr.createdBy": { field: "createdBy", valueType: "string" },
|
|
420
|
+
"request.resource.attr.scope": {
|
|
421
|
+
field: "scope",
|
|
422
|
+
valueType: "string",
|
|
423
|
+
nullable: true,
|
|
424
|
+
nullAttributeRepresentation: "omitted",
|
|
425
|
+
},
|
|
284
426
|
"request.resource.attr.createdAt": {
|
|
285
427
|
field: "createdAt",
|
|
286
428
|
valueType: "dateTime",
|
|
429
|
+
nullAttributeRepresentation: "omitted",
|
|
430
|
+
},
|
|
431
|
+
"request.resource.attr.updatedAt": {
|
|
432
|
+
field: "updatedAt",
|
|
433
|
+
valueType: "dateTime",
|
|
434
|
+
nullAttributeRepresentation: "omitted",
|
|
287
435
|
},
|
|
288
436
|
// `owner` and `coOwner` alias columns that `aOptionalString` and `scope` also map, under the
|
|
289
437
|
// OTHER null convention: the oracle sends a real null attribute for them rather than omitting
|
|
@@ -291,42 +439,52 @@ export const MAPPER: Record<string, MapperConfig> = {
|
|
|
291
439
|
// and leaves it untouched for every other mapping (cerbos/query-plan-adapters#308).
|
|
292
440
|
"request.resource.attr.owner": {
|
|
293
441
|
field: "aOptionalString",
|
|
442
|
+
valueType: "string",
|
|
294
443
|
nullable: true,
|
|
295
444
|
nullAttributeRepresentation: "explicit",
|
|
296
445
|
},
|
|
297
446
|
"request.resource.attr.coOwner": {
|
|
298
447
|
field: "scope",
|
|
448
|
+
valueType: "string",
|
|
299
449
|
nullable: true,
|
|
300
450
|
nullAttributeRepresentation: "explicit",
|
|
301
451
|
},
|
|
302
|
-
// obj.inner is not a real nested column — mirrors aString, same trick the spring-data
|
|
303
|
-
//
|
|
304
|
-
// two-level join. The two are kept side by side on purpose.
|
|
305
|
-
"request.resource.attr.obj.inner": {
|
|
306
|
-
|
|
452
|
+
// obj.inner is not a real nested column — mirrors aString, same trick the spring-data reference
|
|
453
|
+
// harness uses for the comparison/equals/nested-map-member case. `parent.inner` below is the
|
|
454
|
+
// opposite: a real two-level join. The two are kept side by side on purpose.
|
|
455
|
+
"request.resource.attr.obj.inner": {
|
|
456
|
+
field: "aString",
|
|
457
|
+
valueType: "string",
|
|
458
|
+
nullable: true,
|
|
459
|
+
nullAttributeRepresentation: "omitted",
|
|
460
|
+
},
|
|
461
|
+
// The corpus's one REAL to-one chain (the `relation/*` cases). `type: "one"` is what makes the
|
|
307
462
|
// adapter emit `is:` rather than `some:`, and `is:` on an optional relation is what requires
|
|
308
463
|
// the hop to exist — the absent-parent guard the negated shapes discriminate. `inner` nests
|
|
309
464
|
// the same declaration one level further out; two levels is where alias scoping breaks.
|
|
465
|
+
// A column reached through a relation takes the relation entry's null convention, so the one
|
|
466
|
+
// declaration here covers every `parent.*` and `parent.inner.*` field.
|
|
310
467
|
"request.resource.attr.parent": {
|
|
468
|
+
nullAttributeRepresentation: "omitted",
|
|
311
469
|
relation: {
|
|
312
470
|
name: "parent",
|
|
313
471
|
type: "one",
|
|
314
472
|
model: "AdversarialParent",
|
|
315
473
|
fields: {
|
|
316
|
-
aBool: { field: "aBool" },
|
|
317
|
-
aString: { field: "aString" },
|
|
318
|
-
aNumber: { field: "aNumber" },
|
|
319
|
-
aOptionalString: { field: "aOptionalString", nullable: true
|
|
474
|
+
aBool: { field: "aBool", valueType: "boolean", nullable: true },
|
|
475
|
+
aString: { field: "aString", valueType: "string", nullable: true },
|
|
476
|
+
aNumber: { field: "aNumber", valueType: "number", nullable: true },
|
|
477
|
+
aOptionalString: { field: "aOptionalString", valueType: "string", nullable: true },
|
|
320
478
|
inner: {
|
|
321
479
|
relation: {
|
|
322
480
|
name: "inner",
|
|
323
481
|
type: "one",
|
|
324
482
|
model: "AdversarialInner",
|
|
325
483
|
fields: {
|
|
326
|
-
aBool: { field: "aBool" },
|
|
327
|
-
aString: { field: "aString" },
|
|
328
|
-
aNumber: { field: "aNumber" },
|
|
329
|
-
aOptionalString: { field: "aOptionalString", nullable: true
|
|
484
|
+
aBool: { field: "aBool", valueType: "boolean", nullable: true },
|
|
485
|
+
aString: { field: "aString", valueType: "string", nullable: true },
|
|
486
|
+
aNumber: { field: "aNumber", valueType: "number", nullable: true },
|
|
487
|
+
aOptionalString: { field: "aOptionalString", valueType: "string", nullable: true },
|
|
330
488
|
},
|
|
331
489
|
},
|
|
332
490
|
},
|
|
@@ -337,14 +495,15 @@ export const MAPPER: Record<string, MapperConfig> = {
|
|
|
337
495
|
relation: {
|
|
338
496
|
name: "tags",
|
|
339
497
|
type: "many",
|
|
340
|
-
// Model name enables field-to-field comparisons between tag columns
|
|
341
|
-
//
|
|
342
|
-
// macros over elements whose name column is NULL (a missing attribute — a CEL
|
|
343
|
-
//
|
|
498
|
+
// Model name enables field-to-field comparisons between tag columns. `name` is NULLable
|
|
499
|
+
// in the schema, so it keeps the adapter's three-valued-logic guards for collection
|
|
500
|
+
// macros over elements whose name column is NULL (a missing attribute — a CEL error,
|
|
501
|
+
// hence deny — on the check side). Those guards are the default; every REQUIRED element
|
|
502
|
+
// column below says `nullable: false`, because Prisma rejects `{ name: null }` on one.
|
|
344
503
|
model: "AdversarialTag",
|
|
345
504
|
fields: {
|
|
346
|
-
id: { field: "tagId" },
|
|
347
|
-
name: { field: "name", nullable: true
|
|
505
|
+
id: { field: "tagId", nullable: false },
|
|
506
|
+
name: { field: "name", valueType: "string", nullable: true },
|
|
348
507
|
},
|
|
349
508
|
},
|
|
350
509
|
},
|
|
@@ -353,7 +512,30 @@ export const MAPPER: Record<string, MapperConfig> = {
|
|
|
353
512
|
name: "tags",
|
|
354
513
|
type: "many",
|
|
355
514
|
field: "name",
|
|
356
|
-
fields: { name: { field: "name", nullable: true
|
|
515
|
+
fields: { name: { field: "name", valueType: "string", nullable: true } },
|
|
516
|
+
},
|
|
517
|
+
},
|
|
518
|
+
// The two homogeneous scalar lists, one owned row per element, projected the way `tagNames`
|
|
519
|
+
// is. `valueType` sits on the list mapping itself because that is where a membership test reads
|
|
520
|
+
// it: a literal of another type (`"2" in aNumberList`) is refused rather than bound, since CEL's
|
|
521
|
+
// heterogeneous equality answers it false and a store that coerces would not. The element column
|
|
522
|
+
// is nullable because a6 and a4 hold a null element, which is a VALUE in CEL.
|
|
523
|
+
"request.resource.attr.aNumberList": {
|
|
524
|
+
valueType: "number",
|
|
525
|
+
relation: {
|
|
526
|
+
name: "numberList",
|
|
527
|
+
type: "many",
|
|
528
|
+
field: "value",
|
|
529
|
+
fields: { value: { field: "value", valueType: "number", nullable: true } },
|
|
530
|
+
},
|
|
531
|
+
},
|
|
532
|
+
"request.resource.attr.aBoolList": {
|
|
533
|
+
valueType: "boolean",
|
|
534
|
+
relation: {
|
|
535
|
+
name: "boolList",
|
|
536
|
+
type: "many",
|
|
537
|
+
field: "value",
|
|
538
|
+
fields: { value: { field: "value", valueType: "boolean", nullable: true } },
|
|
357
539
|
},
|
|
358
540
|
},
|
|
359
541
|
"request.resource.attr.categories": {
|
|
@@ -361,19 +543,19 @@ export const MAPPER: Record<string, MapperConfig> = {
|
|
|
361
543
|
name: "categories",
|
|
362
544
|
type: "many",
|
|
363
545
|
fields: {
|
|
364
|
-
name: { field: "name" },
|
|
546
|
+
name: { field: "name", valueType: "string", nullable: false },
|
|
365
547
|
subCategories: {
|
|
366
548
|
relation: {
|
|
367
549
|
name: "subCategories",
|
|
368
550
|
type: "many",
|
|
369
551
|
fields: {
|
|
370
|
-
name: { field: "name" },
|
|
552
|
+
name: { field: "name", valueType: "string", nullable: false },
|
|
371
553
|
labels: {
|
|
372
554
|
relation: {
|
|
373
555
|
name: "labels",
|
|
374
556
|
type: "many",
|
|
375
557
|
fields: {
|
|
376
|
-
name: { field: "name", nullable: true
|
|
558
|
+
name: { field: "name", valueType: "string", nullable: true },
|
|
377
559
|
},
|
|
378
560
|
},
|
|
379
561
|
},
|
|
@@ -383,20 +565,21 @@ export const MAPPER: Record<string, MapperConfig> = {
|
|
|
383
565
|
},
|
|
384
566
|
},
|
|
385
567
|
},
|
|
386
|
-
//
|
|
387
|
-
// as a single-object dotted chain on the check side (every
|
|
388
|
-
// pinning that the adapter joins through every intermediate
|
|
568
|
+
// The multi-hop chain (the `relation/*/*-to-one-chain` cases): mainCategory mirrors the SAME
|
|
569
|
+
// categories/subCategories relation as a single-object dotted chain on the check side (every
|
|
570
|
+
// seed holds at most one category), pinning that the adapter joins through every intermediate
|
|
571
|
+
// hop, never off the root.
|
|
389
572
|
"request.resource.attr.mainCategory": {
|
|
390
573
|
relation: {
|
|
391
574
|
name: "categories",
|
|
392
575
|
type: "many",
|
|
393
576
|
fields: {
|
|
394
|
-
name: { field: "name" },
|
|
577
|
+
name: { field: "name", valueType: "string", nullable: false },
|
|
395
578
|
subCategories: {
|
|
396
579
|
relation: {
|
|
397
580
|
name: "subCategories",
|
|
398
581
|
type: "many",
|
|
399
|
-
fields: { name: { field: "name" } },
|
|
582
|
+
fields: { name: { field: "name", valueType: "string", nullable: false } },
|
|
400
583
|
},
|
|
401
584
|
},
|
|
402
585
|
// subNames: the same 2-hop chain but with a bare `field`, so plain `in` membership
|