grafeo-ogm 1.13.0 → 1.14.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (42) hide show
  1. package/CHANGELOG.md +42 -0
  2. package/dist/cjs/compilers/mutation.compiler.d.ts +54 -1
  3. package/dist/cjs/compilers/mutation.compiler.d.ts.map +1 -1
  4. package/dist/cjs/compilers/mutation.compiler.js +134 -32
  5. package/dist/cjs/compilers/mutation.compiler.js.map +1 -1
  6. package/dist/cjs/compilers/where.compiler.d.ts.map +1 -1
  7. package/dist/cjs/compilers/where.compiler.js +7 -0
  8. package/dist/cjs/compilers/where.compiler.js.map +1 -1
  9. package/dist/cjs/interface-model.d.ts.map +1 -1
  10. package/dist/cjs/interface-model.js +9 -1
  11. package/dist/cjs/interface-model.js.map +1 -1
  12. package/dist/cjs/model.d.ts.map +1 -1
  13. package/dist/cjs/model.js +12 -3
  14. package/dist/cjs/model.js.map +1 -1
  15. package/dist/cjs/ogm.d.ts.map +1 -1
  16. package/dist/cjs/ogm.js +6 -1
  17. package/dist/cjs/ogm.js.map +1 -1
  18. package/dist/cjs/utils/validation.d.ts +7 -0
  19. package/dist/cjs/utils/validation.d.ts.map +1 -1
  20. package/dist/cjs/utils/validation.js +421 -0
  21. package/dist/cjs/utils/validation.js.map +1 -1
  22. package/dist/esm/compilers/mutation.compiler.d.ts +54 -1
  23. package/dist/esm/compilers/mutation.compiler.d.ts.map +1 -1
  24. package/dist/esm/compilers/mutation.compiler.js +134 -32
  25. package/dist/esm/compilers/mutation.compiler.js.map +1 -1
  26. package/dist/esm/compilers/where.compiler.d.ts.map +1 -1
  27. package/dist/esm/compilers/where.compiler.js +8 -1
  28. package/dist/esm/compilers/where.compiler.js.map +1 -1
  29. package/dist/esm/interface-model.d.ts.map +1 -1
  30. package/dist/esm/interface-model.js +9 -1
  31. package/dist/esm/interface-model.js.map +1 -1
  32. package/dist/esm/model.d.ts.map +1 -1
  33. package/dist/esm/model.js +12 -3
  34. package/dist/esm/model.js.map +1 -1
  35. package/dist/esm/ogm.d.ts.map +1 -1
  36. package/dist/esm/ogm.js +6 -1
  37. package/dist/esm/ogm.js.map +1 -1
  38. package/dist/esm/utils/validation.d.ts +7 -0
  39. package/dist/esm/utils/validation.d.ts.map +1 -1
  40. package/dist/esm/utils/validation.js +420 -0
  41. package/dist/esm/utils/validation.js.map +1 -1
  42. package/package.json +1 -1
package/CHANGELOG.md CHANGED
@@ -1,5 +1,47 @@
1
1
  # Changelog
2
2
 
