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