grafeo-ogm 2.1.1 → 2.3.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 +126 -0
- package/README.md +98 -1
- package/dist/cjs/compilers/mutation.compiler.d.ts +94 -31
- package/dist/cjs/compilers/mutation.compiler.d.ts.map +1 -1
- package/dist/cjs/compilers/mutation.compiler.js +381 -343
- package/dist/cjs/compilers/mutation.compiler.js.map +1 -1
- package/dist/cjs/compilers/selection.compiler.d.ts +1 -1
- package/dist/cjs/compilers/selection.compiler.d.ts.map +1 -1
- package/dist/cjs/compilers/selection.compiler.js +33 -39
- package/dist/cjs/compilers/selection.compiler.js.map +1 -1
- package/dist/cjs/compilers/where.compiler.d.ts +206 -6
- package/dist/cjs/compilers/where.compiler.d.ts.map +1 -1
- package/dist/cjs/compilers/where.compiler.js +525 -176
- 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/interface-model.d.ts +20 -10
- package/dist/cjs/interface-model.d.ts.map +1 -1
- package/dist/cjs/interface-model.js +47 -104
- package/dist/cjs/interface-model.js.map +1 -1
- package/dist/cjs/model.d.ts +42 -30
- package/dist/cjs/model.d.ts.map +1 -1
- package/dist/cjs/model.js +134 -99
- 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/nested-writes.d.ts +35 -0
- package/dist/cjs/policy/nested-writes.d.ts.map +1 -0
- package/dist/cjs/policy/nested-writes.js +199 -0
- package/dist/cjs/policy/nested-writes.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 +105 -0
- package/dist/cjs/policy/types.d.ts.map +1 -1
- package/dist/cjs/policy/types.js +24 -0
- package/dist/cjs/policy/types.js.map +1 -1
- package/dist/esm/compilers/mutation.compiler.d.ts +94 -31
- package/dist/esm/compilers/mutation.compiler.d.ts.map +1 -1
- package/dist/esm/compilers/mutation.compiler.js +381 -344
- package/dist/esm/compilers/mutation.compiler.js.map +1 -1
- package/dist/esm/compilers/selection.compiler.d.ts +1 -1
- package/dist/esm/compilers/selection.compiler.d.ts.map +1 -1
- package/dist/esm/compilers/selection.compiler.js +33 -39
- package/dist/esm/compilers/selection.compiler.js.map +1 -1
- package/dist/esm/compilers/where.compiler.d.ts +206 -6
- package/dist/esm/compilers/where.compiler.d.ts.map +1 -1
- package/dist/esm/compilers/where.compiler.js +524 -178
- 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/interface-model.d.ts +20 -10
- package/dist/esm/interface-model.d.ts.map +1 -1
- package/dist/esm/interface-model.js +47 -104
- package/dist/esm/interface-model.js.map +1 -1
- package/dist/esm/model.d.ts +42 -30
- package/dist/esm/model.d.ts.map +1 -1
- package/dist/esm/model.js +136 -101
- 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/nested-writes.d.ts +35 -0
- package/dist/esm/policy/nested-writes.d.ts.map +1 -0
- package/dist/esm/policy/nested-writes.js +193 -0
- package/dist/esm/policy/nested-writes.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 +105 -0
- package/dist/esm/policy/types.d.ts.map +1 -1
- package/dist/esm/policy/types.js +23 -0
- package/dist/esm/policy/types.js.map +1 -1
- package/package.json +1 -1
|
@@ -1,6 +1,9 @@
|
|
|
1
1
|
"use strict";
|
|
2
2
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
3
|
exports.WhereCompiler = void 0;
|
|
4
|
+
exports.stitchUserAndPolicy = stitchUserAndPolicy;
|
|
5
|
+
exports.traversalBundle = traversalBundle;
|
|
6
|
+
exports.policyValueExpression = policyValueExpression;
|
|
4
7
|
const errors_1 = require("../errors");
|
|
5
8
|
const types_1 = require("../policy/types");
|
|
6
9
|
const utils_1 = require("../schema/utils");
|
|
@@ -83,46 +86,196 @@ class WhereCompiler {
|
|
|
83
86
|
: `[OGM] ${context}.AND has zero effective conditions — no filter emitted (matches everything).`);
|
|
84
87
|
}
|
|
85
88
|
compile(where, nodeVar, nodeDef, paramCounter = { count: 0 }, options) {
|
|
89
|
+
const split = this.compileUserAndPolicy(where, nodeVar, nodeDef, paramCounter, options);
|
|
90
|
+
if (!split)
|
|
91
|
+
return { cypher: '', params: {} };
|
|
92
|
+
const cypher = split.policy
|
|
93
|
+
? stitchUserAndPolicy(split.userCypher, split.policy.clause)
|
|
94
|
+
: split.userCypher;
|
|
95
|
+
const result = {
|
|
96
|
+
cypher,
|
|
97
|
+
params: split.params,
|
|
98
|
+
};
|
|
99
|
+
if (split.preludes)
|
|
100
|
+
result.preludes = split.preludes;
|
|
101
|
+
return result;
|
|
102
|
+
}
|
|
103
|
+
/**
|
|
104
|
+
* Explain-path twin of `compile()`, used by `Model.explainPolicies`.
|
|
105
|
+
* Compiles the user `where` EXACTLY as `compile()` does — same order,
|
|
106
|
+
* same `paramCounter`, same `@cypher` prelude scope, same policy-aware
|
|
107
|
+
* relationship traversal — but returns the root policy clause
|
|
108
|
+
* separately instead of AND-stitching it: as per-policy fragments AND
|
|
109
|
+
* as the composed string `compile()` would have stitched. Both come
|
|
110
|
+
* from one compilation, so an explanation cannot drift from
|
|
111
|
+
* enforcement.
|
|
112
|
+
*
|
|
113
|
+
* `policy` is `null` when no root policy clause applies (an override
|
|
114
|
+
* fired).
|
|
115
|
+
*
|
|
116
|
+
* @internal
|
|
117
|
+
*/
|
|
118
|
+
compileForExplain(where, nodeVar, nodeDef, paramCounter, policyContext) {
|
|
119
|
+
const split = this.compileUserAndPolicy(where, nodeVar, nodeDef, paramCounter, { policyContext });
|
|
120
|
+
return {
|
|
121
|
+
userCypher: split?.userCypher ?? '',
|
|
122
|
+
params: split?.params ?? {},
|
|
123
|
+
preludes: split?.preludes ?? [],
|
|
124
|
+
policy: split?.policy ?? null,
|
|
125
|
+
};
|
|
126
|
+
}
|
|
127
|
+
/**
|
|
128
|
+
* The policy clause guarding a node of a possibly ABSTRACT type for one
|
|
129
|
+
* operation (v2.3.0) — the single construction behind every target-
|
|
130
|
+
* policy consumer (nested selection, traversal filters, nested writes)
|
|
131
|
+
* and `InterfaceModel`'s root clause:
|
|
132
|
+
*
|
|
133
|
+
* - concrete type → its own composed clause for `op` (byte-identical
|
|
134
|
+
* to compiling it directly); `null` when it has no policy for `op`
|
|
135
|
+
* or an override fires.
|
|
136
|
+
* - interface / union →
|
|
137
|
+
* `(CASE WHEN v:M1 THEN <M1 clause> WHEN v:M2 THEN … ELSE false END)`
|
|
138
|
+
* over the concrete members, each branch being exactly that member's
|
|
139
|
+
* own clause (`resolveForType(M, op)` already folds in M's interface
|
|
140
|
+
* policies). A member without a policy for `op`, or whose override
|
|
141
|
+
* fires, is `true`; when EVERY member is `true` the result is `null`
|
|
142
|
+
* (unconstrained, like a concrete type without policies). `ELSE false`
|
|
143
|
+
* excludes a node carrying no known member label (defense in depth).
|
|
144
|
+
*
|
|
145
|
+
* Pre-2.3.0 abstract targets resolved the ABSTRACT type's name, so the
|
|
146
|
+
* implementers' own policies were never applied through relationships.
|
|
147
|
+
*
|
|
148
|
+
* `fallbackOp`: resolve it for a type with NO policy for `op` — the
|
|
149
|
+
* `aggregate` → `read` fallback `Model.aggregate` applies at the root.
|
|
150
|
+
*
|
|
151
|
+
* `preludes` (for `@cypher` fields referenced by a policy) are returned
|
|
152
|
+
* to the caller; nested contexts reject them.
|
|
153
|
+
*
|
|
154
|
+
* @internal
|
|
155
|
+
*/
|
|
156
|
+
compileTargetPolicyClause(typeName, varName, op, policyContext, paramCounter, fallbackOp) {
|
|
157
|
+
const members = this.abstractMembers(typeName);
|
|
158
|
+
if (members === null)
|
|
159
|
+
return this.compileConcreteTargetClause(typeName, varName, op, policyContext, paramCounter, fallbackOp);
|
|
160
|
+
const params = {};
|
|
161
|
+
const preludes = [];
|
|
162
|
+
const branches = [];
|
|
163
|
+
let constrained = false;
|
|
164
|
+
for (const member of members) {
|
|
165
|
+
if (!this.schema.nodes.has(member))
|
|
166
|
+
continue;
|
|
167
|
+
const clause = this.compileConcreteTargetClause(member, varName, op, policyContext, paramCounter, fallbackOp);
|
|
168
|
+
(0, validation_1.mergeParams)(params, clause.params);
|
|
169
|
+
// Each member compiles in its own `@cypher` scope; chaining two
|
|
170
|
+
// scopes would drop the first one's aliases at the second's `WITH`.
|
|
171
|
+
if (preludes.length > 0 && clause.preludes.length > 0)
|
|
172
|
+
throw new errors_1.OGMError(`Policies on more than one member of "${typeName}" project @cypher ` +
|
|
173
|
+
`fields, which cannot be combined in one query. Refactor all but ` +
|
|
174
|
+
`one to stored properties.`);
|
|
175
|
+
preludes.push(...clause.preludes);
|
|
176
|
+
if (clause.cypher !== null)
|
|
177
|
+
constrained = true;
|
|
178
|
+
branches.push(`WHEN ${varName}:${(0, validation_1.escapeIdentifier)(member)} THEN ${clause.cypher ?? 'true'}`);
|
|
179
|
+
}
|
|
180
|
+
if (!constrained)
|
|
181
|
+
return { cypher: null, params: {}, preludes: [] };
|
|
182
|
+
return {
|
|
183
|
+
cypher: `(CASE ${branches.join(' ')} ELSE false END)`,
|
|
184
|
+
params,
|
|
185
|
+
preludes,
|
|
186
|
+
};
|
|
187
|
+
}
|
|
188
|
+
compileConcreteTargetClause(typeName, varName, op, policyContext, paramCounter, fallbackOp) {
|
|
189
|
+
const nodeDef = this.schema.nodes.get(typeName);
|
|
190
|
+
let resolvedOp = op;
|
|
191
|
+
let resolved = policyContext.resolveForType(typeName, op);
|
|
192
|
+
if (!resolved && fallbackOp) {
|
|
193
|
+
resolvedOp = fallbackOp;
|
|
194
|
+
resolved = policyContext.resolveForType(typeName, fallbackOp);
|
|
195
|
+
}
|
|
196
|
+
if (!nodeDef || !resolved || resolved.overridden)
|
|
197
|
+
return { cypher: null, params: {}, preludes: [] };
|
|
198
|
+
const compiled = this.compile(undefined, varName, nodeDef, paramCounter, {
|
|
199
|
+
policyContext: {
|
|
200
|
+
ctx: policyContext.ctx,
|
|
201
|
+
operation: resolvedOp,
|
|
202
|
+
resolved,
|
|
203
|
+
resolveForType: policyContext.resolveForType,
|
|
204
|
+
defaults: policyContext.defaults,
|
|
205
|
+
},
|
|
206
|
+
});
|
|
207
|
+
return {
|
|
208
|
+
cypher: compiled.cypher || null,
|
|
209
|
+
params: compiled.params,
|
|
210
|
+
preludes: compiled.preludes ?? [],
|
|
211
|
+
};
|
|
212
|
+
}
|
|
213
|
+
/** Concrete members of an interface / union; `null` for any other type. */
|
|
214
|
+
abstractMembers(typeName) {
|
|
215
|
+
if (this.schema.nodes.has(typeName))
|
|
216
|
+
return null;
|
|
217
|
+
const iface = this.schema.interfaces?.get(typeName);
|
|
218
|
+
if (iface)
|
|
219
|
+
return [...iface.implementedBy];
|
|
220
|
+
const union = this.schema.unions?.get(typeName);
|
|
221
|
+
if (union)
|
|
222
|
+
return [...union];
|
|
223
|
+
return null;
|
|
224
|
+
}
|
|
225
|
+
/**
|
|
226
|
+
* Shared body of `compile()` / `compileForExplain()`: compiles the user
|
|
227
|
+
* where, then the policy fragments, sharing one `paramCounter` and one
|
|
228
|
+
* top-level `@cypher` scope. Returns `null` when there is nothing to
|
|
229
|
+
* compile (no user where and no active policy).
|
|
230
|
+
*/
|
|
231
|
+
compileUserAndPolicy(where, nodeVar, nodeDef, paramCounter, options) {
|
|
86
232
|
const hasUserWhere = where != null && Object.keys(where).length > 0;
|
|
87
233
|
const policyContext = options?.policyContext;
|
|
88
234
|
const policyActive = policyContext !== undefined && !policyContext.resolved.overridden;
|
|
89
235
|
if (!hasUserWhere && !policyActive)
|
|
90
|
-
return
|
|
236
|
+
return null;
|
|
91
237
|
// Top-level scope — preludes here are returned to the caller for stitching
|
|
92
238
|
// BEFORE the WHERE clause. Nested scopes (relationship quantifiers) build
|
|
93
239
|
// their own scopes and stitch their preludes into the EXISTS body inline.
|
|
94
|
-
const
|
|
240
|
+
const ownsScope = options?.scope === undefined;
|
|
241
|
+
const scope = options?.scope ??
|
|
242
|
+
new cypher_field_projection_1.CypherFieldScope(nodeVar, options?.preserveVars ?? [], '__where');
|
|
95
243
|
const userBody = hasUserWhere
|
|
96
244
|
? this.compileConditions(where, nodeVar, nodeDef, paramCounter, 0, scope, undefined, policyContext)
|
|
97
245
|
: { cypher: '', params: {} };
|
|
98
|
-
let cypher = userBody.cypher;
|
|
99
246
|
const params = { ...userBody.params };
|
|
247
|
+
let policy = null;
|
|
100
248
|
if (policyActive) {
|
|
101
|
-
const
|
|
102
|
-
|
|
249
|
+
const fragments = this.compilePolicyFragments(policyContext, nodeVar, nodeDef, paramCounter, scope, params);
|
|
250
|
+
policy = { fragments, clause: composePolicyClause(fragments) };
|
|
103
251
|
}
|
|
104
|
-
|
|
105
|
-
cypher,
|
|
252
|
+
return {
|
|
253
|
+
userCypher: userBody.cypher,
|
|
106
254
|
params,
|
|
255
|
+
preludes: ownsScope && scope.hasAny() ? scope.emit() : undefined,
|
|
256
|
+
policy,
|
|
107
257
|
};
|
|
108
|
-
if (scope.hasAny())
|
|
109
|
-
result.preludes = scope.emit();
|
|
110
|
-
return result;
|
|
111
258
|
}
|
|
112
259
|
/**
|
|
113
|
-
* Compile
|
|
114
|
-
*
|
|
115
|
-
*
|
|
116
|
-
* collides downstream.
|
|
260
|
+
* Compile each resolved policy of a single (typeName, op) frame into
|
|
261
|
+
* its boolean fragments ("parts"), WITHOUT combining them —
|
|
262
|
+
* `composePolicyClause` does that. Shares the same `paramCounter` and
|
|
263
|
+
* `scope` as the user where so that nothing collides downstream.
|
|
264
|
+
*
|
|
265
|
+
* The result is positionally aligned with `resolved.permissives` /
|
|
266
|
+
* `resolved.restrictives`: one entry per policy, in order, even when a
|
|
267
|
+
* policy contributes no part.
|
|
117
268
|
*
|
|
118
269
|
* Permissive `cypher.params` keys are namespaced with `policy_p<n>_`
|
|
119
270
|
* to guarantee no collision with `param0..N`.
|
|
120
271
|
*/
|
|
121
|
-
|
|
122
|
-
const { ctx, resolved
|
|
123
|
-
const
|
|
272
|
+
compilePolicyFragments(bundle, nodeVar, nodeDef, paramCounter, scope, paramsTarget) {
|
|
273
|
+
const { ctx, resolved } = bundle;
|
|
274
|
+
const permissives = [];
|
|
124
275
|
let policyParamIdx = 0;
|
|
125
276
|
for (const p of resolved.permissives) {
|
|
277
|
+
const parts = [];
|
|
278
|
+
permissives.push({ policy: p, parts });
|
|
126
279
|
// `when` returns a where-partial — compile it through the same
|
|
127
280
|
// pipeline so every existing operator/quantifier just works.
|
|
128
281
|
if (p.when) {
|
|
@@ -130,13 +283,13 @@ class WhereCompiler {
|
|
|
130
283
|
if (partial && Object.keys(partial).length > 0) {
|
|
131
284
|
const compiled = this.compileConditions(partial, nodeVar, nodeDef, paramCounter, 0, scope);
|
|
132
285
|
if (compiled.cypher) {
|
|
133
|
-
|
|
286
|
+
parts.push(`(${compiled.cypher})`);
|
|
134
287
|
(0, validation_1.mergeParams)(paramsTarget, compiled.params);
|
|
135
288
|
}
|
|
136
289
|
}
|
|
137
290
|
else if (partial && Object.keys(partial).length === 0)
|
|
138
291
|
// Empty partial means "match everything" — equivalent to true.
|
|
139
|
-
|
|
292
|
+
parts.push('true');
|
|
140
293
|
}
|
|
141
294
|
// `cypher` escape hatch — raw fragment + parameterized params
|
|
142
295
|
// namespaced with `policy_p<idx>_`.
|
|
@@ -148,12 +301,14 @@ class WhereCompiler {
|
|
|
148
301
|
const rawParams = p.cypher.params(ctx) ?? {};
|
|
149
302
|
const namespaced = namespacePolicyParams(rawParams, `policy_p${policyParamIdx++}_`);
|
|
150
303
|
(0, validation_1.mergeParams)(paramsTarget, namespaced.values);
|
|
151
|
-
|
|
304
|
+
parts.push(`(${rewritePolicyFragment(fragment, namespaced.map)})`);
|
|
152
305
|
}
|
|
153
306
|
}
|
|
154
307
|
}
|
|
155
|
-
const
|
|
308
|
+
const restrictives = [];
|
|
156
309
|
for (const p of resolved.restrictives) {
|
|
310
|
+
const parts = [];
|
|
311
|
+
restrictives.push({ policy: p, parts });
|
|
157
312
|
// Only ReadRestrictive policies participate in the WHERE clause.
|
|
158
313
|
// WriteRestrictive policies (create/update) are evaluated at the
|
|
159
314
|
// application layer in Model.* — calling their `(ctx, input)`
|
|
@@ -162,7 +317,8 @@ class WhereCompiler {
|
|
|
162
317
|
continue;
|
|
163
318
|
// Compile-time gate. If `appliesWhen(ctx)` is false, the policy
|
|
164
319
|
// contributes nothing to this query — same semantics as a
|
|
165
|
-
// dropped permissive.
|
|
320
|
+
// dropped permissive. (The resolver evaluates this gate too; this
|
|
321
|
+
// check stays as defense in depth for hand-built bundles.)
|
|
166
322
|
if (p.appliesWhen && !p.appliesWhen(ctx))
|
|
167
323
|
continue;
|
|
168
324
|
if (p.when) {
|
|
@@ -171,13 +327,13 @@ class WhereCompiler {
|
|
|
171
327
|
if (partial === false)
|
|
172
328
|
// Hard deny — compiles to `false` and short-circuits the
|
|
173
329
|
// restrictive AND chain.
|
|
174
|
-
|
|
330
|
+
parts.push('false');
|
|
175
331
|
else if (partial && typeof partial === 'object') {
|
|
176
332
|
const obj = partial;
|
|
177
333
|
if (Object.keys(obj).length > 0) {
|
|
178
334
|
const compiled = this.compileConditions(obj, nodeVar, nodeDef, paramCounter, 0, scope);
|
|
179
335
|
if (compiled.cypher) {
|
|
180
|
-
|
|
336
|
+
parts.push(`(${compiled.cypher})`);
|
|
181
337
|
(0, validation_1.mergeParams)(paramsTarget, compiled.params);
|
|
182
338
|
}
|
|
183
339
|
}
|
|
@@ -191,61 +347,11 @@ class WhereCompiler {
|
|
|
191
347
|
const rawParams = p.cypher.params(ctx) ?? {};
|
|
192
348
|
const namespaced = namespacePolicyParams(rawParams, `policy_p${policyParamIdx++}_`);
|
|
193
349
|
(0, validation_1.mergeParams)(paramsTarget, namespaced.values);
|
|
194
|
-
|
|
350
|
+
parts.push(`(${rewritePolicyFragment(fragment, namespaced.map)})`);
|
|
195
351
|
}
|
|
196
352
|
}
|
|
197
353
|
}
|
|
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})`;
|
|
354
|
+
return { permissives, restrictives };
|
|
249
355
|
}
|
|
250
356
|
/**
|
|
251
357
|
* Build a `PolicyContextBundle` for a target type when crossing a
|
|
@@ -264,9 +370,12 @@ class WhereCompiler {
|
|
|
264
370
|
buildTargetBundle(typeName, policyContext) {
|
|
265
371
|
if (!policyContext)
|
|
266
372
|
return undefined;
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
373
|
+
// v2.3.0 — a target WITHOUT a read policy still gets a bundle
|
|
374
|
+
// (`NO_ROOT_POLICY`: no clause of its own) so enforcement keeps
|
|
375
|
+
// cascading into deeper traversals. Pre-2.3.0 this returned
|
|
376
|
+
// `undefined`, making every policy-free type on a traversal path a
|
|
377
|
+
// gateway around the types beyond it (A → B(no policy) → C skipped C).
|
|
378
|
+
const targetPolicy = policyContext.resolveForType(typeName, 'read') ?? types_1.NO_ROOT_POLICY;
|
|
270
379
|
return {
|
|
271
380
|
ctx: policyContext.ctx,
|
|
272
381
|
operation: 'read',
|
|
@@ -275,6 +384,73 @@ class WhereCompiler {
|
|
|
275
384
|
defaults: policyContext.defaults,
|
|
276
385
|
};
|
|
277
386
|
}
|
|
387
|
+
/**
|
|
388
|
+
* Compile a traversal's filter against its target node with the
|
|
389
|
+
* caller's filter (`user`) and the target's `read` policy (`policy`)
|
|
390
|
+
* kept SEPARATE, so each quantifier composes them correctly — the
|
|
391
|
+
* policy must never sit inside a negation (v2.3.0). Pre-2.3.0 the policy
|
|
392
|
+
* was compiled INTO the filter, so `_ALL` (`NOT (user AND policy)`) was
|
|
393
|
+
* falsified by hidden related nodes (an existence oracle), and a
|
|
394
|
+
* connection `node_NOT` was SATISFIED by them.
|
|
395
|
+
*
|
|
396
|
+
* - no policy context → plain compile.
|
|
397
|
+
* - concrete target → one `compileUserAndPolicy` pass (shared `@cypher`
|
|
398
|
+
* prelude scope); the bundle cascades as `NO_ROOT_POLICY` at any depth.
|
|
399
|
+
* - interface target → filter via a `NO_ROOT_POLICY` traversal bundle,
|
|
400
|
+
* policy via `compileTargetPolicyClause` (CASE over the members'
|
|
401
|
+
* own clauses — pre-2.3.0 only the interface's own policies ran).
|
|
402
|
+
*
|
|
403
|
+
* `''` for an absent part. With `scope`, concrete-target preludes
|
|
404
|
+
* register into it (caller emits); otherwise they are returned.
|
|
405
|
+
* Shared with `SelectionCompiler`'s nested-projection filters.
|
|
406
|
+
*
|
|
407
|
+
* @internal
|
|
408
|
+
*/
|
|
409
|
+
compileTargetBody(where, targetVar, targetDef, counter, policyContext, scope) {
|
|
410
|
+
if (!policyContext) {
|
|
411
|
+
const plain = this.compile(where, targetVar, targetDef, counter, {
|
|
412
|
+
scope,
|
|
413
|
+
});
|
|
414
|
+
return {
|
|
415
|
+
user: plain.cypher,
|
|
416
|
+
policy: '',
|
|
417
|
+
params: plain.params,
|
|
418
|
+
preludes: plain.preludes ?? [],
|
|
419
|
+
};
|
|
420
|
+
}
|
|
421
|
+
if (this.abstractMembers(targetDef.typeName) === null) {
|
|
422
|
+
const split = this.compileUserAndPolicy(where, targetVar, targetDef, counter, {
|
|
423
|
+
policyContext: this.buildTargetBundle(targetDef.typeName, policyContext),
|
|
424
|
+
scope,
|
|
425
|
+
});
|
|
426
|
+
if (!split)
|
|
427
|
+
return { user: '', policy: '', params: {}, preludes: [] };
|
|
428
|
+
return {
|
|
429
|
+
user: split.userCypher,
|
|
430
|
+
policy: split.policy?.clause ?? '',
|
|
431
|
+
params: split.params,
|
|
432
|
+
preludes: split.preludes ?? [],
|
|
433
|
+
};
|
|
434
|
+
}
|
|
435
|
+
const userPart = this.compile(where, targetVar, targetDef, counter, {
|
|
436
|
+
policyContext: traversalBundle(policyContext),
|
|
437
|
+
scope,
|
|
438
|
+
});
|
|
439
|
+
const policyPart = this.compileTargetPolicyClause(targetDef.typeName, targetVar, 'read', policyContext, counter);
|
|
440
|
+
const userHasPreludes = (userPart.preludes?.length ?? 0) > 0 || (scope?.hasAny() ?? false);
|
|
441
|
+
if (userHasPreludes && policyPart.preludes.length > 0)
|
|
442
|
+
throw new errors_1.OGMError(`Filtering "${targetDef.typeName}" by @cypher fields is not supported ` +
|
|
443
|
+
`when a member type's policy also projects @cypher fields. Filter on ` +
|
|
444
|
+
`stored properties, or refactor the member policy.`);
|
|
445
|
+
const params = { ...userPart.params };
|
|
446
|
+
(0, validation_1.mergeParams)(params, policyPart.params);
|
|
447
|
+
return {
|
|
448
|
+
user: userPart.cypher,
|
|
449
|
+
policy: policyPart.cypher ?? '',
|
|
450
|
+
params,
|
|
451
|
+
preludes: [...(userPart.preludes ?? []), ...policyPart.preludes],
|
|
452
|
+
};
|
|
453
|
+
}
|
|
278
454
|
compileConditions(where, nodeVar, nodeDef, counter, depth, scope,
|
|
279
455
|
/**
|
|
280
456
|
* Optional shared params accumulator. When provided, every leaf
|
|
@@ -358,37 +534,10 @@ class WhereCompiler {
|
|
|
358
534
|
clauses.push(`NOT (${sub.cypher})`);
|
|
359
535
|
continue;
|
|
360
536
|
}
|
|
361
|
-
//
|
|
537
|
+
// Null values — the operator suffix is split off FIRST (v2.3.0); see
|
|
538
|
+
// `compileNullCondition`.
|
|
362
539
|
if (value === null) {
|
|
363
|
-
|
|
364
|
-
if (relDef) {
|
|
365
|
-
// Relationship null means "no such relationship exists"
|
|
366
|
-
const targetNodeDef = (0, utils_1.resolveTargetDef)(relDef.target, this.schema);
|
|
367
|
-
if (targetNodeDef) {
|
|
368
|
-
const relVar = `r${counter.count}`;
|
|
369
|
-
counter.count++;
|
|
370
|
-
const pattern = (0, utils_1.buildRelPattern)({
|
|
371
|
-
sourceVar: nodeVar,
|
|
372
|
-
relDef,
|
|
373
|
-
targetVar: relVar,
|
|
374
|
-
targetLabel: 'auto',
|
|
375
|
-
// Pass schema so that union/interface targets resolve to a
|
|
376
|
-
// labelless target (relationship-type-only filter). Without
|
|
377
|
-
// this, the literal abstract type name is escaped as a label
|
|
378
|
-
// that no concrete node carries → NOT EXISTS is true for
|
|
379
|
-
// every row → ALL rows match.
|
|
380
|
-
schema: this.schema,
|
|
381
|
-
});
|
|
382
|
-
clauses.push(`NOT EXISTS { MATCH ${pattern} }`);
|
|
383
|
-
}
|
|
384
|
-
}
|
|
385
|
-
else {
|
|
386
|
-
// Scalar null means "property IS NULL". `@cypher` scalars need
|
|
387
|
-
// to project through the scope first (NULL check on the alias).
|
|
388
|
-
(0, validation_1.assertSafeIdentifier)(key, 'where clause');
|
|
389
|
-
const fieldRef = this.resolveFieldRef(key, nodeVar, nodeDef, scope);
|
|
390
|
-
clauses.push(`${fieldRef} IS NULL`);
|
|
391
|
-
}
|
|
540
|
+
clauses.push(this.compileNullCondition(key, nodeVar, nodeDef, counter, scope));
|
|
392
541
|
continue;
|
|
393
542
|
}
|
|
394
543
|
// Connection operators (e.g. hasStatusConnection_SOME)
|
|
@@ -490,16 +639,24 @@ class WhereCompiler {
|
|
|
490
639
|
? (this.schema.relationshipProperties.get(relDef.properties) ?? null)
|
|
491
640
|
: null;
|
|
492
641
|
const inner = this.compileConnectionWhereInput(value, relVar, edgeVar, targetNodeDef, propsDef, nodeScope, edgeScope, counter, depth, policyContext);
|
|
493
|
-
const
|
|
494
|
-
|
|
642
|
+
const innerParams = { ...inner.params };
|
|
643
|
+
// v2.3.0 — the target's `'read'` policy, composed ONCE over the whole
|
|
644
|
+
// connection body (node, node_NOT, edge and logical groups alike) and
|
|
645
|
+
// kept outside every negation. Compiled after the caller's filter so
|
|
646
|
+
// parameter numbering matches the pre-2.3.0 single-`node` shape.
|
|
647
|
+
const target = policyContext
|
|
648
|
+
? this.compileTargetBody(undefined, relVar, targetNodeDef, counter, policyContext, nodeScope)
|
|
649
|
+
: null;
|
|
650
|
+
const policy = target?.policy ?? '';
|
|
651
|
+
if (target)
|
|
652
|
+
(0, validation_1.mergeParams)(innerParams, target.params);
|
|
495
653
|
// Stitch the inner preludes (CALL { ... } + WITH ...) INSIDE the
|
|
496
654
|
// EXISTS body, between the MATCH pattern and the inner WHERE.
|
|
497
|
-
// - `nodeScope.emit()` —
|
|
498
|
-
//
|
|
655
|
+
// - `nodeScope.emit()` — node-side `@cypher` projections: the
|
|
656
|
+
// caller's `node` filters and (concrete target) the target policy.
|
|
499
657
|
// - `edgeScope.emit()` — same, but edge-side.
|
|
500
|
-
// - `inner.preludes`
|
|
501
|
-
//
|
|
502
|
-
// scopes.
|
|
658
|
+
// - `inner.preludes` / `target.preludes` — scopes owned elsewhere
|
|
659
|
+
// (an abstract target's member policies).
|
|
503
660
|
const innerPreludes = [];
|
|
504
661
|
if (nodeScope.hasAny())
|
|
505
662
|
innerPreludes.push(...nodeScope.emit());
|
|
@@ -507,10 +664,13 @@ class WhereCompiler {
|
|
|
507
664
|
innerPreludes.push(...edgeScope.emit());
|
|
508
665
|
if (inner.preludes && inner.preludes.length > 0)
|
|
509
666
|
innerPreludes.push(...inner.preludes);
|
|
667
|
+
if (target && target.preludes.length > 0)
|
|
668
|
+
innerPreludes.push(...target.preludes);
|
|
510
669
|
const preludeFragment = innerPreludes.length
|
|
511
670
|
? ` ${innerPreludes.join(' ')}`
|
|
512
671
|
: '';
|
|
513
|
-
const
|
|
672
|
+
const matched = stitchUserAndPolicy(inner.cypher, policy);
|
|
673
|
+
const whereClause = matched ? ` WHERE ${matched}` : '';
|
|
514
674
|
switch (connSuffix) {
|
|
515
675
|
case 'Connection':
|
|
516
676
|
case 'Connection_SOME':
|
|
@@ -525,9 +685,9 @@ class WhereCompiler {
|
|
|
525
685
|
params: innerParams,
|
|
526
686
|
};
|
|
527
687
|
case 'Connection_ALL':
|
|
528
|
-
if (
|
|
688
|
+
if (inner.cypher)
|
|
529
689
|
return {
|
|
530
|
-
cypher: `NOT EXISTS { MATCH ${pattern}${preludeFragment} WHERE
|
|
690
|
+
cypher: `NOT EXISTS { MATCH ${pattern}${preludeFragment} WHERE ${stitchAllCounterexample(inner.cypher, policy)} }`,
|
|
531
691
|
params: innerParams,
|
|
532
692
|
};
|
|
533
693
|
return { cypher: '', params: {} };
|
|
@@ -573,23 +733,23 @@ class WhereCompiler {
|
|
|
573
733
|
* `MATCH ${pattern}` and `WHERE ...` inside the EXISTS body.
|
|
574
734
|
*/
|
|
575
735
|
const extraPreludes = [];
|
|
576
|
-
// Resolve the target type's `'read'` policy ONCE per connection-where
|
|
577
|
-
// input. The same bundle is reused across every `node` / `node_NOT`
|
|
578
|
-
// key we encounter at this level (and across nested AND/OR/NOT
|
|
579
|
-
// groups via direct policyContext threading).
|
|
580
|
-
const targetBundle = this.buildTargetBundle(targetNodeDef.typeName, policyContext);
|
|
581
736
|
for (const [key, val] of Object.entries(value)) {
|
|
582
737
|
if (val === undefined)
|
|
583
738
|
continue;
|
|
584
739
|
if (key === 'node' || key === 'node_NOT') {
|
|
585
|
-
//
|
|
586
|
-
//
|
|
587
|
-
//
|
|
588
|
-
//
|
|
589
|
-
//
|
|
590
|
-
//
|
|
591
|
-
//
|
|
592
|
-
const nodeResult = this.compile(val, relVar, targetNodeDef, counter,
|
|
740
|
+
// v2.3.0 — the caller's node filter only. The target's `'read'`
|
|
741
|
+
// policy is composed ONCE by `tryCompileConnection` over the whole
|
|
742
|
+
// connection body, never here: stitched in here it sat inside the
|
|
743
|
+
// `node_NOT` negation (satisfied by hidden nodes) and was absent
|
|
744
|
+
// from edge-only filters. The traversal bundle keeps deeper
|
|
745
|
+
// traversals enforcing; `nodeScope` shares the `@cypher` chain
|
|
746
|
+
// with that policy.
|
|
747
|
+
const nodeResult = this.compile(val, relVar, targetNodeDef, counter, {
|
|
748
|
+
policyContext: policyContext
|
|
749
|
+
? traversalBundle(policyContext)
|
|
750
|
+
: undefined,
|
|
751
|
+
scope: nodeScope,
|
|
752
|
+
});
|
|
593
753
|
if (nodeResult.preludes && nodeResult.preludes.length > 0)
|
|
594
754
|
extraPreludes.push(...nodeResult.preludes);
|
|
595
755
|
if (nodeResult.cypher) {
|
|
@@ -709,28 +869,22 @@ class WhereCompiler {
|
|
|
709
869
|
// EXISTS never matches concrete-typed nodes.
|
|
710
870
|
schema: this.schema,
|
|
711
871
|
});
|
|
712
|
-
// v1.8.5 —
|
|
713
|
-
//
|
|
714
|
-
//
|
|
715
|
-
//
|
|
716
|
-
const
|
|
717
|
-
|
|
718
|
-
|
|
719
|
-
// produces. `compile()` manages its own inner scope and returns any
|
|
720
|
-
// preludes via `WhereResult.preludes` for stitching inside EXISTS.
|
|
721
|
-
const innerResult = this.compile(value, relVar, targetNodeDef, counter, targetBundle ? { policyContext: targetBundle } : undefined);
|
|
722
|
-
const innerPreludes = innerResult.preludes ?? [];
|
|
872
|
+
// v1.8.5 — the TARGET type's `'read'` policy applies when crossing the
|
|
873
|
+
// relationship boundary (CRIT-3). v2.3.0 — the caller's filter and the
|
|
874
|
+
// target's policy compile SEPARATELY and compose per quantifier with
|
|
875
|
+
// the policy OUTSIDE every negation (see `compileTargetBody`).
|
|
876
|
+
const body = this.compileTargetBody(value, relVar, targetNodeDef, counter, policyContext);
|
|
877
|
+
const matched = stitchUserAndPolicy(body.user, body.policy);
|
|
878
|
+
const innerPreludes = body.preludes;
|
|
723
879
|
const innerPreludeFragment = innerPreludes.length
|
|
724
880
|
? ` ${innerPreludes.join(' ')}`
|
|
725
881
|
: '';
|
|
726
|
-
const whereClause =
|
|
727
|
-
? ` WHERE ${innerResult.cypher}`
|
|
728
|
-
: '';
|
|
882
|
+
const whereClause = matched ? ` WHERE ${matched}` : '';
|
|
729
883
|
switch (suffix) {
|
|
730
884
|
case '_SOME':
|
|
731
885
|
return {
|
|
732
886
|
cypher: `EXISTS { MATCH ${pattern}${innerPreludeFragment}${whereClause} }`,
|
|
733
|
-
params:
|
|
887
|
+
params: body.params,
|
|
734
888
|
};
|
|
735
889
|
case '_NONE':
|
|
736
890
|
case '_NOT':
|
|
@@ -741,14 +895,16 @@ class WhereCompiler {
|
|
|
741
895
|
// Map → NULL → silent wrong rows.
|
|
742
896
|
return {
|
|
743
897
|
cypher: `NOT EXISTS { MATCH ${pattern}${innerPreludeFragment}${whereClause} }`,
|
|
744
|
-
params:
|
|
898
|
+
params: body.params,
|
|
745
899
|
};
|
|
746
900
|
case '_ALL':
|
|
747
|
-
//
|
|
748
|
-
|
|
901
|
+
// No VISIBLE related node fails the filter. Without a caller filter
|
|
902
|
+
// the quantifier is vacuous — hidden nodes must not falsify it
|
|
903
|
+
// (pre-2.3.0 `_ALL: {}` leaked their existence).
|
|
904
|
+
if (body.user)
|
|
749
905
|
return {
|
|
750
|
-
cypher: `NOT EXISTS { MATCH ${pattern}${innerPreludeFragment} WHERE
|
|
751
|
-
params:
|
|
906
|
+
cypher: `NOT EXISTS { MATCH ${pattern}${innerPreludeFragment} WHERE ${stitchAllCounterexample(body.user, body.policy)} }`,
|
|
907
|
+
params: body.params,
|
|
752
908
|
};
|
|
753
909
|
return { cypher: '', params: {} };
|
|
754
910
|
case '_SINGLE': {
|
|
@@ -758,7 +914,7 @@ class WhereCompiler {
|
|
|
758
914
|
// produces preludes — distinguish that case so the error message
|
|
759
915
|
// points at the right cause.
|
|
760
916
|
if (innerPreludes.length > 0) {
|
|
761
|
-
if (
|
|
917
|
+
if (body.policy)
|
|
762
918
|
throw new errors_1.OGMError(`Policy on "${targetNodeDef.typeName}" requires @cypher field projection, ` +
|
|
763
919
|
`which is not supported inside _SINGLE quantifiers. Refactor the policy ` +
|
|
764
920
|
`to use stored properties, or refactor the predicate to _SOME + _NONE.`);
|
|
@@ -773,7 +929,7 @@ class WhereCompiler {
|
|
|
773
929
|
// collisions if/when a future compiler shared this counter.
|
|
774
930
|
return {
|
|
775
931
|
cypher: `size([${relVar} IN [(${pattern}${whereClause} | ${relVar})] | ${relVar}]) = 1`,
|
|
776
|
-
params:
|
|
932
|
+
params: body.params,
|
|
777
933
|
};
|
|
778
934
|
}
|
|
779
935
|
default:
|
|
@@ -819,27 +975,27 @@ class WhereCompiler {
|
|
|
819
975
|
targetVar: relVar,
|
|
820
976
|
targetLabelRaw: labelStr,
|
|
821
977
|
});
|
|
822
|
-
//
|
|
823
|
-
//
|
|
824
|
-
|
|
825
|
-
//
|
|
826
|
-
// specified). When the member has its own `'read'` policy, we still
|
|
827
|
-
// re-enter `compile()` so the policy AND-stitches into the EXISTS
|
|
828
|
-
// body even when `memberValue` is empty/null.
|
|
978
|
+
// Caller filter and the member's own `'read'` policy, compiled
|
|
979
|
+
// separately (v2.3.0) so `_ALL` can keep the policy outside its
|
|
980
|
+
// negation. `_SOME`/`_NONE`/`_SINGLE` use the stitched form, which
|
|
981
|
+
// is byte-identical to the pre-2.3.0 single compile.
|
|
829
982
|
const memberWhere = memberValue;
|
|
830
|
-
const
|
|
831
|
-
|
|
832
|
-
|
|
833
|
-
|
|
834
|
-
|
|
835
|
-
|
|
836
|
-
|
|
837
|
-
|
|
838
|
-
|
|
839
|
-
|
|
840
|
-
|
|
841
|
-
|
|
842
|
-
|
|
983
|
+
const body = this.compileTargetBody(memberWhere, relVar, memberDef, counter, policyContext);
|
|
984
|
+
const inner = stitchUserAndPolicy(body.user, body.policy);
|
|
985
|
+
if (inner)
|
|
986
|
+
(0, validation_1.mergeParams)(allParams, body.params);
|
|
987
|
+
const preludeFragment = body.preludes.length
|
|
988
|
+
? ` ${body.preludes.join(' ')}`
|
|
989
|
+
: '';
|
|
990
|
+
members.push({
|
|
991
|
+
memberKey,
|
|
992
|
+
pattern,
|
|
993
|
+
relVar,
|
|
994
|
+
inner,
|
|
995
|
+
user: body.user,
|
|
996
|
+
policy: body.policy,
|
|
997
|
+
preludeFragment,
|
|
998
|
+
});
|
|
843
999
|
}
|
|
844
1000
|
if (members.length === 0)
|
|
845
1001
|
return { cypher: '', params: {} };
|
|
@@ -866,9 +1022,12 @@ class WhereCompiler {
|
|
|
866
1022
|
// satisfied (same as the non-union empty-inner short-circuit),
|
|
867
1023
|
// and union members absent from the input are unconstrained —
|
|
868
1024
|
// consistent with `_SOME`, where only mentioned members count.
|
|
1025
|
+
// v2.3.0 — only a VISIBLE member node (its policy holds) can fail
|
|
1026
|
+
// the predicate; a member with no caller filter is vacuous even
|
|
1027
|
+
// when it has a policy (pre-2.3.0 its hidden nodes falsified it).
|
|
869
1028
|
const failClauses = members
|
|
870
|
-
.filter((m) => m.
|
|
871
|
-
.map((m) => `NOT EXISTS { MATCH ${m.pattern}${m.preludeFragment} WHERE
|
|
1029
|
+
.filter((m) => m.user)
|
|
1030
|
+
.map((m) => `NOT EXISTS { MATCH ${m.pattern}${m.preludeFragment} WHERE ${stitchAllCounterexample(m.user, m.policy)} }`);
|
|
872
1031
|
if (failClauses.length === 0)
|
|
873
1032
|
return { cypher: '', params: {} };
|
|
874
1033
|
return {
|
|
@@ -901,11 +1060,105 @@ class WhereCompiler {
|
|
|
901
1060
|
return null;
|
|
902
1061
|
}
|
|
903
1062
|
}
|
|
1063
|
+
/**
|
|
1064
|
+
* Compile `<key>: null` on a node (v2.3.0). The operator suffix is split
|
|
1065
|
+
* off FIRST: pre-2.3.0 the whole key was treated as a property name, so
|
|
1066
|
+
* the common "is not null" idiom `{ deletedAt_NOT: null }` compiled to
|
|
1067
|
+
* `n.deletedAt_NOT IS NULL` — always true, silently matching every row
|
|
1068
|
+
* (and silently restricting nothing when used in a restrictive policy).
|
|
1069
|
+
*
|
|
1070
|
+
* field: null → field IS NULL
|
|
1071
|
+
* field_NOT: null → field IS NOT NULL
|
|
1072
|
+
* rel: null → NOT EXISTS { MATCH (n)-[:REL]->(…) }
|
|
1073
|
+
* rel_NOT: null → EXISTS { MATCH (n)-[:REL]->(…) }
|
|
1074
|
+
* any other operator → OGMError (null has no meaning there)
|
|
1075
|
+
* undeclared field → OGMError regardless of `strictWhere` — a null
|
|
1076
|
+
* filter on an undeclared field matches every
|
|
1077
|
+
* row (fail OPEN), unlike a non-null one
|
|
1078
|
+
*
|
|
1079
|
+
* A field literally named `key` always wins over suffix parsing.
|
|
1080
|
+
*/
|
|
1081
|
+
compileNullCondition(key, nodeVar, nodeDef, counter, scope) {
|
|
1082
|
+
if (nodeDef.relationships.has(key))
|
|
1083
|
+
return this.compileRelationshipExistence(key, nodeVar, nodeDef, counter, false);
|
|
1084
|
+
if (!nodeDef.properties.has(key))
|
|
1085
|
+
for (const suffix of RELATIONSHIP_SUFFIXES) {
|
|
1086
|
+
if (!key.endsWith(suffix))
|
|
1087
|
+
continue;
|
|
1088
|
+
const field = key.slice(0, -suffix.length);
|
|
1089
|
+
if (!nodeDef.relationships.has(field))
|
|
1090
|
+
continue;
|
|
1091
|
+
if (suffix === '_NOT')
|
|
1092
|
+
return this.compileRelationshipExistence(field, nodeVar, nodeDef, counter, true);
|
|
1093
|
+
throw new errors_1.OGMError(`Relationship filter "${key}" cannot be null. Use "${field}: null" ` +
|
|
1094
|
+
`(no related node) or "${field}_NOT: null" (at least one related node).`);
|
|
1095
|
+
}
|
|
1096
|
+
return this.compileNullScalar(key, nodeVar, nodeDef, scope);
|
|
1097
|
+
}
|
|
1098
|
+
/**
|
|
1099
|
+
* Scalar half of the null semantics above, shared by node properties and
|
|
1100
|
+
* relationship-edge properties: `IS NULL` / `IS NOT NULL`, or an
|
|
1101
|
+
* `OGMError` for any other operator and for undeclared fields.
|
|
1102
|
+
*/
|
|
1103
|
+
compileNullScalar(key, varName, propsHolder, scope) {
|
|
1104
|
+
if (propsHolder?.properties.has(key)) {
|
|
1105
|
+
(0, validation_1.assertSafeIdentifier)(key, 'where clause');
|
|
1106
|
+
// `@cypher` scalars project through the scope (NULL check on the alias).
|
|
1107
|
+
return `${this.resolveFieldRef(key, varName, propsHolder, scope)} IS NULL`;
|
|
1108
|
+
}
|
|
1109
|
+
for (const suffix of OPERATOR_SUFFIXES) {
|
|
1110
|
+
if (!key.endsWith(suffix))
|
|
1111
|
+
continue;
|
|
1112
|
+
const field = key.slice(0, -suffix.length);
|
|
1113
|
+
if (!propsHolder?.properties.has(field))
|
|
1114
|
+
continue;
|
|
1115
|
+
if (suffix !== '_NOT')
|
|
1116
|
+
throw new errors_1.OGMError(`Operator "${suffix}" cannot be used with null ("${key}"). Use ` +
|
|
1117
|
+
`"${field}: null" (IS NULL) or "${field}_NOT: null" (IS NOT NULL) to test for null.`);
|
|
1118
|
+
(0, validation_1.assertSafeIdentifier)(field, 'where clause');
|
|
1119
|
+
return `${this.resolveFieldRef(field, varName, propsHolder, scope)} IS NOT NULL`;
|
|
1120
|
+
}
|
|
1121
|
+
throw new errors_1.OGMError(`Unknown field "${key}" in where clause with a null value. A null ` +
|
|
1122
|
+
`filter on a field the type does not declare would match every row, ` +
|
|
1123
|
+
`so it is rejected (regardless of strictWhere). Check for typos.`);
|
|
1124
|
+
}
|
|
1125
|
+
/**
|
|
1126
|
+
* `NOT EXISTS` (no related node) / `EXISTS` (at least one) for a
|
|
1127
|
+
* relationship null filter. Byte-identical to the pre-2.3.0 `rel: null`
|
|
1128
|
+
* emission for the `NOT EXISTS` case.
|
|
1129
|
+
*/
|
|
1130
|
+
compileRelationshipExistence(field, nodeVar, nodeDef, counter, exists) {
|
|
1131
|
+
const relDef = nodeDef.relationships.get(field);
|
|
1132
|
+
if (!(0, utils_1.resolveTargetDef)(relDef.target, this.schema))
|
|
1133
|
+
throw new errors_1.OGMError(`Cannot resolve the target type "${relDef.target}" of relationship "${field}".`);
|
|
1134
|
+
const relVar = `r${counter.count}`;
|
|
1135
|
+
counter.count++;
|
|
1136
|
+
const pattern = (0, utils_1.buildRelPattern)({
|
|
1137
|
+
sourceVar: nodeVar,
|
|
1138
|
+
relDef,
|
|
1139
|
+
targetVar: relVar,
|
|
1140
|
+
targetLabel: 'auto',
|
|
1141
|
+
// Pass schema so that union/interface targets resolve to a
|
|
1142
|
+
// labelless target (relationship-type-only filter). Without this,
|
|
1143
|
+
// the literal abstract type name is escaped as a label that no
|
|
1144
|
+
// concrete node carries → NOT EXISTS is true for every row → ALL
|
|
1145
|
+
// rows match.
|
|
1146
|
+
schema: this.schema,
|
|
1147
|
+
});
|
|
1148
|
+
return exists
|
|
1149
|
+
? `EXISTS { MATCH ${pattern} }`
|
|
1150
|
+
: `NOT EXISTS { MATCH ${pattern} }`;
|
|
1151
|
+
}
|
|
904
1152
|
compileEdgeConditions(edgeWhere, edgeVar, propsDef, edgeScope, counter) {
|
|
905
1153
|
const clauses = [];
|
|
906
1154
|
const params = {};
|
|
907
1155
|
for (const [key, value] of Object.entries(edgeWhere)) {
|
|
908
1156
|
(0, validation_1.assertSafeKey)(key, 'edge where input');
|
|
1157
|
+
// Same null semantics as node properties (v2.3.0).
|
|
1158
|
+
if (value === null) {
|
|
1159
|
+
clauses.push(this.compileNullScalar(key, edgeVar, propsDef, edgeScope));
|
|
1160
|
+
continue;
|
|
1161
|
+
}
|
|
909
1162
|
const result = this.compileScalarCondition(key, value, edgeVar, propsDef, edgeScope, counter);
|
|
910
1163
|
clauses.push(result.cypher);
|
|
911
1164
|
(0, validation_1.mergeParams)(params, result.params);
|
|
@@ -1005,6 +1258,102 @@ function stitchUserAndPolicy(userBody, policyClause) {
|
|
|
1005
1258
|
return userBody;
|
|
1006
1259
|
return `(${userBody}) AND ${policyClause}`;
|
|
1007
1260
|
}
|
|
1261
|
+
/**
|
|
1262
|
+
* `_ALL` body (v2.3.0): a related node the caller may SEE (`policy`) that
|
|
1263
|
+
* fails the caller's filter. The policy sits OUTSIDE the negation, so
|
|
1264
|
+
* hidden related nodes neither satisfy nor falsify the quantifier.
|
|
1265
|
+
*/
|
|
1266
|
+
function stitchAllCounterexample(userBody, policyClause) {
|
|
1267
|
+
return policyClause
|
|
1268
|
+
? `${policyClause} AND NOT (${userBody})`
|
|
1269
|
+
: `NOT (${userBody})`;
|
|
1270
|
+
}
|
|
1271
|
+
/**
|
|
1272
|
+
* The bundle a traversal's USER filter compiles under (v2.3.0): no clause
|
|
1273
|
+
* for the target itself (its policy is composed separately by the caller),
|
|
1274
|
+
* but `resolveForType` stays live so deeper traversals keep enforcing.
|
|
1275
|
+
*
|
|
1276
|
+
* @internal
|
|
1277
|
+
*/
|
|
1278
|
+
function traversalBundle(policyContext) {
|
|
1279
|
+
return { ...policyContext, operation: 'read', resolved: types_1.NO_ROOT_POLICY };
|
|
1280
|
+
}
|
|
1281
|
+
/**
|
|
1282
|
+
* Compose compiled policy fragments into the single boolean clause
|
|
1283
|
+
* `(P1 OR P2 …) AND R1 AND R2 …` that `compile()` AND-stitches into the
|
|
1284
|
+
* WHERE. This is the ONLY place that composition happens: `compile()`
|
|
1285
|
+
* stitches its output, and `Model.explainPolicies` projects the very
|
|
1286
|
+
* same string as its enforcement verdict — so the two cannot disagree.
|
|
1287
|
+
*/
|
|
1288
|
+
function composePolicyClause(fragments) {
|
|
1289
|
+
// Default-deny: no permissives matched. The Model call site is
|
|
1290
|
+
// responsible for raising `PolicyDeniedError` BEFORE compile when
|
|
1291
|
+
// `defaults.onDeny === 'throw'`. At compile time we always fall
|
|
1292
|
+
// back to `false` so the query is safe even if the call-site
|
|
1293
|
+
// throw is bypassed (defense in depth, not the primary path).
|
|
1294
|
+
if (fragments.permissives.length === 0)
|
|
1295
|
+
return 'false';
|
|
1296
|
+
const permFrags = fragments.permissives.flatMap((c) => c.parts);
|
|
1297
|
+
const restFrags = fragments.restrictives.flatMap((c) => c.parts);
|
|
1298
|
+
// At least one permissive matched. Compose `(perm) AND (rest)`.
|
|
1299
|
+
//
|
|
1300
|
+
// SECURITY FIX (v1.8.2 — CRITICAL): Pre-1.8.2 emitted `'true'` here
|
|
1301
|
+
// when `permFrags.length === 0`, on the assumption that an empty
|
|
1302
|
+
// permFrags meant "every permissive returned an empty partial,
|
|
1303
|
+
// which is match-anything". That assumption was WRONG and silently
|
|
1304
|
+
// inverted the deny default in three documented patterns:
|
|
1305
|
+
//
|
|
1306
|
+
// permissive: [{ when: (ctx) => ctx.userId ? {...} : null }]
|
|
1307
|
+
// ^^^ abstain
|
|
1308
|
+
// permissive: [{ when: (ctx) => undefined }]
|
|
1309
|
+
// permissive: [{ cypher: { fragment: () => '', params: ... } }]
|
|
1310
|
+
//
|
|
1311
|
+
// In every case, `permFrags` ended up empty and the compiler emitted
|
|
1312
|
+
// `WHERE true`, dumping unrestricted data. The bug survived TS
|
|
1313
|
+
// checks, code review, and the v1.7.0 NLS audit because "abstain"
|
|
1314
|
+
// and "match-anything" looked indistinguishable at this layer.
|
|
1315
|
+
//
|
|
1316
|
+
// Permissives are an ALLOW-LIST. If no rule fires, access is DENIED.
|
|
1317
|
+
// The explicit "match-anything" path is preserved: when a developer
|
|
1318
|
+
// writes `when: () => ({})`, `compilePolicyFragments` still pushes
|
|
1319
|
+
// `'true'` as that permissive's part, so permFrags.length > 0 and we
|
|
1320
|
+
// take the else branch. The migration path for users relying on the
|
|
1321
|
+
// old abstain-as-allow behaviour is to write `when: () => ({})`
|
|
1322
|
+
// instead of `when: () => null`.
|
|
1323
|
+
const permClause = permFrags.length === 0
|
|
1324
|
+
? 'false'
|
|
1325
|
+
: permFrags.length === 1
|
|
1326
|
+
? permFrags[0]
|
|
1327
|
+
: `(${permFrags.join(' OR ')})`;
|
|
1328
|
+
const restClause = restFrags.length === 0
|
|
1329
|
+
? 'true'
|
|
1330
|
+
: restFrags.length === 1
|
|
1331
|
+
? restFrags[0]
|
|
1332
|
+
: restFrags.join(' AND ');
|
|
1333
|
+
// Avoid the trivial `(... AND true)` formulation when there are no
|
|
1334
|
+
// restrictives — keeps emitted Cypher tighter and the byte-
|
|
1335
|
+
// identical regression cleaner.
|
|
1336
|
+
return restClause === 'true'
|
|
1337
|
+
? permClause
|
|
1338
|
+
: `(${permClause} AND ${restClause})`;
|
|
1339
|
+
}
|
|
1340
|
+
/**
|
|
1341
|
+
* The boolean value ONE compiled policy contributes to
|
|
1342
|
+
* `composePolicyClause`, as a standalone expression (explain path): a
|
|
1343
|
+
* permissive's parts are OR-ed — they join the permissive disjunction —
|
|
1344
|
+
* and a restrictive's parts are AND-ed — they join the restrictive
|
|
1345
|
+
* conjunction. OR/AND are associative in Cypher's three-valued logic,
|
|
1346
|
+
* so grouping per policy preserves the composed clause's truth value.
|
|
1347
|
+
*
|
|
1348
|
+
* Returns `null` when the policy has no parts (it abstained).
|
|
1349
|
+
*/
|
|
1350
|
+
function policyValueExpression(kind, parts) {
|
|
1351
|
+
if (parts.length === 0)
|
|
1352
|
+
return null;
|
|
1353
|
+
if (parts.length === 1)
|
|
1354
|
+
return parts[0];
|
|
1355
|
+
return `(${parts.join(kind === 'permissive' ? ' OR ' : ' AND ')})`;
|
|
1356
|
+
}
|
|
1008
1357
|
/**
|
|
1009
1358
|
* Namespace raw `cypher.params` keys with the given prefix and produce a
|
|
1010
1359
|
* lookup map old-name → new-name. The fragment text is rewritten with
|