3
+ ## 1.14.0 (2026-07-24) — 🛡️ Security release: NLS enforcement on interface reads & relationship writes, and a `_MATCHES` ReDoS guard
4
+
5
+ > **Three security fixes.** Two are object-level authorization gaps: grafeo's node-level-security (NLS) policies were threaded correctly through the concrete-model read and write paths, but two paths silently skipped the **target type's** policy — nested relationship projections on **interface** reads, and **connect/disconnect** relationship writes — so a caller whose own access was correctly scoped could still read, or link/unlink, related nodes their policy forbids. The third hardens the `_MATCHES` filter operator against server-side ReDoS. If you use `policies` with interface models or nested `connect`/`disconnect`, or expose `_MATCHES` to untrusted input, upgrade.
6
+
7
+ ### 🛡️ Interface reads now apply the target type's `read` policy to nested projections
8
+
9
+ `InterfaceModel.find` (and its `findFirst` / `findUnique` / `*OrThrow` siblings, which all route through it) applied the interface's source-side policy and the WHERE-side traversal policy — but compiled the **RETURN projection** without the read `policyContext` that `Model.find` passes. Every nested relationship pattern comprehension was therefore emitted with **no target-type policy**, so an interface query could project fields of related, policy-protected node types (e.g. another tenant's `author { email }`) that the target type's `read` policy is meant to filter out. The leak was silent and spanned every row the interface query returned.
10
+
11
+ The fix threads the resolved read `policyContext` into the interface selection compile, exactly as the concrete-model path already does, so every nested projection AND-stitches the target type's read policy — including union and connection branches. **When no policy layer is configured the emitted Cypher is byte-identical** to before; the guard engages only when a policy actually resolves for the projected type.
12
+
13
+ ### 🛡️ Nested `connect` / `disconnect` now enforce the target type's `read` policy
14
+
15
+ `MutationCompiler` was policy-unaware: the target `MATCH` emitted for a nested `connect` (MERGE) or `disconnect` selected the target node by the caller's `where` alone, with no policy. A caller operating under a bound `withContext(ctx)` — whose *source* node was correctly policy-filtered — could therefore link or unlink relationships to arbitrary target nodes their policy should forbid (cross-tenant / cross-owner relationship writes):
16
+
17
+ ```typescript
18
+ // `Book` has a read policy scoped to the owner. Previously this linked a
19
+ // foreign Book anyway — the target MATCH carried no Book policy. Now it
20
+ // AND-stitches the Book read policy, so an unreadable target fails to
21
+ // match and the MERGE is a no-op.
22
+ ogm.withContext({ uid }).model('User').update({
23
+ where: { id: myId },
24
+ connect: { ownedBooks: { where: { id: someBookId } } },
25
+ });
26
+ ```
27
+
28
+ The fix threads the resolved policy context into `MutationCompiler` and AND-stitches the target type's `read` policy into every connect/disconnect target `MATCH` — all seven sites (single / array / `UNWIND` connect, specific disconnect, nested-in-update connect and disconnect, union targets), reached from both `update` and `updateMany`. `read` is the correct gate: row-level target visibility is expressed as the `read` policy, while `create`/`update` restrictives are WITH-CHECK predicates over a mutation-input bag that a referenced existing node has none of. **No-policy, bypass (`unsafe.bypassPolicies`), and no-target-policy paths stay byte-identical**; `create` and `upsert` (which never emits a relationship `MATCH`) are untouched. Policy predicates use the mutation's shared, monotonic parameter counter, so they never collide with the caller's `connect`/`disconnect` `where` params.
29
+
30
+ ### 🛡️ `_MATCHES` is now guarded against catastrophic-backtracking regexes (ReDoS)
31
+
32
+ The `_MATCHES` filter operator forwards its value into a Neo4j `=~` predicate, which the server evaluates with Java's **backtracking** regex engine. An attacker able to influence a `where` filter could supply a catastrophic pattern (e.g. `(a+)+$`, `([a-z]+)+$`, `(a?)+$`) and pin shared Neo4j CPU — a server-side ReDoS ([CWE-1333](https://cwe.mitre.org/data/definitions/1333.html)). Parameterization stops injection but not this: the payload *is* the regex.
33
+
34
+ Every `_MATCHES` value is now validated at compile time by a **sound-by-construction** guard. Rather than blocklisting known-bad shapes (always incomplete), it accepts a pattern **only when it can prove the pattern is linear**, and refuses everything it cannot model — alternation, quantified groups, lookaround, backreferences, unsupported escapes. The check is single-pass and bounded, so it cannot itself become a DoS vector, and Java-flavor divergences are modeled soundly (`\v` is the vertical-whitespace class; `\0`/octal escapes are refused; only inline flags that don't change the analysis — `i`/`m`/`s`/`d` — are honored, so `(?x)` COMMENTS mode and `(?u)`/`(?U)` Unicode widening are refused).
35
+
36
+ - **Precision cost (intended, fail-closed).** Some linear-but-unprovable patterns are refused — notably **all alternation** (`(cat|dog)`) and **contains-style** `.*x.*` shapes. Reach for the dedicated, always-safe operators (`_CONTAINS` / `_STARTS_WITH` / `_ENDS_WITH` / `_IN`), simplify the pattern, or disable `_MATCHES` entirely via `features.filters.String.MATCHES` if you must run arbitrary regexes and accept the risk. The refusal error names these options.
37
+ - **Common patterns keep working** — anchors, character classes, single wildcards (`^foo.*bar$`, `[A-Za-z0-9_]+`, `\d{3}-\d{4}`), fenced quantifiers (`[a-z]+@[a-z]+\.[a-z]+`), and inline-flag prefixes (`(?i)…`).
38
+
39
+ ### Tests
40
+
41
+ Interface-read policy: two specs asserting the nested projection now carries the target `read` predicate for interface reads, and that the no-policy path stays byte-identical. Connect/disconnect policy: six specs covering connect and disconnect target MATCHes carrying the target `read` policy, the update-op-not-stitched case, byte-identical no-policy/bypass Cypher, and non-colliding parameter numbering. ReDoS guard: a 100+-case battery covering every known catastrophic family (nested quantifiers, nullable/overlapping alternation under repeat, custom-class and nested-group bases, unrolled adjacent optionals), fail-closed refusal of unmodeled constructs, acceptance of provably-linear patterns, and the three Java-flavor soundness regressions found and closed during adversarial review (`(?x)`, `\v`, `\0`). Full suite green (1629); lint and format clean.
42
+
43
+ ---
44
+
3
45
  ## 1.13.0 (2026-07-14) — ✨ `$cloneSubgraph` returns `nodesByLabel`
4
46
 
5
47
  > **First community-requested feature** ([#1](https://github.com/neomodular/grafeo-ogm/issues/1)). After deep-cloning a content subgraph, a caller often needs to post-process only the nodes of a **specific label** (re-normalize, re-stamp, re-index). Until now `nodeMapping` held every cloned id across all labels, forcing a broad-`IN`-plus-label-filter dance on the caller. The clone already visits every node with its labels in hand — so now it just tells you.
@@ -1,5 +1,7 @@
1
1
  import type { OGMLogger } from '../execution/executor';
2
+ import type { PolicyContextBundle } from '../policy/types';
2
3
  import { NodeDefinition, SchemaMetadata } from '../schema/types';
4
+ import { WhereCompiler } from './where.compiler';
3
5
  export interface MutationResult {
4
6
  cypher: string;
5
7
  params: Record<string, unknown>;
@@ -15,9 +17,44 @@ export declare class MutationCompiler {
15
17
  */
16
18
  private labelCache;
17
19
  private logger?;
20
+ /**
21
+ * WhereCompiler used ONLY to compile a relationship TARGET type's
22
+ * `read` policy predicate for connect/disconnect target MATCHes,
23
+ * mirroring the read paths (`WhereCompiler`/`SelectionCompiler`).
24
+ * Injected by the OGM so it shares the user's compiler options;
25
+ * lazily constructed with default options otherwise.
26
+ */
27
+ private policyWhereCompiler?;
18
28
  constructor(schema: SchemaMetadata, options?: {
19
29
  logger?: OGMLogger;
30
+ whereCompiler?: WhereCompiler;
20
31
  });
32
+ /**
33
+ * Resolve and compile the TARGET type's `read` policy predicate for a
34
+ * connect/disconnect target MATCH, mirroring the read path
35
+ * (`WhereCompiler.buildTargetBundle` + `WhereCompiler.compile`). You
36
+ * must be allowed to SEE a node to link/unlink it, so linking to an
37
+ * unreadable node is the IDOR this closes; the codebase models the
38
+ * row-level write gate for `update`/`delete` targets as `read`
39
+ * ReadRestrictive policies (see policy/types.ts), so `read` is both
40
+ * necessary and the semantically correct operation here.
41
+ *
42
+ * Returns `null` — leaving the emitted Cypher BYTE-IDENTICAL — when:
43
+ * - no policy context is threaded (no policy bound / bypass active), OR
44
+ * - no `paramCounter` is available to allocate policy params, OR
45
+ * - the target type has no policy applicable to `read`, OR
46
+ * - the resolved policy is `overridden` (compiles to nothing).
47
+ *
48
+ * When a target policy resolves, the compiled predicate is returned and
49
+ * its bound params are merged into `params`. Policy params use the
50
+ * shared `paramCounter` (`param<N>`), continuing the sequence started by
51
+ * the top-level WHERE, so they can never collide with the
52
+ * connect/disconnect `where` params (prefix-named, e.g.
53
+ * `connect_<field>_<prop>`) or the selection's params (allocated later
54
+ * from the same counter).
55
+ */
56
+ private buildTargetPolicyPredicate;
57
+ private getPolicyWhereCompiler;
21
58
  /**
22
59
  * Surface a logical operator that contributed zero effective conditions.
23
60
  * Mirrors WhereCompiler.warnEmptyLogical — fires only on the anomalous
@@ -39,7 +76,23 @@ export declare class MutationCompiler {
39
76
  cypher: string;
40
77
  params: Record<string, unknown>;
41
78
  preludes?: string[];
42
- }, labels?: string[], returnMode?: 'node' | 'count'): MutationResult;
79
+ }, labels?: string[], returnMode?: 'node' | 'count',
80
+ /**
81
+ * The caller's resolved policy bundle for this mutation (the SOURCE
82
+ * type's `update` bundle). Its `resolveForType` is used to resolve
83
+ * each connect/disconnect TARGET type's `read` policy. `undefined`
84
+ * when no policy is bound or a bypass is active — in which case the
85
+ * emitted Cypher is byte-identical to before this enforcement.
86
+ */
87
+ policyContext?: PolicyContextBundle,
88
+ /**
89
+ * Shared `param<N>` counter, continued from the top-level WHERE
90
+ * compile so target-policy params never collide with connect/
91
+ * disconnect `where` params or the selection's params.
92
+ */
93
+ paramCounter?: {
94
+ count: number;
95
+ }): MutationResult;
43
96
  /**
44
97
  * Generate DELETE Cypher with optional cascade.
45
98
  */
@@ -1 +1 @@
1
- {"version":3,"file":"mutation.compiler.d.ts","sourceRoot":"","sources":["../../../src/compilers/mutation.compiler.ts"],"names":[],"mappings":"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"}
1
+ {"version":3,"file":"mutation.compiler.d.ts","sourceRoot":"","sources":["../../../src/compilers/mutation.compiler.ts"],"names":[],"mappings":"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"}
@@ -4,6 +4,7 @@ exports.MutationCompiler = void 0;
4
4
  const errors_1 = require("../errors");
5
5
  const utils_1 = require("../schema/utils");
6
6
  const validation_1 = require("../utils/validation");
7
+ const where_compiler_1 = require("./where.compiler");
7
8
  /**
8
9
  * Relationship filter suffixes that require EXISTS subquery patterns.
9
10
  * These match related nodes via relationship traversal rather than scalar property comparison.
@@ -23,6 +24,59 @@ class MutationCompiler {
23
24
  /** Counter for unique relationship filter variables within a single WHERE clause. */
24
25
  this.relFilterCounter = 0;
25
26
  this.logger = options?.logger;
27
+ this.policyWhereCompiler = options?.whereCompiler;
28
+ }
29
+ /**
30
+ * Resolve and compile the TARGET type's `read` policy predicate for a
31
+ * connect/disconnect target MATCH, mirroring the read path
32
+ * (`WhereCompiler.buildTargetBundle` + `WhereCompiler.compile`). You
33
+ * must be allowed to SEE a node to link/unlink it, so linking to an
34
+ * unreadable node is the IDOR this closes; the codebase models the
35
+ * row-level write gate for `update`/`delete` targets as `read`
36
+ * ReadRestrictive policies (see policy/types.ts), so `read` is both
37
+ * necessary and the semantically correct operation here.
38
+ *
39
+ * Returns `null` — leaving the emitted Cypher BYTE-IDENTICAL — when:
40
+ * - no policy context is threaded (no policy bound / bypass active), OR
41
+ * - no `paramCounter` is available to allocate policy params, OR
42
+ * - the target type has no policy applicable to `read`, OR
43
+ * - the resolved policy is `overridden` (compiles to nothing).
44
+ *
45
+ * When a target policy resolves, the compiled predicate is returned and
46
+ * its bound params are merged into `params`. Policy params use the
47
+ * shared `paramCounter` (`param<N>`), continuing the sequence started by
48
+ * the top-level WHERE, so they can never collide with the
49
+ * connect/disconnect `where` params (prefix-named, e.g.
50
+ * `connect_<field>_<prop>`) or the selection's params (allocated later
51
+ * from the same counter).
52
+ */
53
+ buildTargetPolicyPredicate(targetNodeDef, targetVar, params, policyContext, paramCounter) {
54
+ if (!policyContext || !paramCounter)
55
+ return null;
56
+ const targetPolicy = policyContext.resolveForType(targetNodeDef.typeName, 'read');
57
+ if (!targetPolicy)
58
+ return null;
59
+ const targetBundle = {
60
+ ctx: policyContext.ctx,
61
+ operation: 'read',
62
+ resolved: targetPolicy,
63
+ resolveForType: policyContext.resolveForType,
64
+ defaults: policyContext.defaults,
65
+ };
66
+ const compiled = this.getPolicyWhereCompiler().compile(undefined, targetVar, targetNodeDef, paramCounter, { policyContext: targetBundle });
67
+ if (compiled.preludes && compiled.preludes.length > 0)
68
+ throw new errors_1.OGMError(`Policy on "${targetNodeDef.typeName}" requires @cypher field ` +
69
+ `projection, which is not supported inside connect/disconnect ` +
70
+ `target filters. Refactor the policy to use stored properties.`);
71
+ if (!compiled.cypher)
72
+ return null;
73
+ (0, validation_1.mergeParams)(params, compiled.params);
74
+ return compiled.cypher;
75
+ }
76
+ getPolicyWhereCompiler() {
77
+ if (!this.policyWhereCompiler)
78
+ this.policyWhereCompiler = new where_compiler_1.WhereCompiler(this.schema);
79
+ return this.policyWhereCompiler;
26
80
  }
27
81
  /**
28
82
  * Surface a logical operator that contributed zero effective conditions.
@@ -67,7 +121,11 @@ class MutationCompiler {
67
121
  // Process relationship operations (nested create / connect)
68
122
  // Pass all previously created node vars as ancestors so WITH clauses
69
123
  // don't drop them from scope (needed for RETURN n at the end)
70
- const relLines = this.buildCreateRelationships(input, nodeDef, nodeVar, prefix, params, createdVars.slice(0, -1));
124
+ const relLines = this.buildCreateRelationships(input, nodeDef, nodeVar, prefix, params, createdVars.slice(0, -1), // ancestors = all vars before current
125
+ // No policy context is threaded on the create path: `create`'s
126
+ // nested connect enforcement is out of scope for this fix, so
127
+ // the emitted create Cypher stays byte-identical.
128
+ undefined, undefined);
71
129
  lines.push(...relLines);
72
130
  }
73
131
  // Add extra labels to all created nodes
@@ -82,7 +140,21 @@ class MutationCompiler {
82
140
  /**
83
141
  * Generate UPDATE Cypher (SET properties + connect/disconnect).
84
142
  */
85
- compileUpdate(_where, update, connect, disconnect, nodeDef, whereResult, labels, returnMode = 'node') {
143
+ compileUpdate(_where, update, connect, disconnect, nodeDef, whereResult, labels, returnMode = 'node',
144
+ /**
145
+ * The caller's resolved policy bundle for this mutation (the SOURCE
146
+ * type's `update` bundle). Its `resolveForType` is used to resolve
147
+ * each connect/disconnect TARGET type's `read` policy. `undefined`
148
+ * when no policy is bound or a bypass is active — in which case the
149
+ * emitted Cypher is byte-identical to before this enforcement.
150
+ */
151
+ policyContext,
152
+ /**
153
+ * Shared `param<N>` counter, continued from the top-level WHERE
154
+ * compile so target-policy params never collide with connect/
155
+ * disconnect `where` params or the selection's params.
156
+ */
157
+ paramCounter) {
86
158
  this.relFilterCounter = 0;
87
159
  const labelStr = this.getCachedLabelString(nodeDef);
88
160
  const lines = [];
@@ -117,18 +189,18 @@ class MutationCompiler {
117
189
  if (setClauses.length > 0)
118
190
  lines.push(`SET ${setClauses.join(', ')}`);
119
191
  // Process nested relationship operations within update body
120
- const relLines = this.buildUpdateRelationships(update, nodeDef, 'n', 'update', params);
192
+ const relLines = this.buildUpdateRelationships(update, nodeDef, 'n', 'update', params, 0, [], policyContext, paramCounter);
121
193
  lines.push(...relLines);
122
194
  }
123
195
  // Disconnect relationships (top-level) — must run before connects
124
196
  // so that blanket disconnects don't remove newly-connected relationships.
125
197
  if (disconnect) {
126
- const disconnectLines = this.buildDisconnects(disconnect, nodeDef, params);
198
+ const disconnectLines = this.buildDisconnects(disconnect, nodeDef, params, policyContext, paramCounter);
127
199
  lines.push(...disconnectLines);
128
200
  }
129
201
  // Connect relationships (top-level)
130
202
  if (connect) {
131
- const connectLines = this.buildConnects(connect, nodeDef, params);
203
+ const connectLines = this.buildConnects(connect, nodeDef, params, policyContext, paramCounter);
132
204
  lines.push(...connectLines);
133
205
  }
134
206
  lines.push(returnMode === 'count' ? 'RETURN count(n) AS count' : 'RETURN n');
@@ -390,7 +462,7 @@ class MutationCompiler {
390
462
  }
391
463
  return { propString: parts.join(', '), propParams };
392
464
  }
393
- buildCreateRelationships(input, nodeDef, nodeVar, prefix, params, ancestorVars = []) {
465
+ buildCreateRelationships(input, nodeDef, nodeVar, prefix, params, ancestorVars = [], policyContext, paramCounter) {
394
466
  const lines = [];
395
467
  let nestedCounter = 0;
396
468
  // WITH clause must carry all ancestors + the node being created on
@@ -415,7 +487,7 @@ class MutationCompiler {
415
487
  continue;
416
488
  const memberInput = memberValue;
417
489
  // Process creates/connects for this union member
418
- const memberLines = this.buildCreateRelationshipsForTarget(memberInput, memberNodeDef, relDef, nodeVar, `${prefix}_${key}_${memberKey}`, params, allVars, withClause, nestedCounter);
490
+ const memberLines = this.buildCreateRelationshipsForTarget(memberInput, memberNodeDef, relDef, nodeVar, `${prefix}_${key}_${memberKey}`, params, allVars, withClause, nestedCounter, policyContext, paramCounter);
419
491
  nestedCounter += memberLines.counterUsed;
420
492
  lines.push(...memberLines.lines);
421
493
  }
@@ -424,7 +496,7 @@ class MutationCompiler {
424
496
  const targetNodeDef = this.schema.nodes.get(relDef.target);
425
497
  if (!targetNodeDef)
426
498
  continue;
427
- const resultLines = this.buildCreateRelationshipsForTarget(relInput, targetNodeDef, relDef, nodeVar, `${prefix}_${key}`, params, allVars, withClause, nestedCounter);
499
+ const resultLines = this.buildCreateRelationshipsForTarget(relInput, targetNodeDef, relDef, nodeVar, `${prefix}_${key}`, params, allVars, withClause, nestedCounter, policyContext, paramCounter);
428
500
  nestedCounter += resultLines.counterUsed;
429
501
  lines.push(...resultLines.lines);
430
502
  }
@@ -434,7 +506,7 @@ class MutationCompiler {
434
506
  * Process create/connect operations for a specific target node type.
435
507
  * Used by buildCreateRelationships for both union-member and non-union targets.
436
508
  */
437
- buildCreateRelationshipsForTarget(relInput, targetNodeDef, relDef, nodeVar, prefix, params, allVars, withClause, startCounter) {
509
+ buildCreateRelationshipsForTarget(relInput, targetNodeDef, relDef, nodeVar, prefix, params, allVars, withClause, startCounter, policyContext, paramCounter) {
438
510
  const lines = [];
439
511
  let counter = 0;
440
512
  const targetLabelStr = this.getCachedLabelString(targetNodeDef);
@@ -450,7 +522,7 @@ class MutationCompiler {
450
522
  counter++;
451
523
  const { propString, propParams } = this.buildCreateProperties(nodeSpec, targetNodeDef, `${prefix}_create${ci}`);
452
524
  // Handle nested relationship creates within the created node
453
- const nestedRelLines = this.buildCreateRelationships(nodeSpec, targetNodeDef, createVar, `${prefix}_create${ci}`, params, allVars);
525
+ const nestedRelLines = this.buildCreateRelationships(nodeSpec, targetNodeDef, createVar, `${prefix}_create${ci}`, params, allVars, policyContext, paramCounter);
454
526
  const propsClause = propString.length > 0
455
527
  ? ` { ${propString} }`
456
528
  : ` { ${this.buildGeneratedIdClause(targetNodeDef)} }`;
@@ -522,8 +594,12 @@ class MutationCompiler {
522
594
  counter++;
523
595
  lines.push(`MATCH (${connectVar}:${targetLabelStr})`);
524
596
  const conditions = this.buildConnectionWhereConditions(whereSpec, connectVar, `${prefix}_conn${ci}`, params, targetNodeDef);
525
- if (conditions.length > 0)
526
- lines.push(`WHERE ${conditions.join(' AND ')}`);
597
+ const policyPredicate = this.buildTargetPolicyPredicate(targetNodeDef, connectVar, params, policyContext, paramCounter);
598
+ const connectConditions = policyPredicate
599
+ ? [...conditions, policyPredicate]
600
+ : conditions;
601
+ if (connectConditions.length > 0)
602
+ lines.push(`WHERE ${connectConditions.join(' AND ')}`);
527
603
  if (edgeInput && Object.keys(edgeInput).length > 0) {
528
604
  // Use bare target var (no label) since connectVar is already bound by MATCH.
529
605
  // Including labels on a bound variable in MERGE causes Neo4j to reject it.
@@ -560,7 +636,7 @@ class MutationCompiler {
560
636
  }
561
637
  return { lines, counterUsed: counter };
562
638
  }
563
- buildConnects(connect, nodeDef, params) {
639
+ buildConnects(connect, nodeDef, params, policyContext, paramCounter) {
564
640
  const lines = [];
565
641
  for (const [fieldName, spec] of Object.entries(connect)) {
566
642
  const relDef = nodeDef.relationships.get(fieldName);
@@ -591,8 +667,12 @@ class MutationCompiler {
591
667
  const connectVar = `target_${fieldName}_${ci}`;
592
668
  lines.push(`MATCH (${connectVar}:${targetLabelStr})`);
593
669
  const conditions = this.buildConnectionWhereConditions(whereSpec, connectVar, `connect_${fieldName}_${ci}`, params, targetNodeDef);
594
- if (conditions.length > 0)
595
- lines.push(`WHERE ${conditions.join(' AND ')}`);
670
+ const policyPredicate = this.buildTargetPolicyPredicate(targetNodeDef, connectVar, params, policyContext, paramCounter);
671
+ const connectConditions = policyPredicate
672
+ ? [...conditions, policyPredicate]
673
+ : conditions;
674
+ if (connectConditions.length > 0)
675
+ lines.push(`WHERE ${connectConditions.join(' AND ')}`);
596
676
  if (edgeInput && Object.keys(edgeInput).length > 0) {
597
677
  (0, validation_1.assertSafeIdentifier)(relDef.type, 'relationship type');
598
678
  const escapedRelType = (0, validation_1.escapeIdentifier)(relDef.type);
@@ -661,8 +741,12 @@ class MutationCompiler {
661
741
  `to share the same shape.`);
662
742
  }
663
743
  const whereConditions = this.extractConnectWhereConditions(firstItem, 'target', 'connItem');
664
- if (whereConditions.length > 0)
665
- lines.push(`WHERE ${whereConditions.join(' AND ')}`);
744
+ const policyPredicate = this.buildTargetPolicyPredicate(targetNodeDef, 'target', params, policyContext, paramCounter);
745
+ const unwindConditions = policyPredicate
746
+ ? [...whereConditions, policyPredicate]
747
+ : whereConditions;
748
+ if (unwindConditions.length > 0)
749
+ lines.push(`WHERE ${unwindConditions.join(' AND ')}`);
666
750
  const mergePattern = (0, utils_1.buildRelPattern)({
667
751
  sourceVar: 'n',
668
752
  relDef,
@@ -694,8 +778,12 @@ class MutationCompiler {
694
778
  lines.push('WITH n');
695
779
  lines.push(`MATCH (target:${targetLabelStr})`);
696
780
  const matchConditions = this.buildConnectionWhereConditions(whereSpec, 'target', `connect_${fieldName}`, params, targetNodeDef);
697
- if (matchConditions.length > 0)
698
- lines.push(`WHERE ${matchConditions.join(' AND ')}`);
781
+ const policyPredicate = this.buildTargetPolicyPredicate(targetNodeDef, 'target', params, policyContext, paramCounter);
782
+ const singleConnectConditions = policyPredicate
783
+ ? [...matchConditions, policyPredicate]
784
+ : matchConditions;
785
+ if (singleConnectConditions.length > 0)
786
+ lines.push(`WHERE ${singleConnectConditions.join(' AND ')}`);
699
787
  if (edgeInput && Object.keys(edgeInput).length > 0) {
700
788
  // Use bare target var (no label) since target is already bound by MATCH
701
789
  (0, validation_1.assertSafeIdentifier)(relDef.type, 'relationship type');
@@ -727,7 +815,7 @@ class MutationCompiler {
727
815
  }
728
816
  return lines;
729
817
  }
730
- buildDisconnects(disconnect, nodeDef, params) {
818
+ buildDisconnects(disconnect, nodeDef, params, policyContext, paramCounter) {
731
819
  const lines = [];
732
820
  for (const [fieldName, spec] of Object.entries(disconnect)) {
733
821
  const relDef = nodeDef.relationships.get(fieldName);
@@ -766,8 +854,14 @@ class MutationCompiler {
766
854
  });
767
855
  lines.push(`OPTIONAL MATCH ${pattern}`);
768
856
  const conditions = this.buildConnectionWhereConditions(whereSpec, targetVar, `disconnect_${fieldName}_${si}`, params, targetNodeDef);
769
- if (conditions.length > 0)
770
- lines.push(`WHERE ${conditions.join(' AND ')}`);
857
+ const policyPredicate = targetNodeDef
858
+ ? this.buildTargetPolicyPredicate(targetNodeDef, targetVar, params, policyContext, paramCounter)
859
+ : null;
860
+ const disconnectConditions = policyPredicate
861
+ ? [...conditions, policyPredicate]
862
+ : conditions;
863
+ if (disconnectConditions.length > 0)
864
+ lines.push(`WHERE ${disconnectConditions.join(' AND ')}`);
771
865
  lines.push(`DELETE ${relVar}`);
772
866
  }
773
867
  }
@@ -779,7 +873,7 @@ class MutationCompiler {
779
873
  * Each member key in the input is recursed into buildUpdateRelationships
780
874
  * with a narrowed relDef pointing at the concrete member type.
781
875
  */
782
- dispatchUnionUpdateOps(key, memberEntries, relDef, nodeDef, sourceVar, prefix, params, depth, ancestorVars) {
876
+ dispatchUnionUpdateOps(key, memberEntries, relDef, nodeDef, sourceVar, prefix, params, depth, ancestorVars, policyContext, paramCounter) {
783
877
  const unionMembers = this.schema.unions.get(relDef.target);
784
878
  const lines = [];
785
879
  for (const [memberKey, memberValue] of Object.entries(memberEntries)) {
@@ -799,7 +893,7 @@ class MutationCompiler {
799
893
  ...nodeDef,
800
894
  relationships: new Map([[key, memberRelDef]]),
801
895
  };
802
- lines.push(...this.buildUpdateRelationships({ [key]: memberItems }, memberNodeDef2, sourceVar, `${prefix}_${key}_${memberKey}`, params, depth + 1, ancestorVars));
896
+ lines.push(...this.buildUpdateRelationships({ [key]: memberItems }, memberNodeDef2, sourceVar, `${prefix}_${key}_${memberKey}`, params, depth + 1, ancestorVars, policyContext, paramCounter));
803
897
  }
804
898
  return lines;
805
899
  }
@@ -807,7 +901,7 @@ class MutationCompiler {
807
901
  * Process nested relationship operations within an update input.
808
902
  * Handles: create, connect, disconnect, update, and arrays of these.
809
903
  */
810
- buildUpdateRelationships(update, nodeDef, sourceVar, prefix, params, depth = 0, ancestorVars = []) {
904
+ buildUpdateRelationships(update, nodeDef, sourceVar, prefix, params, depth = 0, ancestorVars = [], policyContext, paramCounter) {
811
905
  if (depth > 5)
812
906
  throw new errors_1.OGMError(`[neo4j-ogm] buildUpdateRelationships: max nesting depth (5) exceeded for prefix "${prefix}". ` +
813
907
  'Nested relationship mutations beyond depth 5 are not supported.');
@@ -824,7 +918,7 @@ class MutationCompiler {
824
918
  const isUnionTarget = !this.schema.nodes.has(relDef.target) &&
825
919
  this.schema.unions?.has(relDef.target);
826
920
  if (isUnionTarget) {
827
- lines.push(...this.dispatchUnionUpdateOps(key, value, relDef, nodeDef, sourceVar, prefix, params, depth, ancestorVars));
921
+ lines.push(...this.dispatchUnionUpdateOps(key, value, relDef, nodeDef, sourceVar, prefix, params, depth, ancestorVars, policyContext, paramCounter));
828
922
  continue;
829
923
  }
830
924
  const targetNodeDef = this.schema.nodes.get(relDef.target);
@@ -894,7 +988,7 @@ class MutationCompiler {
894
988
  varCounter++;
895
989
  const { propString, propParams } = this.buildCreateProperties(nodeSpec, targetNodeDef, `${itemPrefix}_create${ci}`);
896
990
  // Handle nested relationship creates within the created node
897
- const nestedRelLines = this.buildCreateRelationships(nodeSpec, targetNodeDef, createVar, `${itemPrefix}_create${ci}`, params, allVars);
991
+ const nestedRelLines = this.buildCreateRelationships(nodeSpec, targetNodeDef, createVar, `${itemPrefix}_create${ci}`, params, allVars, policyContext, paramCounter);
898
992
  const propsClause = propString.length > 0
899
993
  ? ` { ${propString} }`
900
994
  : ` { ${this.buildGeneratedIdClause(targetNodeDef)} }`;
@@ -963,8 +1057,12 @@ class MutationCompiler {
963
1057
  // path (`buildDisconnects`). Restores backwards-compat with
964
1058
  // @neo4j/graphql-ogm's nested update.disconnect[].where shape.
965
1059
  const conditions = this.buildConnectionWhereConditions(whereSpec, discTarget, `${itemPrefix}_disc_${di}`, params, targetNodeDef);
966
- if (conditions.length > 0)
967
- lines.push(`WHERE ${conditions.join(' AND ')}`);
1060
+ const policyPredicate = this.buildTargetPolicyPredicate(targetNodeDef, discTarget, params, policyContext, paramCounter);
1061
+ const nestedDiscConditions = policyPredicate
1062
+ ? [...conditions, policyPredicate]
1063
+ : conditions;
1064
+ if (nestedDiscConditions.length > 0)
1065
+ lines.push(`WHERE ${nestedDiscConditions.join(' AND ')}`);
968
1066
  lines.push(`DELETE ${relVar}`);
969
1067
  }
970
1068
  }
@@ -993,8 +1091,12 @@ class MutationCompiler {
993
1091
  // top-level paths. Empty/undefined `whereSpec` short-circuits
994
1092
  // to `[]` inside the helper, so the no-where case still works.
995
1093
  const conditions = this.buildConnectionWhereConditions(whereSpec, connectVar, `${itemPrefix}_conn${ci}`, params, targetNodeDef);
996
- if (conditions.length > 0)
997
- lines.push(`WHERE ${conditions.join(' AND ')}`);
1094
+ const policyPredicate = this.buildTargetPolicyPredicate(targetNodeDef, connectVar, params, policyContext, paramCounter);
1095
+ const nestedConnConditions = policyPredicate
1096
+ ? [...conditions, policyPredicate]
1097
+ : conditions;
1098
+ if (nestedConnConditions.length > 0)
1099
+ lines.push(`WHERE ${nestedConnConditions.join(' AND ')}`);
998
1100
  if (edgeInput && Object.keys(edgeInput).length > 0) {
999
1101
  // Use bare target var (no label) since connectVar is already bound by MATCH
1000
1102
  const relVar = `r_conn_${key}_${ci}`;
@@ -1065,7 +1167,7 @@ class MutationCompiler {
1065
1167
  }
1066
1168
  // Pre-compute nested relationship operations
1067
1169
  const nestedParams = {};
1068
- const nestedRelLines = this.buildUpdateRelationships(nodeUpdate, targetNodeDef, updateVar, itemPrefix, nestedParams, depth + 1, allVars);
1170
+ const nestedRelLines = this.buildUpdateRelationships(nodeUpdate, targetNodeDef, updateVar, itemPrefix, nestedParams, depth + 1, allVars, policyContext, paramCounter);
1069
1171
  // Only emit the MATCH block if there's actual work to do
1070
1172
  const hasWork = setClauses.length > 0 ||
1071
1173
  edgeClauses.length > 0 ||