@contember/engine-content-api 2.1.0-beta.1 → 2.1.0-rc.1

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 (65) hide show
  1. package/dist/development/index.cjs +1 -0
  2. package/dist/development/index.cjs.map +1 -1
  3. package/dist/development/index.js +2 -1
  4. package/dist/development/src/ExecutionContainer.cjs +40 -4
  5. package/dist/development/src/ExecutionContainer.cjs.map +1 -1
  6. package/dist/development/src/ExecutionContainer.js +41 -5
  7. package/dist/development/src/ExecutionContainer.js.map +1 -1
  8. package/dist/development/src/acl/PermissionFactory.cjs +29 -15
  9. package/dist/development/src/acl/PermissionFactory.cjs.map +1 -1
  10. package/dist/development/src/acl/PermissionFactory.js +29 -15
  11. package/dist/development/src/acl/PermissionFactory.js.map +1 -1
  12. package/dist/development/src/acl/PredicateFactory.cjs +33 -13
  13. package/dist/development/src/acl/PredicateFactory.cjs.map +1 -1
  14. package/dist/development/src/acl/PredicateFactory.js +33 -13
  15. package/dist/development/src/acl/PredicateFactory.js.map +1 -1
  16. package/dist/development/src/acl/PredicatesInjector.cjs +21 -18
  17. package/dist/development/src/acl/PredicatesInjector.cjs.map +1 -1
  18. package/dist/development/src/acl/PredicatesInjector.js +21 -18
  19. package/dist/development/src/acl/PredicatesInjector.js.map +1 -1
  20. package/dist/development/src/mapper/select/handlers/FieldsVisitor.cjs +2 -1
  21. package/dist/development/src/mapper/select/handlers/FieldsVisitor.cjs.map +1 -1
  22. package/dist/development/src/mapper/select/handlers/FieldsVisitor.js +2 -1
  23. package/dist/development/src/mapper/select/handlers/FieldsVisitor.js.map +1 -1
  24. package/dist/production/index.cjs +1 -0
  25. package/dist/production/index.cjs.map +1 -1
  26. package/dist/production/index.js +2 -1
  27. package/dist/production/src/ExecutionContainer.cjs +40 -4
  28. package/dist/production/src/ExecutionContainer.cjs.map +1 -1
  29. package/dist/production/src/ExecutionContainer.js +41 -5
  30. package/dist/production/src/ExecutionContainer.js.map +1 -1
  31. package/dist/production/src/acl/PermissionFactory.cjs +29 -15
  32. package/dist/production/src/acl/PermissionFactory.cjs.map +1 -1
  33. package/dist/production/src/acl/PermissionFactory.js +29 -15
  34. package/dist/production/src/acl/PermissionFactory.js.map +1 -1
  35. package/dist/production/src/acl/PredicateFactory.cjs +33 -13
  36. package/dist/production/src/acl/PredicateFactory.cjs.map +1 -1
  37. package/dist/production/src/acl/PredicateFactory.js +33 -13
  38. package/dist/production/src/acl/PredicateFactory.js.map +1 -1
  39. package/dist/production/src/acl/PredicatesInjector.cjs +21 -18
  40. package/dist/production/src/acl/PredicatesInjector.cjs.map +1 -1
  41. package/dist/production/src/acl/PredicatesInjector.js +21 -18
  42. package/dist/production/src/acl/PredicatesInjector.js.map +1 -1
  43. package/dist/production/src/mapper/select/handlers/FieldsVisitor.cjs +2 -1
  44. package/dist/production/src/mapper/select/handlers/FieldsVisitor.cjs.map +1 -1
  45. package/dist/production/src/mapper/select/handlers/FieldsVisitor.js +2 -1
  46. package/dist/production/src/mapper/select/handlers/FieldsVisitor.js.map +1 -1
  47. package/dist/tests/tsconfig.tsbuildinfo +1 -1
  48. package/dist/types/ExecutionContainer.d.ts +20 -1
  49. package/dist/types/ExecutionContainer.d.ts.map +1 -1
  50. package/dist/types/acl/PermissionFactory.d.ts +6 -0
  51. package/dist/types/acl/PermissionFactory.d.ts.map +1 -1
  52. package/dist/types/acl/PredicateFactory.d.ts +17 -6
  53. package/dist/types/acl/PredicateFactory.d.ts.map +1 -1
  54. package/dist/types/acl/PredicatesInjector.d.ts.map +1 -1
  55. package/dist/types/mapper/select/handlers/FieldsVisitor.d.ts.map +1 -1
  56. package/dist/types/tsconfig.tsbuildinfo +1 -1
  57. package/package.json +1 -2
  58. package/src/ExecutionContainer.ts +57 -4
  59. package/src/acl/PermissionFactory.ts +48 -15
  60. package/src/acl/PredicateFactory.ts +38 -9
  61. package/src/acl/PredicatesInjector.ts +19 -13
  62. package/src/mapper/select/handlers/FieldsVisitor.ts +2 -1
  63. package/tests/cases/unit/permissionMerger.test.ts +162 -0
  64. package/tests/cases/unit/predicatesFactory.test.ts +75 -0
  65. package/vitest.config.ts +0 -3
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@contember/engine-content-api",
3
- "version": "2.1.0-beta.1",
3
+ "version": "2.1.0-rc.1",
4
4
  "license": "Apache-2.0",
