grafeo-ogm 2.1.1 → 2.2.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.
Files changed (59) hide show
  1. package/CHANGELOG.md +43 -0
  2. package/README.md +49 -0
  3. package/dist/cjs/compilers/where.compiler.d.ts +83 -6
  4. package/dist/cjs/compilers/where.compiler.d.ts.map +1 -1
  5. package/dist/cjs/compilers/where.compiler.js +155 -75
  6. package/dist/cjs/compilers/where.compiler.js.map +1 -1
  7. package/dist/cjs/execution/executor.d.ts +11 -1
  8. package/dist/cjs/execution/executor.d.ts.map +1 -1
  9. package/dist/cjs/execution/executor.js +6 -3
  10. package/dist/cjs/execution/executor.js.map +1 -1
  11. package/dist/cjs/index.d.ts +2 -2
  12. package/dist/cjs/index.d.ts.map +1 -1
  13. package/dist/cjs/index.js.map +1 -1
  14. package/dist/cjs/model.d.ts +38 -1
  15. package/dist/cjs/model.d.ts.map +1 -1
  16. package/dist/cjs/model.js +120 -2
  17. package/dist/cjs/model.js.map +1 -1
  18. package/dist/cjs/ogm.d.ts.map +1 -1
  19. package/dist/cjs/ogm.js +1 -0
  20. package/dist/cjs/ogm.js.map +1 -1
  21. package/dist/cjs/policy/explain.d.ts +59 -0
  22. package/dist/cjs/policy/explain.d.ts.map +1 -0
  23. package/dist/cjs/policy/explain.js +175 -0
  24. package/dist/cjs/policy/explain.js.map +1 -0
  25. package/dist/cjs/policy/resolver.d.ts +26 -2
  26. package/dist/cjs/policy/resolver.d.ts.map +1 -1
  27. package/dist/cjs/policy/resolver.js +103 -33
  28. package/dist/cjs/policy/resolver.js.map +1 -1
  29. package/dist/cjs/policy/types.d.ts +87 -0
  30. package/dist/cjs/policy/types.d.ts.map +1 -1
  31. package/dist/esm/compilers/where.compiler.d.ts +83 -6
  32. package/dist/esm/compilers/where.compiler.d.ts.map +1 -1
  33. package/dist/esm/compilers/where.compiler.js +154 -75
  34. package/dist/esm/compilers/where.compiler.js.map +1 -1
  35. package/dist/esm/execution/executor.d.ts +11 -1
  36. package/dist/esm/execution/executor.d.ts.map +1 -1
  37. package/dist/esm/execution/executor.js +6 -3
  38. package/dist/esm/execution/executor.js.map +1 -1
  39. package/dist/esm/index.d.ts +2 -2
  40. package/dist/esm/index.d.ts.map +1 -1
  41. package/dist/esm/index.js.map +1 -1
  42. package/dist/esm/model.d.ts +38 -1
  43. package/dist/esm/model.d.ts.map +1 -1
  44. package/dist/esm/model.js +121 -3
  45. package/dist/esm/model.js.map +1 -1
  46. package/dist/esm/ogm.d.ts.map +1 -1
  47. package/dist/esm/ogm.js +1 -0
  48. package/dist/esm/ogm.js.map +1 -1
  49. package/dist/esm/policy/explain.d.ts +59 -0
  50. package/dist/esm/policy/explain.d.ts.map +1 -0
  51. package/dist/esm/policy/explain.js +169 -0
  52. package/dist/esm/policy/explain.js.map +1 -0
  53. package/dist/esm/policy/resolver.d.ts +26 -2
  54. package/dist/esm/policy/resolver.d.ts.map +1 -1
  55. package/dist/esm/policy/resolver.js +102 -33
  56. package/dist/esm/policy/resolver.js.map +1 -1
  57. package/dist/esm/policy/types.d.ts +87 -0
  58. package/dist/esm/policy/types.d.ts.map +1 -1
  59. package/package.json +1 -1
package/CHANGELOG.md CHANGED
@@ -1,5 +1,48 @@
1
1
  # Changelog
2
2
 
