@cerbos/orm-prisma 4.0.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 +611 -460
- 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 +91 -8
- package/lib/index.d.ts.map +1 -1
- package/lib/index.js +34 -2301
- 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 +31 -18
- package/src/adversarial.test.ts +488 -488
- package/src/collections.ts +738 -0
- package/src/comparison.ts +1157 -0
- package/src/corpus.ts +597 -0
- 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 +20 -0
- package/src/generated/prisma-adversarial/client.ts +20 -0
- package/src/generated/prisma-adversarial/commonInputTypes.ts +116 -127
- package/src/generated/prisma-adversarial/internal/class.ts +44 -4
- package/src/generated/prisma-adversarial/internal/prismaNamespace.ts +350 -3
- package/src/generated/prisma-adversarial/internal/prismaNamespaceBrowser.ts +49 -2
- package/src/generated/prisma-adversarial/models/AdversarialBoolListElement.ts +1272 -0
- package/src/generated/prisma-adversarial/models/AdversarialInner.ts +1369 -0
- package/src/generated/prisma-adversarial/models/AdversarialNumberListElement.ts +1316 -0
- package/src/generated/prisma-adversarial/models/AdversarialParent.ts +1474 -0
- package/src/generated/prisma-adversarial/models/AdversarialResource.ts +547 -75
- package/src/generated/prisma-adversarial/models.ts +4 -0
- package/src/generated/prisma-adversarial-mysql/browser.ts +64 -0
- package/src/generated/{prisma → prisma-adversarial-mysql}/client.ts +23 -18
- package/src/generated/{prisma → prisma-adversarial-mysql}/commonInputTypes.ts +167 -92
- 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/index.d.ts +13822 -0
- package/src/generated/prisma-adversarial-pg/browser.ts +64 -0
- package/src/generated/prisma-adversarial-pg/client.ts +86 -0
- package/src/generated/prisma-adversarial-pg/commonInputTypes.ts +345 -0
- package/src/generated/prisma-adversarial-pg/enums.ts +15 -0
- package/src/generated/prisma-adversarial-pg/internal/class.ts +284 -0
- package/src/generated/prisma-adversarial-pg/internal/prismaNamespace.ts +1542 -0
- package/src/generated/{prisma → prisma-adversarial-pg}/internal/prismaNamespaceBrowser.ts +72 -43
- package/src/generated/prisma-adversarial-pg/models/AdversarialBoolListElement.ts +1275 -0
- package/src/generated/prisma-adversarial-pg/models/AdversarialCategory.ts +1404 -0
- package/src/generated/prisma-adversarial-pg/models/AdversarialInner.ts +1371 -0
- package/src/generated/prisma-adversarial-pg/models/AdversarialLabel.ts +1275 -0
- package/src/generated/prisma-adversarial-pg/models/AdversarialNumberListElement.ts +1319 -0
- package/src/generated/prisma-adversarial-pg/models/AdversarialParent.ts +1476 -0
- package/src/generated/prisma-adversarial-pg/models/AdversarialResource.ts +2139 -0
- package/src/generated/prisma-adversarial-pg/models/AdversarialSubCategory.ts +1404 -0
- package/src/generated/prisma-adversarial-pg/models/AdversarialTag.ts +1310 -0
- package/src/generated/prisma-adversarial-pg/models.ts +20 -0
- package/src/generated/prisma-adversarial-pg-v6/client.d.ts +1 -0
- package/src/generated/prisma-adversarial-pg-v6/default.d.ts +1 -0
- package/src/generated/prisma-adversarial-pg-v6/edge.d.ts +1 -0
- package/src/generated/prisma-adversarial-pg-v6/index.d.ts +14918 -0
- package/src/generated/prisma-adversarial-pg-v6/runtime/index-browser.d.ts +370 -0
- package/src/generated/prisma-adversarial-pg-v6/runtime/library.d.ts +3982 -0
- package/src/generated/prisma-adversarial-pg-v6/wasm.d.ts +1 -0
- package/src/generated/prisma-adversarial-v6/index.d.ts +6515 -304
- package/src/hierarchy.ts +405 -0
- package/src/index.ts +134 -3325
- 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 +21 -0
- 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.pg.v6.ts +13 -0
- package/src/test-setup.adversarial.pg.v7.ts +9 -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 +766 -0
- package/src/types.ts +1310 -0
- package/src/value.ts +16 -0
- package/src/generated/prisma/browser.ts +0 -59
- package/src/generated/prisma/internal/class.ts +0 -274
- package/src/generated/prisma/internal/prismaNamespace.ts +0 -1403
- package/src/generated/prisma/models/Category.ts +0 -1412
- package/src/generated/prisma/models/Label.ts +0 -1276
- package/src/generated/prisma/models/NestedResource.ts +0 -1562
- package/src/generated/prisma/models/NextLevelNestedResource.ts +0 -1337
- package/src/generated/prisma/models/Resource.ts +0 -2196
- package/src/generated/prisma/models/SubCategory.ts +0 -1412
- package/src/generated/prisma/models/Tag.ts +0 -1276
- package/src/generated/prisma/models/User.ts +0 -1503
- package/src/generated/prisma/models.ts +0 -19
- package/src/generated/prisma-v6/index.d.ts +0 -13726
- package/src/index.test.ts +0 -8885
- package/src/test-setup.v6.ts +0 -5
- package/src/test-setup.v7.ts +0 -7
- /package/src/generated/{prisma → prisma-adversarial-mysql}/enums.ts +0 -0
- /package/src/generated/{prisma-v6 → prisma-adversarial-mysql-v6}/client.d.ts +0 -0
- /package/src/generated/{prisma-v6 → prisma-adversarial-mysql-v6}/default.d.ts +0 -0
- /package/src/generated/{prisma-v6 → prisma-adversarial-mysql-v6}/edge.d.ts +0 -0
- /package/src/generated/{prisma-v6 → prisma-adversarial-mysql-v6}/runtime/index-browser.d.ts +0 -0
- /package/src/generated/{prisma-v6 → prisma-adversarial-mysql-v6}/runtime/library.d.ts +0 -0
- /package/src/generated/{prisma-v6 → prisma-adversarial-mysql-v6}/wasm.d.ts +0 -0
package/src/corpus.ts
ADDED
|
@@ -0,0 +1,597 @@
|
|
|
1
|
+
import * as fs from "fs";
|
|
2
|
+
import * as path from "path";
|
|
3
|
+
import { isDeepStrictEqual } from "util";
|
|
4
|
+
|
|
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";
|
|
16
|
+
|
|
17
|
+
import type { MapperConfig } from ".";
|
|
18
|
+
import { isObject, isString } from "./narrowing";
|
|
19
|
+
|
|
20
|
+
/**
|
|
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.
|
|
25
|
+
*
|
|
26
|
+
* Duplicated across adapters on purpose — adapters share data, not code (ADR 0007).
|
|
27
|
+
* Test-only: excluded from `tsc --build` by `tsconfig.json`, so nothing here reaches `lib/`.
|
|
28
|
+
*/
|
|
29
|
+
|
|
30
|
+
export const CONFORMANCE_DIR = path.join(__dirname, "..", "..", "conformance");
|
|
31
|
+
|
|
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
|
+
}
|
|
40
|
+
|
|
41
|
+
/** The Prisma model the corpus's resource kind maps onto. */
|
|
42
|
+
export const MODEL = "AdversarialResource";
|
|
43
|
+
|
|
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");
|
|
61
|
+
|
|
62
|
+
return [versions.current.tag, versions.previous.tag];
|
|
63
|
+
}
|
|
64
|
+
|
|
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));
|
|
74
|
+
}
|
|
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
|
+
|
|
82
|
+
/**
|
|
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.
|
|
87
|
+
*/
|
|
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
|
+
);
|
|
140
|
+
}
|
|
141
|
+
|
|
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
|
+
);
|
|
152
|
+
}
|
|
153
|
+
|
|
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`;
|
|
164
|
+
}
|
|
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
|
+
|
|
177
|
+
/**
|
|
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.
|
|
180
|
+
*/
|
|
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;
|
|
196
|
+
}
|
|
197
|
+
|
|
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;
|
|
202
|
+
}
|
|
203
|
+
|
|
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
|
+
});
|
|
218
|
+
};
|
|
219
|
+
|
|
220
|
+
return new HTTP(STUB_HTTP_PDP);
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
/**
|
|
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.
|
|
226
|
+
*/
|
|
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
|
+
);
|
|
256
|
+
|
|
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 };
|
|
270
|
+
}
|
|
271
|
+
|
|
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;
|
|
282
|
+
}
|
|
283
|
+
|
|
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)}`
|
|
303
|
+
);
|
|
304
|
+
|
|
305
|
+
return `{${members.join(",")}}`;
|
|
306
|
+
}
|
|
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)])
|
|
319
|
+
);
|
|
320
|
+
}
|
|
321
|
+
|
|
322
|
+
return node;
|
|
323
|
+
}
|
|
324
|
+
|
|
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
|
+
});
|
|
340
|
+
}
|
|
341
|
+
|
|
342
|
+
/**
|
|
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.
|
|
346
|
+
*/
|
|
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),
|
|
357
|
+
};
|
|
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`);
|
|
368
|
+
}
|
|
369
|
+
|
|
370
|
+
return overGrpc;
|
|
371
|
+
}
|
|
372
|
+
|
|
373
|
+
// -- the mapper ----------------------------------------------------------------------------------
|
|
374
|
+
|
|
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
|
|
382
|
+
// references by stripping a `request.resource.attr.` prefix never sees this name.
|
|
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
|
+
},
|
|
426
|
+
"request.resource.attr.createdAt": {
|
|
427
|
+
field: "createdAt",
|
|
428
|
+
valueType: "dateTime",
|
|
429
|
+
nullAttributeRepresentation: "omitted",
|
|
430
|
+
},
|
|
431
|
+
"request.resource.attr.updatedAt": {
|
|
432
|
+
field: "updatedAt",
|
|
433
|
+
valueType: "dateTime",
|
|
434
|
+
nullAttributeRepresentation: "omitted",
|
|
435
|
+
},
|
|
436
|
+
// `owner` and `coOwner` alias columns that `aOptionalString` and `scope` also map, under the
|
|
437
|
+
// OTHER null convention: the oracle sends a real null attribute for them rather than omitting
|
|
438
|
+
// it. Declaring that here is what makes the equality family definite for these two attributes
|
|
439
|
+
// and leaves it untouched for every other mapping (cerbos/query-plan-adapters#308).
|
|
440
|
+
"request.resource.attr.owner": {
|
|
441
|
+
field: "aOptionalString",
|
|
442
|
+
valueType: "string",
|
|
443
|
+
nullable: true,
|
|
444
|
+
nullAttributeRepresentation: "explicit",
|
|
445
|
+
},
|
|
446
|
+
"request.resource.attr.coOwner": {
|
|
447
|
+
field: "scope",
|
|
448
|
+
valueType: "string",
|
|
449
|
+
nullable: true,
|
|
450
|
+
nullAttributeRepresentation: "explicit",
|
|
451
|
+
},
|
|
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
|
|
462
|
+
// adapter emit `is:` rather than `some:`, and `is:` on an optional relation is what requires
|
|
463
|
+
// the hop to exist — the absent-parent guard the negated shapes discriminate. `inner` nests
|
|
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.
|
|
467
|
+
"request.resource.attr.parent": {
|
|
468
|
+
nullAttributeRepresentation: "omitted",
|
|
469
|
+
relation: {
|
|
470
|
+
name: "parent",
|
|
471
|
+
type: "one",
|
|
472
|
+
model: "AdversarialParent",
|
|
473
|
+
fields: {
|
|
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 },
|
|
478
|
+
inner: {
|
|
479
|
+
relation: {
|
|
480
|
+
name: "inner",
|
|
481
|
+
type: "one",
|
|
482
|
+
model: "AdversarialInner",
|
|
483
|
+
fields: {
|
|
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 },
|
|
488
|
+
},
|
|
489
|
+
},
|
|
490
|
+
},
|
|
491
|
+
},
|
|
492
|
+
},
|
|
493
|
+
},
|
|
494
|
+
"request.resource.attr.tags": {
|
|
495
|
+
relation: {
|
|
496
|
+
name: "tags",
|
|
497
|
+
type: "many",
|
|
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.
|
|
503
|
+
model: "AdversarialTag",
|
|
504
|
+
fields: {
|
|
505
|
+
id: { field: "tagId", nullable: false },
|
|
506
|
+
name: { field: "name", valueType: "string", nullable: true },
|
|
507
|
+
},
|
|
508
|
+
},
|
|
509
|
+
},
|
|
510
|
+
"request.resource.attr.tagNames": {
|
|
511
|
+
relation: {
|
|
512
|
+
name: "tags",
|
|
513
|
+
type: "many",
|
|
514
|
+
field: "name",
|
|
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 } },
|
|
539
|
+
},
|
|
540
|
+
},
|
|
541
|
+
"request.resource.attr.categories": {
|
|
542
|
+
relation: {
|
|
543
|
+
name: "categories",
|
|
544
|
+
type: "many",
|
|
545
|
+
fields: {
|
|
546
|
+
name: { field: "name", valueType: "string", nullable: false },
|
|
547
|
+
subCategories: {
|
|
548
|
+
relation: {
|
|
549
|
+
name: "subCategories",
|
|
550
|
+
type: "many",
|
|
551
|
+
fields: {
|
|
552
|
+
name: { field: "name", valueType: "string", nullable: false },
|
|
553
|
+
labels: {
|
|
554
|
+
relation: {
|
|
555
|
+
name: "labels",
|
|
556
|
+
type: "many",
|
|
557
|
+
fields: {
|
|
558
|
+
name: { field: "name", valueType: "string", nullable: true },
|
|
559
|
+
},
|
|
560
|
+
},
|
|
561
|
+
},
|
|
562
|
+
},
|
|
563
|
+
},
|
|
564
|
+
},
|
|
565
|
+
},
|
|
566
|
+
},
|
|
567
|
+
},
|
|
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.
|
|
572
|
+
"request.resource.attr.mainCategory": {
|
|
573
|
+
relation: {
|
|
574
|
+
name: "categories",
|
|
575
|
+
type: "many",
|
|
576
|
+
fields: {
|
|
577
|
+
name: { field: "name", valueType: "string", nullable: false },
|
|
578
|
+
subCategories: {
|
|
579
|
+
relation: {
|
|
580
|
+
name: "subCategories",
|
|
581
|
+
type: "many",
|
|
582
|
+
fields: { name: { field: "name", valueType: "string", nullable: false } },
|
|
583
|
+
},
|
|
584
|
+
},
|
|
585
|
+
// subNames: the same 2-hop chain but with a bare `field`, so plain `in` membership
|
|
586
|
+
// compares the flattened tail's name column directly.
|
|
587
|
+
subNames: {
|
|
588
|
+
relation: {
|
|
589
|
+
name: "subCategories",
|
|
590
|
+
type: "many",
|
|
591
|
+
field: "name",
|
|
592
|
+
},
|
|
593
|
+
},
|
|
594
|
+
},
|
|
595
|
+
},
|
|
596
|
+
},
|
|
597
|
+
};
|
package/src/errors.ts
ADDED
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The error `queryPlanToPrisma` raises when a plan holds a shape this adapter cannot translate
|
|
3
|
+
* without returning rows the PDP would deny: a CEL operator with no faithful Prisma filter, a
|
|
4
|
+
* literal it cannot bind exactly, a construct CEL itself rejects, or a plan that is malformed.
|
|
5
|
+
*
|
|
6
|
+
* It extends `Error`, so existing `catch` blocks keep working. Mapper misconfiguration — a
|
|
7
|
+
* missing `model` option or `relation.model`, an empty field path — is not a refusal and stays a
|
|
8
|
+
* plain `Error`: that is a bug in the caller's mapping, not a limitation of the adapter.
|
|
9
|
+
*/
|
|
10
|
+
export class UnsupportedQueryPlanError extends Error {
|
|
11
|
+
override readonly name = "UnsupportedQueryPlanError";
|
|
12
|
+
}
|