5
5
  "main": "./dist/production/index.js",
6
6
  "typings": "./dist/types/index.d.ts",
@@ -37,7 +37,6 @@
37
37
  },
38
38
  "devDependencies": {
39
39
  "@contember/database-tester": "2.1.0-beta.1",
40
- "@contember/schema-definition": "2.1.0-beta.1",
41
40
  "@types/node": "^24.12.0",
42
41
  "graphql": "^16.13.1",
43
42
  "pg": "^8.20.0"
@@ -36,6 +36,32 @@ import {
36
36
  } from './input-validation'
37
37
  import { RefreshViewResolver } from './resolvers/RefreshViewResolver'
38
38
 
39
+ export interface TriggeredActionEvent {
40
+ id: string
41
+ trigger: string
42
+ target: string
43
+ transactionId: string
44
+ }
45
+
46
+ export interface TriggeredActionsCollector {
47
+ add(events: readonly TriggeredActionEvent[]): void
48
+ getEvents(): readonly TriggeredActionEvent[]
49
+ }
50
+
51
+ export class InMemoryTriggeredActionsCollector implements TriggeredActionsCollector {
52
+ private events: TriggeredActionEvent[] = []
53
+
54
+ add(events: readonly TriggeredActionEvent[]): void {
55
+ for (const event of events) {
56
+ this.events.push(event)
57
+ }
58
+ }
59
+
60
+ getEvents(): readonly TriggeredActionEvent[] {
61
+ return this.events
62
+ }
63
+ }
64
+
39
65
  export type ExecutionContainerArgs = {
40
66
  schema: Schema
41
67
  schemaMeta: { id?: number }
@@ -44,6 +70,7 @@ export type ExecutionContainerArgs = {
44
70
  identityId: string
45
71
  identityVariables: Acl.VariablesMap
46
72
  permissions: Acl.Permissions
73
+ allPermissions?: Acl.Permissions
47
74
  systemSchema: string
48
75
  project: { slug: string }
49
76
  stage: { id: string; slug: string }
@@ -55,6 +82,7 @@ export interface ExecutionContainer {
55
82
  mutationResolver: MutationResolver
56
83
  validationResolver: ValidationResolver
57
84
  refreshViewResolver: RefreshViewResolver
85
+ triggeredActionsCollector: TriggeredActionsCollector | undefined
58
86
  }
59
87
 
60
88
  export type ExecutionContainerBuilder = ReturnType<ExecutionContainerFactory['createBuilderInternal']>
@@ -75,21 +103,40 @@ export class ExecutionContainerFactory {
75
103
  }
76
104
 
77
105
  createBuilderInternal(
78
- { permissions, identityVariables, identityId, db, schema, schemaMeta, systemSchema, stage, project, schemaDatabaseMetadata, userInfo }:
79
- ExecutionContainerArgs,
106
+ {
107
+ permissions,
108
+ allPermissions,
109
+ identityVariables,
110
+ identityId,
111
+ db,
112
+ schema,
113
+ schemaMeta,
114
+ systemSchema,
115
+ stage,
116
+ project,
117
+ schemaDatabaseMetadata,
118
+ userInfo,
119
+ }: ExecutionContainerArgs,
80
120
  ) {
81
121
  return new Builder({})
82
122
  .addService('systemSchema', () => systemSchema)
83
123
  .addService('db', () => db)
84
124
  .addService('project', () => project)
85
125
  .addService('stage', () => stage)
126
+ .addService('triggeredActionsCollector', (): TriggeredActionsCollector | undefined =>
127
+ schema.settings.actions?.returnTriggeredActions === true
128
+ ? new InMemoryTriggeredActionsCollector()
129
+ : undefined)
86
130
  .addService('schema', () => schema)
87
131
  .addService('schemaMeta', () => schemaMeta)
88
132
  .addService('schemaDatabaseMetadata', () => schemaDatabaseMetadata)
89
133
  .addService('userInfo', () => userInfo)
90
134
  .addService('providers', () => this.providers)
91
135
  .addService('variableInjector', ({ schema }) => new VariableInjector(schema.model, identityVariables))
92
- .addService('predicateFactory', ({ variableInjector, schema }) => new PredicateFactory(permissions, schema.model, variableInjector))
136
+ .addService(
137
+ 'predicateFactory',
138
+ ({ variableInjector, schema }) => new PredicateFactory(permissions, schema.model, variableInjector, allPermissions),
139
+ )
93
140
  .addService('predicatesInjector', ({ predicateFactory, schema }) => new PredicatesInjector(schema.model, predicateFactory))
94
141
  .addService('joinBuilder', ({ schema }) => new JoinBuilder(schema.model))
95
142
  .addService('conditionBuilder', () => new ConditionBuilder())
@@ -232,6 +279,12 @@ export class ExecutionContainerFactory {
232
279
  }
233
280
 
234
281
  public create(args: ExecutionContainerArgs): ExecutionContainer {
235
- return this.createBuilder(args).build().pick('validationResolver', 'readResolver', 'mutationResolver', 'refreshViewResolver')
282
+ return this.createBuilder(args).build().pick(
283
+ 'validationResolver',
284
+ 'readResolver',
285
+ 'mutationResolver',
286
+ 'refreshViewResolver',
287
+ 'triggeredActionsCollector',
288
+ )
236
289
  }
237
290
  }
@@ -7,17 +7,33 @@ export interface Identity {
7
7
  projectRoles: readonly string[]
8
8
  }
9
9
 
10
+ export interface ContextualPermissions {
11
+ root: Acl.Permissions
12
+ all: Acl.Permissions
13
+ }
14
+
10
15
  export class PermissionFactory {
11
16
  public create(schema: Schema, roles: readonly string[], prefix?: string): Acl.Permissions {
17
+ return this.createInternal(schema, roles, false, prefix)
18
+ }
19
+
20
+ public createContextual(schema: Schema, roles: readonly string[]): ContextualPermissions {
21
+ return {
22
+ root: this.createInternal(schema, roles, false),
23
+ all: this.createInternal(schema, roles, true),
24
+ }
25
+ }
26
+
27
+ private createInternal(schema: Schema, roles: readonly string[], includeThrough: boolean, prefix?: string): Acl.Permissions {
12
28
  let result: Acl.Permissions = {}
13
29
  for (let role of roles) {
14
30
  const roleDefinition = schema.acl.roles[role] || { entities: {} }
15
31
  let rolePermissions: Acl.Permissions = this.prefixPredicatesWithRole(schema.model, roleDefinition.entities, prefix || role)
16
32
  if (roleDefinition.inherits) {
17
- const inheritedPermissions = this.create(schema, roleDefinition.inherits, prefix || role)
18
- rolePermissions = this.mergePermissions(inheritedPermissions, rolePermissions)
33
+ const inheritedPermissions = this.createInternal(schema, roleDefinition.inherits, includeThrough, prefix || role)
34
+ rolePermissions = this.mergePermissions(inheritedPermissions, rolePermissions, includeThrough)
19
35
  }
20
- result = this.mergePermissions(result, rolePermissions)
36
+ result = this.mergePermissions(result, rolePermissions, includeThrough)
21
37
  }
22
38
  result = this.makePrimaryPredicatesUnionOfAllFields(schema.model, result)
23
39
 
@@ -98,11 +114,11 @@ export class PermissionFactory {
98
114
  })
99
115
  }
100
116
 
101
- private mergePermissions(left: Acl.Permissions, right: Acl.Permissions): Acl.Permissions {
117
+ private mergePermissions(left: Acl.Permissions, right: Acl.Permissions, includeThrough = false): Acl.Permissions {
102
118
  const result = { ...left }
103
119
  for (let entityName in right) {
104
120
  if (result[entityName] !== undefined) {
105
- result[entityName] = this.mergeEntityPermissions(result[entityName], right[entityName])
121
+ result[entityName] = this.mergeEntityPermissions(result[entityName], right[entityName], includeThrough)
106
122
  } else {
107
123
  result[entityName] = right[entityName]
108
124
  }
@@ -110,7 +126,7 @@ export class PermissionFactory {
110
126
  return result
111
127
  }
112
128
 
113
- private mergeEntityPermissions(left: Acl.EntityPermissions, right: Acl.EntityPermissions): Acl.EntityPermissions {
129
+ private mergeEntityPermissions(left: Acl.EntityPermissions, right: Acl.EntityPermissions, includeThrough = false): Acl.EntityPermissions {
114
130
  let predicates: Writable<Acl.PredicateMap> = {}
115
131
  const operations: Writable<Acl.EntityOperations> = {}
116
132
  if (left.operations.customPrimary || right.operations.customPrimary) {
@@ -125,7 +141,12 @@ export class PermissionFactory {
125
141
  'update',
126
142
  ] as const
127
143
  ) {
128
- const { predicates: opPredicates, permissions: fieldPermissions, noRoot: opNoRoot } = this.mergeOperationPermissions(left, right, operation)
144
+ const { predicates: opPredicates, permissions: fieldPermissions, noRoot: opNoRoot } = this.mergeOperationPermissions(
145
+ left,
146
+ right,
147
+ operation,
148
+ includeThrough,
149
+ )
129
150
 
130
151
  if (opNoRoot) {
131
152
  noRoot.push(operation)
@@ -137,7 +158,7 @@ export class PermissionFactory {
137
158
  }
138
159
  }
139
160
 
140
- const { predicateDefinition, predicate, noRoot: opNoRoot } = this.mergeDeletePermissions(left, right)
161
+ const { predicateDefinition, predicate, noRoot: opNoRoot } = this.mergeDeletePermissions(left, right, includeThrough)
141
162
  if (opNoRoot) {
142
163
  noRoot.push('delete')
143
164
  }
@@ -158,12 +179,17 @@ export class PermissionFactory {
158
179
  }
159
180
  }
160
181
 
161
- private mergeOperationPermissions(left: Acl.EntityPermissions, right: Acl.EntityPermissions, operation: 'create' | 'read' | 'update'): {
182
+ private mergeOperationPermissions(
183
+ left: Acl.EntityPermissions,
184
+ right: Acl.EntityPermissions,
185
+ operation: 'create' | 'read' | 'update',
186
+ includeThrough = false,
187
+ ): {
162
188
  noRoot: boolean
163
189
  predicates: Acl.PredicateMap
164
190
  permissions: Acl.FieldPermissions
165
191
  } {
166
- const { noRoot, leftPermissions, rightPermissions } = this.resolveNoRoot(left, right, operation)
192
+ const { noRoot, leftPermissions, rightPermissions } = this.resolveNoRoot(left, right, operation, includeThrough)
167
193
  return {
168
194
  noRoot,
169
195
  ...this.mergeFieldPermissions(
@@ -175,12 +201,12 @@ export class PermissionFactory {
175
201
  }
176
202
  }
177
203
 
178
- private mergeDeletePermissions(left: Acl.EntityPermissions, right: Acl.EntityPermissions): {
204
+ private mergeDeletePermissions(left: Acl.EntityPermissions, right: Acl.EntityPermissions, includeThrough = false): {
179
205
  noRoot: boolean
180
206
  predicate: Acl.PredicateReference | boolean
181
207
  predicateDefinition: Acl.PredicateDefinition | undefined
182
208
  } {
183
- const { noRoot, leftPermissions, rightPermissions } = this.resolveNoRoot(left, right, 'delete')
209
+ const { noRoot, leftPermissions, rightPermissions } = this.resolveNoRoot(left, right, 'delete', includeThrough)
184
210
 
185
211
  return {
186
212
  noRoot,
@@ -193,7 +219,12 @@ export class PermissionFactory {
193
219
  }
194
220
  }
195
221
 
196
- private resolveNoRoot<const Op extends `${Acl.Operation}`>(left: Acl.EntityPermissions, right: Acl.EntityPermissions, operation: Op): {
222
+ private resolveNoRoot<const Op extends `${Acl.Operation}`>(
223
+ left: Acl.EntityPermissions,
224
+ right: Acl.EntityPermissions,
225
+ operation: Op,
226
+ includeThrough = false,
227
+ ): {
197
228
  noRoot: boolean
198
229
  leftPermissions: Acl.EntityPermissions['operations'][Op] | undefined
199
230
  rightPermissions: Acl.EntityPermissions['operations'][Op] | undefined
@@ -207,8 +238,10 @@ export class PermissionFactory {
207
238
  || (leftRootForbidden && !rightPermissions)
208
239
  || (rightRootForbidden && !leftPermissions)
209
240
 
210
- const resolvedLeftPermissions = !rootForbidden && leftRootForbidden ? undefined : leftPermissions
211
- const resolvedRightPermissions = !rootForbidden && rightRootForbidden ? undefined : rightPermissions
241
+ // When includeThrough is true, keep all permissions regardless of noRoot
242
+ // (used for "all" permissions that apply when accessing through relations)
243
+ const resolvedLeftPermissions = !includeThrough && !rootForbidden && leftRootForbidden ? undefined : leftPermissions
244
+ const resolvedRightPermissions = !includeThrough && !rootForbidden && rightRootForbidden ? undefined : rightPermissions
212
245
 
213
246
  return {
214
247
  noRoot: rootForbidden,
@@ -14,14 +14,32 @@ export class PredicateFactory {
14
14
  private readonly permissions: Acl.Permissions,
15
15
  private readonly model: Model.Schema,
16
16
  private readonly variableInjector: VariableInjector,
17
+ private readonly allPermissions?: Acl.Permissions,
17
18
  ) {}
18
19
 
20
+ /**
21
+ * Selects the appropriate permission set based on query context:
22
+ * - `isRoot === false` (nested/relation context): uses allPermissions (includes through-only permissions)
23
+ * - `isRoot === true` or `isRoot === undefined` (root or unknown context): uses root-only permissions
24
+ *
25
+ * The `undefined` vs `true` distinction matters: `undefined` is the default for callers
26
+ * that don't track root context, and must fall through to root permissions for safety.
27
+ */
28
+ private getPermissionsForContext(isRoot?: boolean): Acl.Permissions {
29
+ if (isRoot === false && this.allPermissions) {
30
+ return this.allPermissions
31
+ }
32
+ return this.permissions
33
+ }
34
+
19
35
  public getFieldPredicate(
20
36
  entity: Model.Entity,
21
37
  operation: Acl.Operation.update | Acl.Operation.read | Acl.Operation.create,
22
38
  fieldName: string,
39
+ isRoot?: boolean,
23
40
  ): FieldRequiredPredicate {
24
- const permissions = this.permissions[entity.name]?.operations?.[operation]
41
+ const perms = this.getPermissionsForContext(isRoot)
42
+ const permissions = perms[entity.name]?.operations?.[operation]
25
43
  const predicate = permissions?.[fieldName] ?? false
26
44
  const rowLevelField = getRowLevelPredicatePseudoField(entity)
27
45
 
@@ -38,12 +56,15 @@ export class PredicateFactory {
38
56
  entity: Model.Entity,
39
57
  operation: Acl.Operation.read,
40
58
  fieldName: string,
59
+ isRoot?: boolean,
41
60
  ): boolean {
61
+ const perms = this.getPermissionsForContext(isRoot)
42
62
  const rowLevelField = getRowLevelPredicatePseudoField(entity)
43
- const permissions = this.permissions[entity.name]?.operations?.[operation]
63
+ const permissions = perms[entity.name]?.operations?.[operation]
44
64
  return permissions?.[fieldName] !== permissions?.[rowLevelField]
45
65
  }
46
66
 
67
+ /** Delete predicates are not context-aware — through-permission support is scoped to read operations only. */
47
68
  public createDeletePredicate(entity: Model.Entity) {
48
69
  const neverCondition: Input.Where = { [entity.primary]: { never: true } }
49
70
  const entityPermissions = this.permissions[entity.name]
@@ -65,8 +86,10 @@ export class PredicateFactory {
65
86
  operation: Acl.Operation.update | Acl.Operation.read | Acl.Operation.create,
66
87
  fieldNames: string[] = [getRowLevelPredicatePseudoField(entity)],
67
88
  relationContext?: Model.AnyRelationContext,
89
+ isRoot?: boolean,
68
90
  ): Input.OptionalWhere {
69
- const entityPermissions: Acl.EntityPermissions = this.permissions[entity.name]
91
+ const perms = this.getPermissionsForContext(isRoot)
92
+ const entityPermissions: Acl.EntityPermissions = perms[entity.name]
70
93
  const neverCondition: Input.Where = { [entity.primary]: { never: true } }
71
94
 
72
95
  if (!entityPermissions) {
@@ -85,11 +108,17 @@ export class PredicateFactory {
85
108
  return neverCondition
86
109
  }
87
110
 
88
- return this.buildPredicates(entity, operationPredicates, relationContext)
111
+ return this.buildPredicates(entity, operationPredicates, relationContext, isRoot)
89
112
  }
90
113
 
91
- public buildPredicates(entity: Model.Entity, predicates: Acl.PredicateReference[], relationContext?: Model.AnyRelationContext): Input.OptionalWhere {
92
- const entityPermissions: Acl.EntityPermissions = this.permissions[entity.name] ?? {}
114
+ public buildPredicates(
115
+ entity: Model.Entity,
116
+ predicates: Acl.PredicateReference[],
117
+ relationContext?: Model.AnyRelationContext,
118
+ isRoot?: boolean,
119
+ ): Input.OptionalWhere {
120
+ const perms = this.getPermissionsForContext(isRoot)
121
+ const entityPermissions: Acl.EntityPermissions = perms[entity.name] ?? {}
93
122
 
94
123
  const predicatesWhere: Input.Where[] = predicates.reduce(
95
124
  (result: Input.Where[], name: Acl.PredicateReference): Input.Where[] => {
@@ -105,7 +134,7 @@ export class PredicateFactory {
105
134
  return {}
106
135
  }
107
136
  const where: Input.Where = predicatesWhere.length === 1 ? predicatesWhere[0] : { and: predicatesWhere }
108
- return this.optimizePredicates(where, relationContext)
137
+ return this.optimizePredicates(where, relationContext, isRoot)
109
138
  }
110
139
 
111
140
  private getRequiredPredicates(
@@ -128,11 +157,11 @@ export class PredicateFactory {
128
157
  return predicates
129
158
  }
130
159
 
131
- public optimizePredicates(where: Input.OptionalWhere, relationContext?: Model.AnyRelationContext) {
160
+ public optimizePredicates(where: Input.OptionalWhere, relationContext?: Model.AnyRelationContext, isRoot?: boolean) {
132
161
  if (!relationContext || !relationContext.targetRelation) {
133
162
  return where
134
163
  }
135
- const sourcePredicate = this.create(relationContext.entity, Acl.Operation.read, [relationContext.relation.name])
164
+ const sourcePredicate = this.create(relationContext.entity, Acl.Operation.read, [relationContext.relation.name], undefined, isRoot)
136
165
  if (Object.keys(sourcePredicate).length === 0) {
137
166
  return where
138
167
  }
@@ -11,8 +11,9 @@ export class PredicatesInjector {
11
11
  relationContext?: Model.AnyRelationContext,
12
12
  ancestorPath?: readonly Model.AnyRelationContext[],
13
13
  ): Input.OptionalWhere {
14
- const restrictedWhere = this.injectToWhere(where, entity, true, relationContext, false, ancestorPath ?? [])
15
- return this.createWhere(entity, undefined, restrictedWhere, relationContext, false, ancestorPath ?? [])
14
+ const isQueryRoot = !relationContext && (!ancestorPath || ancestorPath.length === 0)
15
+ const restrictedWhere = this.injectToWhere(where, entity, true, relationContext, false, ancestorPath ?? [], isQueryRoot)
16
+ return this.createWhere(entity, undefined, restrictedWhere, relationContext, false, ancestorPath ?? [], isQueryRoot)
16
17
  }
17
18
 
18
19
  /**
@@ -40,6 +41,7 @@ export class PredicatesInjector {
40
41
  relationContext?: Model.AnyRelationContext,
41
42
  isBackReferenceContext?: boolean,
42
43
  ancestorPath?: readonly Model.AnyRelationContext[],
44
+ isQueryRoot?: boolean,
43
45
  ): Input.OptionalWhere {
44
46
  // Simplify predicates when:
45
47
  // 1. We're in a back-reference context (inside a filter that traverses back)
@@ -55,13 +57,14 @@ export class PredicatesInjector {
55
57
  if (shouldSimplify) {
56
58
  predicatesWhere = { [entity.primary]: { always: true } }
57
59
  } else {
58
- const rawPredicate = this.predicateFactory.create(entity, Acl.Operation.read, fieldNames, relationContext)
60
+ const rawPredicate = this.predicateFactory.create(entity, Acl.Operation.read, fieldNames, relationContext, isQueryRoot)
59
61
  // Process the predicate to inject nested entity predicates
60
62
  predicatesWhere = this.injectPredicatesToPredicate(
61
63
  rawPredicate,
62
64
  entity,
63
65
  isBackReferenceContext ?? false,
64
66
  ancestorPath ?? [],
67
+ isQueryRoot,
65
68
  )
66
69
  }
67
70
 
@@ -85,21 +88,22 @@ export class PredicatesInjector {
85
88
  entity: Model.Entity,
86
89
  isBackReferenceContext: boolean,
87
90
  ancestorPath: readonly Model.AnyRelationContext[],
91
+ isQueryRoot?: boolean,
88
92
  ): Input.OptionalWhere {
89
93
  const resultWhere: Writable<Input.OptionalWhere> = {}
90
94
 
91
95
  if (where.and) {
92
96
  resultWhere.and = where.and
93
97
  .filter((it): it is Input.Where => !!it)
94
- .map(it => this.injectPredicatesToPredicate(it, entity, isBackReferenceContext, ancestorPath))
98
+ .map(it => this.injectPredicatesToPredicate(it, entity, isBackReferenceContext, ancestorPath, isQueryRoot))
95
99
  }
96
100
  if (where.or) {
97
101
  resultWhere.or = where.or
98
102
  .filter((it): it is Input.Where => !!it)
99
- .map(it => this.injectPredicatesToPredicate(it, entity, isBackReferenceContext, ancestorPath))
103
+ .map(it => this.injectPredicatesToPredicate(it, entity, isBackReferenceContext, ancestorPath, isQueryRoot))
100
104
  }
101
105
  if (where.not) {
102
- resultWhere.not = this.injectPredicatesToPredicate(where.not, entity, isBackReferenceContext, ancestorPath)
106
+ resultWhere.not = this.injectPredicatesToPredicate(where.not, entity, isBackReferenceContext, ancestorPath, isQueryRoot)
103
107
  }
104
108
 
105
109
  const fields = Object.keys(where).filter(it => !['and', 'or', 'not'].includes(it))
@@ -128,6 +132,7 @@ export class PredicatesInjector {
128
132
  context.targetEntity,
129
133
  nestedIsBackReferenceContext,
130
134
  nestedAncestorPath,
135
+ isQueryRoot,
131
136
  )
132
137
 
133
138
  // Check if we should simplify the target entity's predicate
@@ -137,7 +142,7 @@ export class PredicatesInjector {
137
142
  // Get target entity's predicate (simplified if back-reference)
138
143
  const targetPredicate = shouldSimplifyNested
139
144
  ? { [context.targetEntity.primary]: { always: true } }
140
- : this.predicateFactory.create(context.targetEntity, Acl.Operation.read, undefined, context)
145
+ : this.predicateFactory.create(context.targetEntity, Acl.Operation.read, undefined, context, isQueryRoot)
141
146
 
142
147
  // Optimization: avoid duplicate { id: always } when both are simplified
143
148
  const primaryKey = context.targetEntity.primary
@@ -175,20 +180,21 @@ export class PredicatesInjector {
175
180
  relationContext: Model.AnyRelationContext | undefined,
176
181
  isBackReferenceContext: boolean,
177
182
  ancestorPath: readonly Model.AnyRelationContext[],
183
+ isQueryRoot?: boolean,
178
184
  ): Input.OptionalWhere {
179
185
  const resultWhere: Writable<Input.OptionalWhere> = {}
180
186
  if (where.and) {
181
187
  resultWhere.and = where.and.filter((it): it is Input.Where => !!it).map(it =>
182
- this.injectToWhere(it, entity, isRoot, relationContext, isBackReferenceContext, ancestorPath)
188
+ this.injectToWhere(it, entity, isRoot, relationContext, isBackReferenceContext, ancestorPath, isQueryRoot)
183
189
  )
184
190
  }
185
191
  if (where.or) {
186
192
  resultWhere.or = where.or.filter((it): it is Input.Where => !!it).map(it =>
187
- this.injectToWhere(it, entity, isRoot, relationContext, isBackReferenceContext, ancestorPath)
193
+ this.injectToWhere(it, entity, isRoot, relationContext, isBackReferenceContext, ancestorPath, isQueryRoot)
188
194
  )
189
195
  }
190
196
  if (where.not) {
191
- resultWhere.not = this.injectToWhere(where.not, entity, isRoot, relationContext, isBackReferenceContext, ancestorPath)
197
+ resultWhere.not = this.injectToWhere(where.not, entity, isRoot, relationContext, isBackReferenceContext, ancestorPath, isQueryRoot)
192
198
  }
193
199
 
194
200
  const fields = Object.keys(where).filter(it => !['and', 'or', 'not'].includes(it))
@@ -210,14 +216,14 @@ export class PredicatesInjector {
210
216
  const nestedIsBackReferenceContext = isBackReference || isBackReferenceContext
211
217
  // Build extended ancestor path for nested traversal
212
218
  const nestedAncestorPath: Model.AnyRelationContext[] = [...ancestorPath, context]
213
- return this.injectToWhere(relationWhere, context.targetEntity, false, context, nestedIsBackReferenceContext, nestedAncestorPath)
219
+ return this.injectToWhere(relationWhere, context.targetEntity, false, context, nestedIsBackReferenceContext, nestedAncestorPath, isQueryRoot)
214
220
  },
215
221
  })
216
222
  }
217
223
  const fieldsForPredicate = !isRoot
218
224
  ? fields
219
- : fields.filter(it => this.predicateFactory.shouldApplyCellLevelPredicate(entity, Acl.Operation.read, it))
225
+ : fields.filter(it => this.predicateFactory.shouldApplyCellLevelPredicate(entity, Acl.Operation.read, it, isQueryRoot))
220
226
 
221
- return this.createWhere(entity, fieldsForPredicate, resultWhere, relationContext, isBackReferenceContext, ancestorPath)
227
+ return this.createWhere(entity, fieldsForPredicate, resultWhere, relationContext, isBackReferenceContext, ancestorPath, isQueryRoot)
222
228
  }
223
229
  }
@@ -220,7 +220,8 @@ export class FieldsVisitor implements Model.RelationByTypeVisitor<void>, Model.C
220
220
  }
221
221
 
222
222
  private getRequiredPredicate(entity: Model.Entity, field: Model.AnyField): Acl.Predicate | undefined {
223
- const fieldPredicate = this.predicateFactory.getFieldPredicate(entity, Acl.Operation.read, field.name)
223
+ const isRoot = this.relationPath.length === 0
224
+ const fieldPredicate = this.predicateFactory.getFieldPredicate(entity, Acl.Operation.read, field.name, isRoot)
224
225
  return fieldPredicate.isSameAsPrimary ? undefined : fieldPredicate.predicate
225
226
  }
226
227
  }
@@ -11,6 +11,13 @@ interface Test {
11
11
  result: Acl.Permissions
12
12
  }
13
13
 
14
+ interface ContextualTest {
15
+ acl: Acl.Schema
16
+ roles: string[]
17
+ rootResult: Acl.Permissions
18
+ allResult: Acl.Permissions
19
+ }
20
+
14
21
  const execute = (test: Test) => {
15
22
  const model: Model.Schema = new SchemaBuilder()
16
23
  .entity('Entity1', e => e.column('lorem').column('bar'))
@@ -27,6 +34,21 @@ const execute = (test: Test) => {
27
34
  assert.deepStrictEqual(test.acl, initialAcl)
28
35
  }
29
36
 
37
+ const executeContextual = (test: ContextualTest) => {
38
+ const model: Model.Schema = new SchemaBuilder()
39
+ .entity('Entity1', e => e.column('lorem').column('bar'))
40
+ .entity('Entity2', e => e.oneHasOne('xyz', r => r.target('Entity1')))
41
+ .buildSchema()
42
+ const factory = new PermissionFactory()
43
+ const { root, all } = factory.createContextual({
44
+ ...emptySchema,
45
+ model,
46
+ acl: test.acl,
47
+ }, test.roles)
48
+ assert.deepStrictEqual(root, test.rootResult)
49
+ assert.deepStrictEqual(all, test.allResult)
50
+ }
51
+
30
52
  describe('Permission merger', () => {
31
53
  it('merge inheritance', () => {
32
54
  execute({
@@ -845,3 +867,143 @@ describe('Permission merger', () => {
845
867
  })
846
868
  })
847
869
  })
870
+
871
+ describe('Contextual permission merger', () => {
872
+ it('createContextual: root drops through-only, all includes both', () => {
873
+ executeContextual({
874
+ acl: {
875
+ roles: {
876
+ throughRole: {
877
+ variables: {},
878
+ entities: {
879
+ Entity2: {
880
+ predicates: {
881
+ foo: { xyz: { lorem: { eq: 'through' } } },
882
+ },
883
+ operations: {
884
+ read: {
885
+ title: 'foo',
886
+ },
887
+ noRoot: ['read'],
888
+ },
889
+ },
890
+ },
891
+ },
892
+ rootRole: {
893
+ variables: {},
894
+ entities: {
895
+ Entity2: {
896
+ predicates: {
897
+ bar: { xyz: { lorem: { eq: 'root' } } },
898
+ },
899
+ operations: {
900
+ read: {
901
+ title: 'bar',
902
+ },
903
+ },
904
+ },
905
+ },
906
+ },
907
+ },
908
+ },
909
+ roles: ['throughRole', 'rootRole'],
910
+ rootResult: {
911
+ Entity2: {
912
+ operations: {
913
+ read: {
914
+ title: 'bar',
915
+ id: 'bar',
916
+ },
917
+ },
918
+ predicates: {
919
+ bar: {
920
+ xyz: {
921
+ lorem: { eq: 'root' },
922
+ },
923
+ },
924
+ },
925
+ },
926
+ },
927
+ allResult: {
928
+ Entity2: {
929
+ operations: {
930
+ read: {
931
+ title: '__merge__foo__bar',
932
+ id: '__merge__foo__bar',
933
+ },
934
+ },
935
+ predicates: {
936
+ __merge__foo__bar: {
937
+ or: [
938
+ { xyz: { lorem: { eq: 'through' } } },
939
+ { xyz: { lorem: { eq: 'root' } } },
940
+ ],
941
+ },
942
+ },
943
+ },
944
+ },
945
+ })
946
+ })
947
+
948
+ it('createContextual: both roles without noRoot produce same root and all', () => {
949
+ executeContextual({
950
+ acl: {
951
+ roles: {
952
+ role1: {
953
+ variables: {},
954
+ entities: {
955
+ Entity1: {
956
+ predicates: {},
957
+ operations: {
958
+ read: {
959
+ id: true,
960
+ lorem: true,
961
+ },
962
+ },
963
+ },
964
+ },
965
+ },
966
+ role2: {
967
+ variables: {},
968
+ entities: {
969
+ Entity1: {
970
+ predicates: {},
971
+ operations: {
972
+ read: {
973
+ id: true,
974
+ bar: true,
975
+ },
976
+ },
977
+ },
978
+ },
979
+ },
980
+ },
981
+ },
982
+ roles: ['role1', 'role2'],
983
+ rootResult: {
984
+ Entity1: {
985
+ predicates: {},
986
+ operations: {
987
+ read: {
988
+ id: true,
989
+ lorem: true,
990
+ bar: true,
991
+ },
992
+ },
993
+ },
994
+ },
995
+ allResult: {
996
+ Entity1: {
997
+ predicates: {},
998
+ operations: {
999
+ read: {
1000
+ id: true,
1001
+ lorem: true,
1002
+ bar: true,
1003
+ },
1004
+ },
1005
+ },
1006
+ },
1007
+ })
1008
+ })
1009
+ })