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.
- package/CHANGELOG.md +43 -0
- package/README.md +49 -0
- package/dist/cjs/compilers/where.compiler.d.ts +83 -6
- package/dist/cjs/compilers/where.compiler.d.ts.map +1 -1
- package/dist/cjs/compilers/where.compiler.js +155 -75
- package/dist/cjs/compilers/where.compiler.js.map +1 -1
- package/dist/cjs/execution/executor.d.ts +11 -1
- package/dist/cjs/execution/executor.d.ts.map +1 -1
- package/dist/cjs/execution/executor.js +6 -3
- package/dist/cjs/execution/executor.js.map +1 -1
- package/dist/cjs/index.d.ts +2 -2
- package/dist/cjs/index.d.ts.map +1 -1
- package/dist/cjs/index.js.map +1 -1
- package/dist/cjs/model.d.ts +38 -1
- package/dist/cjs/model.d.ts.map +1 -1
- package/dist/cjs/model.js +120 -2
- package/dist/cjs/model.js.map +1 -1
- package/dist/cjs/ogm.d.ts.map +1 -1
- package/dist/cjs/ogm.js +1 -0
- package/dist/cjs/ogm.js.map +1 -1
- package/dist/cjs/policy/explain.d.ts +59 -0
- package/dist/cjs/policy/explain.d.ts.map +1 -0
- package/dist/cjs/policy/explain.js +175 -0
- package/dist/cjs/policy/explain.js.map +1 -0
- package/dist/cjs/policy/resolver.d.ts +26 -2
- package/dist/cjs/policy/resolver.d.ts.map +1 -1
- package/dist/cjs/policy/resolver.js +103 -33
- package/dist/cjs/policy/resolver.js.map +1 -1
- package/dist/cjs/policy/types.d.ts +87 -0
- package/dist/cjs/policy/types.d.ts.map +1 -1
- package/dist/esm/compilers/where.compiler.d.ts +83 -6
- package/dist/esm/compilers/where.compiler.d.ts.map +1 -1
- package/dist/esm/compilers/where.compiler.js +154 -75
- package/dist/esm/compilers/where.compiler.js.map +1 -1
- package/dist/esm/execution/executor.d.ts +11 -1
- package/dist/esm/execution/executor.d.ts.map +1 -1
- package/dist/esm/execution/executor.js +6 -3
- package/dist/esm/execution/executor.js.map +1 -1
- package/dist/esm/index.d.ts +2 -2
- package/dist/esm/index.d.ts.map +1 -1
- package/dist/esm/index.js.map +1 -1
- package/dist/esm/model.d.ts +38 -1
- package/dist/esm/model.d.ts.map +1 -1
- package/dist/esm/model.js +121 -3
- package/dist/esm/model.js.map +1 -1
- package/dist/esm/ogm.d.ts.map +1 -1
- package/dist/esm/ogm.js +1 -0
- package/dist/esm/ogm.js.map +1 -1
- package/dist/esm/policy/explain.d.ts +59 -0
- package/dist/esm/policy/explain.d.ts.map +1 -0
- package/dist/esm/policy/explain.js +169 -0
- package/dist/esm/policy/explain.js.map +1 -0
- package/dist/esm/policy/resolver.d.ts +26 -2
- package/dist/esm/policy/resolver.d.ts.map +1 -1
- package/dist/esm/policy/resolver.js +102 -33
- package/dist/esm/policy/resolver.js.map +1 -1
- package/dist/esm/policy/types.d.ts +87 -0
- package/dist/esm/policy/types.d.ts.map +1 -1
- 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
|
-
*
|
|
79
|
-
*
|
|
80
|
-
* same `paramCounter
|
|
81
|
-
*
|
|
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
|
|
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,
|
|
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
|
|
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
|
|
102
|
-
|
|
147
|
+
const fragments = this.compilePolicyFragments(policyContext, nodeVar, nodeDef, paramCounter, scope, params);
|
|
148
|
+
policy = { fragments, clause: composePolicyClause(fragments) };
|
|
103
149
|
}
|
|
104
|
-
|
|
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
|
|
114
|
-
*
|
|
115
|
-
*
|
|
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
|
-
|
|
122
|
-
const { ctx, resolved
|
|
123
|
-
const
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
202
|
+
parts.push(`(${rewritePolicyFragment(fragment, namespaced.map)})`);
|
|
152
203
|
}
|
|
153
204
|
}
|
|
154
205
|
}
|
|
155
|
-
const
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
248
|
+
parts.push(`(${rewritePolicyFragment(fragment, namespaced.map)})`);
|
|
195
249
|
}
|
|
196
250
|
}
|
|
197
251
|
}
|
|
198
|
-
|
|
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
|