3
+ ## 2.2.0 (2026-09-23) — 🔎 Explain policy decisions
4
+
5
+ > **Policy-bound reads can now explain themselves.** When a policy hides a node, `find` could only show *that* it disappeared, never *which* named clause removed it, because every clause is compiled into a single `WHERE` predicate. The new `Model.explainPolicies()` evaluates each `read` policy separately for every candidate node, covering both the declarative `when:` form and the raw `cypher:` form, and returns the hidden nodes too ([#6](https://github.com/neomodular/grafeo-ogm/issues/6)).
6
+ >
7
+ > **Additive minor release.** No existing signature changes, and every existing query emits byte-identical Cypher (pinned by a new 17-case golden suite). The one observable change is a correction to `policiesEvaluated` in the audit metadata (see below).
8
+
9
+ ### ✨ `Model.explainPolicies()`
10
+
11
+ ```typescript
12
+ const report = await ogm
13
+ .withContext(ctx)
14
+ .model('Chart')
15
+ .explainPolicies({ where: { id_IN: candidateIds }, select: { id: true } });
16
+ // → [{ node, visible, overriddenBy, permissiveGranted, failedRestrictives,
17
+ // policies: [{ name, named, kind, source, applied, outcome }] }]
18
+ ```
19
+
20
+ - **Matches enforcement exactly.** Explain reuses the fragments `find` compiles, and `visible` is computed from the same composed predicate `find` puts in its `WHERE`. The verdict recomputed from the per-policy outcomes is checked against it, and a mismatch throws rather than returning a wrong explanation.
21
+ - **Outcomes:** `pass`, `fail`, `null` (NULL is reported separately from `false`), `abstain` (the policy emitted no predicate), `not-applied` (`appliesWhen` returned false), `skipped` (an override fired).
22
+ - **Candidates** are selected by `where` / `labels` exactly as in `find`. Full `select` / `selectionSet` is supported, and nested relationships are still filtered by their own policies.
23
+ - **Read-only:** when grafeo opens the session itself, it uses `READ` access mode. Candidates default to 100 per call; more than 1,000 is rejected.
24
+ - **An explicit bypass:** every call logs a `warn`, is tagged `explain: true` in the transaction metadata (even with `auditMetadata: false`), and throws on models without a policy binding and on policy-bypassed OGMs. `onDeny: 'throw'` is reported as `visible: false` instead of being thrown.
25
+ - Scope: `Model` and the `read` operation only. `InterfaceModel`, `count`, `aggregate`, and `delete` are not supported yet.
26
+ - New exported types: `PolicyExplanation`, `PolicyClauseExplanation`, `PolicyClauseOutcome`, and `ExecuteOptions`.
27
+
28
+ ### 🐛 `policiesEvaluated` lists only policies that applied
29
+
30
+ The audit metadata field is documented as "names of policies that fired", but it also listed restrictives whose `appliesWhen(ctx)` returned `false`, even though those contributed nothing to the query. They are no longer listed. Emitted Cypher is unchanged.
31
+
32
+ To produce this list, the resolver now evaluates restrictive `appliesWhen` itself, and the compiler (or `evaluateWriteRestrictives`) still evaluates it too. As a result, **restrictive `appliesWhen` callbacks run twice per query**. They are documented as pure compile-time gates, so this only matters if yours have side effects (counters, logging).
33
+
34
+ ### 🔧 Internal
35
+
36
+ - `WhereCompiler`'s policy clause is now built in two steps: `compilePolicyFragments()` (the per-policy parts) and `composePolicyClause()` (the combination). Output is byte-identical to 2.1.1. The new `compileForExplain()` returns the fragments without folding them into the `WHERE`.
37
+ - `PolicyResolver.resolveDetailed()` returns every operation-matching policy with its `applied` / `skipped` state, source, and registration index. `resolve()` is now a projection of it via `projectResolution()`.
38
+ - `Executor.execute()` accepts an optional 4th argument, `{ accessMode }`. It only affects sessions the executor opens itself; calls without it behave as before.
39
+
40
+ ### Tests
41
+
42
+ - `tests/policy/explain-refactor-golden.spec.ts`: 17 goldens captured from the 2.1.1 compiler, covering every fragment shape, interface inheritance, `@cypher` preludes, traversal, nested selection, count, aggregate, delete, override, and `InterfaceModel`.
43
+ - `tests/policy/explain.spec.ts`: 27 tests covering guards, clause forms, no short-circuiting, applicability, the outcome vocabulary, fidelity with `find` (including a forced mismatch), identity, selection and prelude scoping, and security signalling.
44
+ - 10 new resolver tests, 2 new audit-metadata tests, and `tests/executor-access-mode.spec.ts` (5 tests).
45
+
3
46
  ## 2.1.1 (2026-08-13) — 📍 Point fields round-trip
4
47
 
5
48
  > **The last member of the 2.1.0 type-faithfulness family.** Reads flatten a native Point into a plain `{ x, y[, z], srid }` object — but writing that object back bound it as a map property value, which Neo4j rejects at runtime ("Property values can only be of primitive types or arrays thereof"). A `Point` read through the OGM could not be written back through the OGM.
package/README.md CHANGED
@@ -1302,6 +1302,54 @@ When `policies` is configured, every OGM-emitted query attaches transaction meta
1302
1302
 
1303
1303
  Disable via `policyDefaults: { auditMetadata: false }`. The fingerprint is intentionally key-only — no ctx values are leaked.
1304
1304
 
1305
+ `policiesEvaluated` lists only the policies that **applied** to the query. A restrictive whose `appliesWhen(ctx)` returned `false` is omitted (before v2.2.0 it was listed even though it contributed nothing). `explainPolicies` calls add `explain: true` and are always recorded, even with `auditMetadata: false`.
1306
+
1307
+ #### Explaining policy decisions
1308
+
1309
+ When a policy-bound read hides a node, `find` can only show *that* the node disappeared. `explainPolicies` shows *which* named clause decided it. It evaluates every `read` policy for the type separately, for each candidate node, without short-circuiting. Both `when:` and `cypher:` clauses are covered, and nodes the policies reject are returned too.
1310
+
1311
+ ```typescript
1312
+ const report = await ogm
1313
+ .withContext(ctx)
1314
+ .model('Chart')
1315
+ .explainPolicies({ where: { id_IN: candidateIds }, select: { id: true } });
1316
+
1317
+ // [{
1318
+ // node: { id: 'chart-8617' },
1319
+ // visible: false, // exactly what `find` would enforce
1320
+ // overriddenBy: null,
1321
+ // permissiveGranted: true,
1322
+ // failedRestrictives: ['chart.dose-translation-coverage'],
1323
+ // policies: [
1324
+ // { name: 'chart.read-grant', kind: 'permissive', source: 'Chart', applied: true, outcome: 'pass' },
1325
+ // { name: 'chart.weight-zone', kind: 'restrictive', source: 'Chart', applied: true, outcome: 'pass' },
1326
+ // { name: 'chart.text-field-scope', kind: 'restrictive', source: 'Chart', applied: false, outcome: 'not-applied' },
1327
+ // { name: 'chart.dose-translation-coverage', kind: 'restrictive', source: 'Chart', applied: true, outcome: 'fail' },
1328
+ // ],
1329
+ // }]
1330
+ ```
1331
+
1332
+ | `outcome` | Meaning |
1333
+ |---|---|
1334
+ | `pass` | Applied, and its predicate evaluated to `true`. |
1335
+ | `fail` | Applied, and it evaluated to `false`. This includes a restrictive hard deny (`when: () => false`). |
1336
+ | `null` | Applied, and it evaluated to NULL (for example, a missing property). Enforcement treats NULL as not-true. It is reported separately from `fail` because the fix is different. |
1337
+ | `abstain` | Applied, but it emitted no predicate. A permissive abstain grants nothing; a restrictive abstain restricts nothing. |
1338
+ | `not-applied` | `appliesWhen(ctx)` returned `false` (for an override: `when(ctx)` returned `false`). The policy was not evaluated. |
1339
+ | `skipped` | Not evaluated, because an earlier override fired. |
1340
+
1341
+ How it works:
1342
+
1343
+ - **Same predicates as `find`.** The explain query projects the exact compiled fragments `find` would enforce. `visible` is computed from the same composed predicate `find` puts in its `WHERE`. The verdict recomputed from the per-policy outcomes must agree with it; if it ever doesn't, the call throws instead of returning a wrong explanation.
1344
+ - **Candidate selection.** `where` and `labels` select candidates exactly as in `find`, including target-type policies on relationship filters. The root type's policy clause is reported, not used as a filter. Candidates that don't match `where` are simply absent from the result.
1345
+ - **Selection.** `select` / `selectionSet` accept everything `find` accepts. Relationships in the selection are still filtered by their own target-type policies. Only the root type's policies are explained.
1346
+ - **Read-only.** When grafeo opens the session itself, it opens it in `READ` access mode, so the server rejects any write. If you pass your own `transaction` or `session`, it is used as-is, and you control its access mode.
1347
+ - **Bounded.** `options.limit` defaults to 100, and values above 1,000 are rejected. Each candidate evaluates every policy, and the enforcement predicate is evaluated a second time for the cross-check, so explain costs more than a normal read. `sort` and `offset` are supported.
1348
+ - **Unnamed policies** are reported as `<source>.<kind>[<index>]` with `named: false`. Give every policy a `name` if you want stable, readable output.
1349
+ - **Default deny is reported, not thrown.** With `onDeny: 'throw'`, `explainPolicies` still returns rows with `visible: false` and `permissiveGranted: false`.
1350
+
1351
+ > **Security: `explainPolicies` bypasses policy filtering by design.** It returns nodes the bound context cannot see, together with their selected fields. Expose it only on admin-only diagnostic paths, and never let request input decide whether it runs. Every call logs a `warn` and is tagged `explain: true` in the transaction metadata. It is only available on policy-bound models (`ogm.withContext(ctx).model(...)`) and throws on a model with no policy binding or on a policy-bypassed OGM. In this release it covers `Model` and the `read` operation only: `InterfaceModel`, `count`, `aggregate`, and `delete` are not supported yet.
1352
+
1305
1353
  #### Limitations
1306
1354
 
1307
1355
  - **`@cypher` scalar fields inside a policy `where`-partial throw when the policy is injected into nested-selection enforcement.** Refactor the policy to use stored properties or a relationship traversal.
@@ -1879,6 +1927,7 @@ Open an issue on [GitHub](https://github.com/neomodular/grafeo-ogm/issues). For
1879
1927
  | `count(options?)` | `number` | Count matching nodes |
1880
1928
  | `aggregate(options)` | `AggregateResult` | Aggregate values (min, max, avg, count) |
1881
1929
  | `setLabels(options)` | `void` | Add or remove labels on matching nodes |
1930
+ | `explainPolicies(options?)` | `PolicyExplanation<T>[]` | Per-clause `read` policy outcomes per candidate, including hidden nodes (policy-bound models only; see [Explaining policy decisions](#explaining-policy-decisions)) |
1882
1931
 
1883
1932
  ### InterfaceModel
1884
1933
 
@@ -1,5 +1,5 @@
1
1
  import type { OGMLogger } from '../execution/executor';
2
- import type { PolicyContextBundle } from '../policy/types';
2
+ import type { PermissivePolicy, PolicyContextBundle, RestrictivePolicy } from '../policy/types';
3
3
  import { NodeDefinition, SchemaMetadata } from '../schema/types';
4
4
  export interface WhereResult {
5
5
  cypher: string;
@@ -17,6 +17,43 @@ export interface WhereResult {
17
17
  */
18
18
  preludes?: string[];
19
19
  }
20
+ /**
21
+ * One resolved policy's compiled contribution to the policy clause.
22
+ * `parts` are its boolean fragments in emission order (the `when` part,
23
+ * then the `cypher` part) — exactly what `composePolicyClause` joins.
24
+ * Empty `parts` → the policy emitted no predicate: it abstained, or (for
25
+ * restrictives) it is write-side or gated off by `appliesWhen`.
26
+ */
27
+ export interface CompiledPolicy {
28
+ readonly policy: PermissivePolicy | RestrictivePolicy;
29
+ readonly parts: ReadonlyArray<string>;
30
+ }
31
+ /**
32
+ * Per-policy compilation of one (typeName, op) frame. Positionally
33
+ * aligned with the bundle's `resolved.permissives` /
34
+ * `resolved.restrictives` — one entry per policy, in order.
35
+ */
36
+ export interface CompiledPolicyFragments {
37
+ readonly permissives: ReadonlyArray<CompiledPolicy>;
38
+ readonly restrictives: ReadonlyArray<CompiledPolicy>;
39
+ }
40
+ /** Result of `WhereCompiler.compileForExplain` (explain path). */
41
+ export interface ExplainWhereResult {
42
+ /** User where body only — the candidate filter. `''` when absent. */
43
+ userCypher: string;
44
+ /** User-where params followed by policy params (same as `compile()`). */
45
+ params: Record<string, unknown>;
46
+ /** Top-level `@cypher` preludes for BOTH the user where and policies. */
47
+ preludes: string[];
48
+ /**
49
+ * Root policy clause, or `null` when none applies (override fired).
50
+ * `clause` is byte-identical to what `compile()` AND-stitches.
51
+ */
52
+ policy: {
53
+ fragments: CompiledPolicyFragments;
54
+ clause: string;
55
+ } | null;
56
+ }
20
57
  type OperatorSuffix = string;
21
58
  export interface WhereCompilerOptions {
22
59
  /** Set of operator suffixes to reject at runtime (e.g. `new Set(['_MATCHES'])`) */
@@ -75,15 +112,44 @@ export declare class WhereCompiler {
75
112
  policyContext?: PolicyContextBundle;
76
113
  }): WhereResult;
77
114
  /**
78
- * Compile the policy clause for a single (typeName, op) frame. AND-
79
- * stitches into the user's body via `stitchUserAndPolicy`. Shares the
80
- * same `paramCounter` and `scope` as the user where so that nothing
81
- * collides downstream.
115
+ * Explain-path twin of `compile()`, used by `Model.explainPolicies`.
116
+ * Compiles the user `where` EXACTLY as `compile()` does — same order,
117
+ * same `paramCounter`, same `@cypher` prelude scope, same policy-aware
118
+ * relationship traversal — but returns the root policy clause
119
+ * separately instead of AND-stitching it: as per-policy fragments AND
120
+ * as the composed string `compile()` would have stitched. Both come
121
+ * from one compilation, so an explanation cannot drift from
122
+ * enforcement.
123
+ *
124
+ * `policy` is `null` when no root policy clause applies (an override
125
+ * fired).
126
+ *
127
+ * @internal
128
+ */
129
+ compileForExplain(where: Record<string, unknown> | undefined | null, nodeVar: string, nodeDef: NodeDefinition, paramCounter: {
130
+ count: number;
131
+ }, policyContext: PolicyContextBundle): ExplainWhereResult;
132
+ /**
133
+ * Shared body of `compile()` / `compileForExplain()`: compiles the user
134
+ * where, then the policy fragments, sharing one `paramCounter` and one
135
+ * top-level `@cypher` scope. Returns `null` when there is nothing to
136
+ * compile (no user where and no active policy).
137
+ */
138
+ private compileUserAndPolicy;
139
+ /**
140
+ * Compile each resolved policy of a single (typeName, op) frame into
141
+ * its boolean fragments ("parts"), WITHOUT combining them —
142
+ * `composePolicyClause` does that. Shares the same `paramCounter` and
143
+ * `scope` as the user where so that nothing collides downstream.
144
+ *
145
+ * The result is positionally aligned with `resolved.permissives` /
146
+ * `resolved.restrictives`: one entry per policy, in order, even when a
147
+ * policy contributes no part.
82
148
  *
83
149
  * Permissive `cypher.params` keys are namespaced with `policy_p<n>_`
84
150
  * to guarantee no collision with `param0..N`.
85
151
  */
86
- private compilePolicyClause;
152
+ private compilePolicyFragments;
87
153
  /**
88
154
  * Build a `PolicyContextBundle` for a target type when crossing a
89
155
  * node-type boundary inside a relationship traversal (`_SOME` /
@@ -139,5 +205,16 @@ export declare class WhereCompiler {
139
205
  */
140
206
  private compileScalarCondition;
141
207
  }
208
+ /**
209
+ * The boolean value ONE compiled policy contributes to
210
+ * `composePolicyClause`, as a standalone expression (explain path): a
211
+ * permissive's parts are OR-ed — they join the permissive disjunction —
212
+ * and a restrictive's parts are AND-ed — they join the restrictive
213
+ * conjunction. OR/AND are associative in Cypher's three-valued logic,
214
+ * so grouping per policy preserves the composed clause's truth value.
215
+ *
216
+ * Returns `null` when the policy has no parts (it abstained).
217
+ */
218
+ export declare function policyValueExpression(kind: 'permissive' | 'restrictive', parts: ReadonlyArray<string>): string | null;
142
219
  export {};
143
220
  //# sourceMappingURL=where.compiler.d.ts.map
@@ -1 +1 @@
1
- {"version":3,"file":"where.compiler.d.ts","sourceRoot":"","sources":["../../../src/compilers/where.compiler.ts"],"names":[],"mappings":"AACA,OAAO,KAAK,EAAE,SAAS,EAAE,MAAM,uBAAuB,CAAC;AAEvD,OAAO,KAAK,EAAE,mBAAmB,EAAE,MAAM,iBAAiB,CAAC;AAC3D,OAAO,EACL,cAAc,EAGd,cAAc,EACf,MAAM,iBAAiB,CAAC;AAiBzB,MAAM,WAAW,WAAW;IAC1B,MAAM,EAAE,MAAM,CAAC;IACf,MAAM,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,CAAC;IAChC;;;;;;;;;;OAUG;IACH,QAAQ,CAAC,EAAE,MAAM,EAAE,CAAC;CACrB;AAqDD,KAAK,cAAc,GAAG,MAAM,CAAC;AA4B7B,MAAM,WAAW,oBAAoB;IACnC,mFAAmF;IACnF,iBAAiB,CAAC,EAAE,GAAG,CAAC,cAAc,CAAC,CAAC;IACxC;;;;;;OAMG;IACH,WAAW,CAAC,EAAE,OAAO,CAAC;IACtB;;;;;OAKG;IACH,MAAM,CAAC,EAAE,SAAS,CAAC;CACpB;AAED;;GAEG;AACH,qBAAa,aAAa;IAMtB,OAAO,CAAC,MAAM;IALhB,OAAO,CAAC,iBAAiB,CAAsB;IAC/C,OAAO,CAAC,WAAW,CAAU;IAC7B,OAAO,CAAC,MAAM,CAAC,CAAY;gBAGjB,MAAM,EAAE,cAAc,EAC9B,OAAO,CAAC,EAAE,oBAAoB;IAOhC;;;OAGG;IACH,OAAO,CAAC,gBAAgB;IAQxB,OAAO,CACL,KAAK,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,GAAG,SAAS,GAAG,IAAI,EACjD,OAAO,EAAE,MAAM,EACf,OAAO,EAAE,cAAc,EACvB,YAAY,GAAE;QAAE,KAAK,EAAE,MAAM,CAAA;KAAiB,EAC9C,OAAO,CAAC,EAAE;QACR;;;;;WAKG;QACH,YAAY,CAAC,EAAE,aAAa,CAAC,MAAM,CAAC,CAAC;QACrC;;;;;;;;;;WAUG;QACH,aAAa,CAAC,EAAE,mBAAmB,CAAC;KACrC,GACA,WAAW;IAoDd;;;;;;;;OAQG;IACH,OAAO,CAAC,mBAAmB;IA6K3B;;;;;;;;;;;;;OAaG;IACH,OAAO,CAAC,iBAAiB;IAgBzB,OAAO,CAAC,iBAAiB;IAsNzB;;;;;OAKG;IACH,OAAO,CAAC,eAAe;IAavB,OAAO,CAAC,oBAAoB;IA0I5B;;;;;;;;;;;OAWG;IACH,OAAO,CAAC,2BAA2B;IAsKnC,OAAO,CAAC,sBAAsB;IAsK9B;;;;;OAKG;IACH,OAAO,CAAC,wBAAwB;IAyKhC,OAAO,CAAC,qBAAqB;IA8B7B;;;;;;OAMG;IACH,OAAO,CAAC,sBAAsB;CA0G/B"}
1
+ {"version":3,"file":"where.compiler.d.ts","sourceRoot":"","sources":["../../../src/compilers/where.compiler.ts"],"names":[],"mappings":"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"}
@@ -1,6 +1,7 @@
1
1
  "use strict";
2
2
  Object.defineProperty(exports, "__esModule", { value: true });
3
3
  exports.WhereCompiler = void 0;
4
+ exports.policyValueExpression = policyValueExpression;
4
5
  const errors_1 = require("../errors");
5
6
  const types_1 = require("../policy/types");
6
7
  const utils_1 = require("../schema/utils");
@@ -83,11 +84,56 @@ class WhereCompiler {
83
84
  : `[OGM] ${context}.AND has zero effective conditions — no filter emitted (matches everything).`);
84
85
  }
85
86
  compile(where, nodeVar, nodeDef, paramCounter = { count: 0 }, options) {
87
+ const split = this.compileUserAndPolicy(where, nodeVar, nodeDef, paramCounter, options);
88
+ if (!split)
89
+ return { cypher: '', params: {} };
90
+ const cypher = split.policy
91
+ ? stitchUserAndPolicy(split.userCypher, split.policy.clause)
92
+ : split.userCypher;
93
+ const result = {
94
+ cypher,
95
+ params: split.params,
96
+ };
97
+ if (split.preludes)
98
+ result.preludes = split.preludes;
99
+ return result;
100
+ }
101
+ /**
102
+ * Explain-path twin of `compile()`, used by `Model.explainPolicies`.
103
+ * Compiles the user `where` EXACTLY as `compile()` does — same order,
104
+ * same `paramCounter`, same `@cypher` prelude scope, same policy-aware
105
+ * relationship traversal — but returns the root policy clause
106
+ * separately instead of AND-stitching it: as per-policy fragments AND
107
+ * as the composed string `compile()` would have stitched. Both come
108
+ * from one compilation, so an explanation cannot drift from
109
+ * enforcement.
110
+ *
111
+ * `policy` is `null` when no root policy clause applies (an override
112
+ * fired).
113
+ *
114
+ * @internal
115
+ */
116
+ compileForExplain(where, nodeVar, nodeDef, paramCounter, policyContext) {
117
+ const split = this.compileUserAndPolicy(where, nodeVar, nodeDef, paramCounter, { policyContext });
118
+ return {
119
+ userCypher: split?.userCypher ?? '',
120
+ params: split?.params ?? {},
121
+ preludes: split?.preludes ?? [],
122
+ policy: split?.policy ?? null,
123
+ };
124
+ }
125
+ /**
126
+ * Shared body of `compile()` / `compileForExplain()`: compiles the user
127
+ * where, then the policy fragments, sharing one `paramCounter` and one
128
+ * top-level `@cypher` scope. Returns `null` when there is nothing to
129
+ * compile (no user where and no active policy).
130
+ */
131
+ compileUserAndPolicy(where, nodeVar, nodeDef, paramCounter, options) {
86
132
  const hasUserWhere = where != null && Object.keys(where).length > 0;
87
133
  const policyContext = options?.policyContext;
88
134
  const policyActive = policyContext !== undefined && !policyContext.resolved.overridden;
89
135
  if (!hasUserWhere && !policyActive)
90
- return { cypher: '', params: {} };
136
+ return null;
91
137
  // Top-level scope — preludes here are returned to the caller for stitching
92
138
  // BEFORE the WHERE clause. Nested scopes (relationship quantifiers) build
93
139
  // their own scopes and stitch their preludes into the EXISTS body inline.
@@ -95,34 +141,39 @@ class WhereCompiler {
95
141
  const userBody = hasUserWhere
96
142
  ? this.compileConditions(where, nodeVar, nodeDef, paramCounter, 0, scope, undefined, policyContext)
97
143
  : { cypher: '', params: {} };
98
- let cypher = userBody.cypher;
99
144
  const params = { ...userBody.params };
145
+ let policy = null;
100
146
  if (policyActive) {
101
- const policyClause = this.compilePolicyClause(policyContext, nodeVar, nodeDef, paramCounter, scope, params);
102
- cypher = stitchUserAndPolicy(cypher, policyClause);
147
+ const fragments = this.compilePolicyFragments(policyContext, nodeVar, nodeDef, paramCounter, scope, params);
148
+ policy = { fragments, clause: composePolicyClause(fragments) };
103
149
  }
104
- const result = {
105
- cypher,
150
+ return {
151
+ userCypher: userBody.cypher,
106
152
  params,
153
+ preludes: scope.hasAny() ? scope.emit() : undefined,
154
+ policy,
107
155
  };
108
- if (scope.hasAny())
109
- result.preludes = scope.emit();
110
- return result;
111
156
  }
112
157
  /**
113
- * Compile the policy clause for a single (typeName, op) frame. AND-
114
- * stitches into the user's body via `stitchUserAndPolicy`. Shares the
115
- * same `paramCounter` and `scope` as the user where so that nothing
116
- * collides downstream.
158
+ * Compile each resolved policy of a single (typeName, op) frame into
159
+ * its boolean fragments ("parts"), WITHOUT combining them —
160
+ * `composePolicyClause` does that. Shares the same `paramCounter` and
161
+ * `scope` as the user where so that nothing collides downstream.
162
+ *
163
+ * The result is positionally aligned with `resolved.permissives` /
164
+ * `resolved.restrictives`: one entry per policy, in order, even when a
165
+ * policy contributes no part.
117
166
  *
118
167
  * Permissive `cypher.params` keys are namespaced with `policy_p<n>_`
119
168
  * to guarantee no collision with `param0..N`.
120
169
  */
121
- compilePolicyClause(bundle, nodeVar, nodeDef, paramCounter, scope, paramsTarget) {
122
- const { ctx, resolved, defaults, operation } = bundle;
123
- const permFrags = [];
170
+ compilePolicyFragments(bundle, nodeVar, nodeDef, paramCounter, scope, paramsTarget) {
171
+ const { ctx, resolved } = bundle;
172
+ const permissives = [];
124
173
  let policyParamIdx = 0;
125
174
  for (const p of resolved.permissives) {
175
+ const parts = [];
176
+ permissives.push({ policy: p, parts });
126
177
  // `when` returns a where-partial — compile it through the same
127
178
  // pipeline so every existing operator/quantifier just works.
128
179
  if (p.when) {
@@ -130,13 +181,13 @@ class WhereCompiler {
130
181
  if (partial && Object.keys(partial).length > 0) {
131
182
  const compiled = this.compileConditions(partial, nodeVar, nodeDef, paramCounter, 0, scope);
132
183
  if (compiled.cypher) {
133
- permFrags.push(`(${compiled.cypher})`);
184
+ parts.push(`(${compiled.cypher})`);
134
185
  (0, validation_1.mergeParams)(paramsTarget, compiled.params);
135
186
  }
136
187
  }
137
188
  else if (partial && Object.keys(partial).length === 0)
138
189
  // Empty partial means "match everything" — equivalent to true.
139
- permFrags.push('true');
190
+ parts.push('true');
140
191
  }
141
192
  // `cypher` escape hatch — raw fragment + parameterized params
142
193
  // namespaced with `policy_p<idx>_`.
@@ -148,12 +199,14 @@ class WhereCompiler {
148
199
  const rawParams = p.cypher.params(ctx) ?? {};
149
200
  const namespaced = namespacePolicyParams(rawParams, `policy_p${policyParamIdx++}_`);
150
201
  (0, validation_1.mergeParams)(paramsTarget, namespaced.values);
151
- permFrags.push(`(${rewritePolicyFragment(fragment, namespaced.map)})`);
202
+ parts.push(`(${rewritePolicyFragment(fragment, namespaced.map)})`);
152
203
  }
153
204
  }
154
205
  }
155
- const restFrags = [];
206
+ const restrictives = [];
156
207
  for (const p of resolved.restrictives) {
208
+ const parts = [];
209
+ restrictives.push({ policy: p, parts });
157
210
  // Only ReadRestrictive policies participate in the WHERE clause.
158
211
  // WriteRestrictive policies (create/update) are evaluated at the
159
212
  // application layer in Model.* — calling their `(ctx, input)`
@@ -162,7 +215,8 @@ class WhereCompiler {
162
215
  continue;
163
216
  // Compile-time gate. If `appliesWhen(ctx)` is false, the policy
164
217
  // contributes nothing to this query — same semantics as a
165
- // dropped permissive.
218
+ // dropped permissive. (The resolver evaluates this gate too; this
219
+ // check stays as defense in depth for hand-built bundles.)
166
220
  if (p.appliesWhen && !p.appliesWhen(ctx))
167
221
  continue;
168
222
  if (p.when) {
@@ -171,13 +225,13 @@ class WhereCompiler {
171
225
  if (partial === false)
172
226
  // Hard deny — compiles to `false` and short-circuits the
173
227
  // restrictive AND chain.
174
- restFrags.push('false');
228
+ parts.push('false');
175
229
  else if (partial && typeof partial === 'object') {
176
230
  const obj = partial;
177
231
  if (Object.keys(obj).length > 0) {
178
232
  const compiled = this.compileConditions(obj, nodeVar, nodeDef, paramCounter, 0, scope);
179
233
  if (compiled.cypher) {
180
- restFrags.push(`(${compiled.cypher})`);
234
+ parts.push(`(${compiled.cypher})`);
181
235
  (0, validation_1.mergeParams)(paramsTarget, compiled.params);
182
236
  }
183
237
  }
@@ -191,61 +245,11 @@ class WhereCompiler {
191
245
  const rawParams = p.cypher.params(ctx) ?? {};
192
246
  const namespaced = namespacePolicyParams(rawParams, `policy_p${policyParamIdx++}_`);
193
247
  (0, validation_1.mergeParams)(paramsTarget, namespaced.values);
194
- restFrags.push(`(${rewritePolicyFragment(fragment, namespaced.map)})`);
248
+ parts.push(`(${rewritePolicyFragment(fragment, namespaced.map)})`);
195
249
  }
196
250
  }
197
251
  }
198
- // Default-deny: no permissives matched. The Model call site is
199
- // responsible for raising `PolicyDeniedError` BEFORE compile when
200
- // `defaults.onDeny === 'throw'`. At compile time we always fall
201
- // back to `false` so the query is safe even if the call-site
202
- // throw is bypassed (defense in depth, not the primary path).
203
- if (resolved.permissives.length === 0) {
204
- void defaults;
205
- void operation;
206
- return 'false';
207
- }
208
- // At least one permissive matched. Compose `(perm) AND (rest)`.
209
- //
210
- // SECURITY FIX (v1.8.2 — CRITICAL): Pre-1.8.2 emitted `'true'` here
211
- // when `permFrags.length === 0`, on the assumption that an empty
212
- // permFrags meant "every permissive returned an empty partial,
213
- // which is match-anything". That assumption was WRONG and silently
214
- // inverted the deny default in three documented patterns:
215
- //
216
- // permissive: [{ when: (ctx) => ctx.userId ? {...} : null }]
217
- // ^^^ abstain
218
- // permissive: [{ when: (ctx) => undefined }]
219
- // permissive: [{ cypher: { fragment: () => '', params: ... } }]
220
- //
221
- // In every case, `permFrags` ended up empty and the compiler emitted
222
- // `WHERE true`, dumping unrestricted data. The bug survived TS
223
- // checks, code review, and the v1.7.0 NLS audit because "abstain"
224
- // and "match-anything" looked indistinguishable at this layer.
225
- //
226
- // Permissives are an ALLOW-LIST. If no rule fires, access is DENIED.
227
- // The explicit "match-anything" path is preserved: when a developer
228
- // writes `when: () => ({})`, line 257-259 still pushes `'true'` into
229
- // permFrags before this check, so permFrags.length > 0 and we take
230
- // the else branch. The migration path for users relying on the old
231
- // abstain-as-allow behaviour is to write `when: () => ({})` instead
232
- // of `when: () => null`.
233
- const permClause = permFrags.length === 0
234
- ? 'false'
235
- : permFrags.length === 1
236
- ? permFrags[0]
237
- : `(${permFrags.join(' OR ')})`;
238
- const restClause = restFrags.length === 0
239
- ? 'true'
240
- : restFrags.length === 1
241
- ? restFrags[0]
242
- : restFrags.join(' AND ');
243
- // Avoid the trivial `(... AND true)` formulation when there are no
244
- // restrictives — keeps emitted Cypher tighter and the byte-
245
- // identical regression cleaner.
246
- return restClause === 'true'
247
- ? permClause
248
- : `(${permClause} AND ${restClause})`;
252
+ return { permissives, restrictives };
249
253
  }
250
254
  /**
251
255
  * Build a `PolicyContextBundle` for a target type when crossing a
@@ -1005,6 +1009,82 @@ function stitchUserAndPolicy(userBody, policyClause) {
1005
1009
  return userBody;
1006
1010
  return `(${userBody}) AND ${policyClause}`;
1007
1011
  }
1012
+ /**
1013
+ * Compose compiled policy fragments into the single boolean clause
1014
+ * `(P1 OR P2 …) AND R1 AND R2 …` that `compile()` AND-stitches into the
1015
+ * WHERE. This is the ONLY place that composition happens: `compile()`
1016
+ * stitches its output, and `Model.explainPolicies` projects the very
1017
+ * same string as its enforcement verdict — so the two cannot disagree.
1018
+ */
1019
+ function composePolicyClause(fragments) {
1020
+ // Default-deny: no permissives matched. The Model call site is
1021
+ // responsible for raising `PolicyDeniedError` BEFORE compile when
1022
+ // `defaults.onDeny === 'throw'`. At compile time we always fall
1023
+ // back to `false` so the query is safe even if the call-site
1024
+ // throw is bypassed (defense in depth, not the primary path).
1025
+ if (fragments.permissives.length === 0)
1026
+ return 'false';
1027
+ const permFrags = fragments.permissives.flatMap((c) => c.parts);
1028
+ const restFrags = fragments.restrictives.flatMap((c) => c.parts);
1029
+ // At least one permissive matched. Compose `(perm) AND (rest)`.
1030
+ //
1031
+ // SECURITY FIX (v1.8.2 — CRITICAL): Pre-1.8.2 emitted `'true'` here
1032
+ // when `permFrags.length === 0`, on the assumption that an empty
1033
+ // permFrags meant "every permissive returned an empty partial,
1034
+ // which is match-anything". That assumption was WRONG and silently
1035
+ // inverted the deny default in three documented patterns:
1036
+ //
1037
+ // permissive: [{ when: (ctx) => ctx.userId ? {...} : null }]
1038
+ // ^^^ abstain
1039
+ // permissive: [{ when: (ctx) => undefined }]
1040
+ // permissive: [{ cypher: { fragment: () => '', params: ... } }]
1041
+ //
1042
+ // In every case, `permFrags` ended up empty and the compiler emitted
1043
+ // `WHERE true`, dumping unrestricted data. The bug survived TS
1044
+ // checks, code review, and the v1.7.0 NLS audit because "abstain"
1045
+ // and "match-anything" looked indistinguishable at this layer.
1046
+ //
1047
+ // Permissives are an ALLOW-LIST. If no rule fires, access is DENIED.
1048
+ // The explicit "match-anything" path is preserved: when a developer
1049
+ // writes `when: () => ({})`, `compilePolicyFragments` still pushes
1050
+ // `'true'` as that permissive's part, so permFrags.length > 0 and we
1051
+ // take the else branch. The migration path for users relying on the
1052
+ // old abstain-as-allow behaviour is to write `when: () => ({})`
1053
+ // instead of `when: () => null`.
1054
+ const permClause = permFrags.length === 0
1055
+ ? 'false'
1056
+ : permFrags.length === 1
1057
+ ? permFrags[0]
1058
+ : `(${permFrags.join(' OR ')})`;
1059
+ const restClause = restFrags.length === 0
1060
+ ? 'true'
1061
+ : restFrags.length === 1
1062
+ ? restFrags[0]
1063
+ : restFrags.join(' AND ');
1064
+ // Avoid the trivial `(... AND true)` formulation when there are no
1065
+ // restrictives — keeps emitted Cypher tighter and the byte-
1066
+ // identical regression cleaner.
1067
+ return restClause === 'true'
1068
+ ? permClause
1069
+ : `(${permClause} AND ${restClause})`;
1070
+ }
1071
+ /**
1072
+ * The boolean value ONE compiled policy contributes to
1073
+ * `composePolicyClause`, as a standalone expression (explain path): a
1074
+ * permissive's parts are OR-ed — they join the permissive disjunction —
1075
+ * and a restrictive's parts are AND-ed — they join the restrictive
1076
+ * conjunction. OR/AND are associative in Cypher's three-valued logic,
1077
+ * so grouping per policy preserves the composed clause's truth value.
1078
+ *
1079
+ * Returns `null` when the policy has no parts (it abstained).
1080
+ */
1081
+ function policyValueExpression(kind, parts) {
1082
+ if (parts.length === 0)
1083
+ return null;
1084
+ if (parts.length === 1)
1085
+ return parts[0];
1086
+ return `(${parts.join(kind === 'permissive' ? ' OR ' : ' AND ')})`;
1087
+ }
1008
1088
  /**
1009
1089
  * Namespace raw `cypher.params` keys with the given prefix and produce a
1010
1090
  * lookup map old-name → new-name. The fragment text is rewritten with