@opensaas/stack-core 0.32.0 → 0.33.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 (52) hide show
  1. package/.turbo/turbo-build.log +1 -1
  2. package/CHANGELOG.md +26 -0
  3. package/dist/access/access-filter.d.ts +76 -20
  4. package/dist/access/access-filter.d.ts.map +1 -1
  5. package/dist/access/access-filter.js +97 -70
  6. package/dist/access/access-filter.js.map +1 -1
  7. package/dist/access/access-filter.test.js +171 -10
  8. package/dist/access/access-filter.test.js.map +1 -1
  9. package/dist/access/depth-limits.d.ts +12 -0
  10. package/dist/access/depth-limits.d.ts.map +1 -0
  11. package/dist/access/depth-limits.js +12 -0
  12. package/dist/access/depth-limits.js.map +1 -0
  13. package/dist/access/errors.d.ts +19 -0
  14. package/dist/access/errors.d.ts.map +1 -0
  15. package/dist/access/errors.js +29 -0
  16. package/dist/access/errors.js.map +1 -0
  17. package/dist/access/field-visibility.d.ts.map +1 -1
  18. package/dist/access/field-visibility.js +10 -3
  19. package/dist/access/field-visibility.js.map +1 -1
  20. package/dist/access/index.d.ts +3 -1
  21. package/dist/access/index.d.ts.map +1 -1
  22. package/dist/access/index.js +3 -1
  23. package/dist/access/index.js.map +1 -1
  24. package/dist/context/index.d.ts.map +1 -1
  25. package/dist/context/index.js +14 -8
  26. package/dist/context/index.js.map +1 -1
  27. package/dist/context/nested-operations.d.ts +1 -1
  28. package/dist/context/nested-operations.d.ts.map +1 -1
  29. package/dist/context/nested-operations.js +1 -5
  30. package/dist/context/nested-operations.js.map +1 -1
  31. package/dist/context/transaction-boundary.d.ts.map +1 -1
  32. package/dist/context/transaction-boundary.js +43 -6
  33. package/dist/context/transaction-boundary.js.map +1 -1
  34. package/dist/index.d.ts +1 -0
  35. package/dist/index.d.ts.map +1 -1
  36. package/dist/index.js +5 -0
  37. package/dist/index.js.map +1 -1
  38. package/package.json +1 -1
  39. package/src/access/access-filter.test.ts +254 -7
  40. package/src/access/access-filter.ts +141 -72
  41. package/src/access/depth-limits.ts +11 -0
  42. package/src/access/errors.ts +32 -0
  43. package/src/access/field-visibility.ts +10 -3
  44. package/src/access/index.ts +4 -0
  45. package/src/context/index.ts +14 -5
  46. package/src/context/nested-operations.ts +0 -7
  47. package/src/context/transaction-boundary.ts +48 -7
  48. package/src/index.ts +6 -0
  49. package/tests/access-relationships.test.ts +77 -63
  50. package/tests/context.test.ts +106 -24
  51. package/tests/transaction-boundary-hooks.test.ts +246 -1
  52. package/tsconfig.tsbuildinfo +1 -1
@@ -1,5 +1,11 @@
1
1
  import { describe, it, expect } from 'vitest'
2
- import { buildIncludeWithAccessControl, mergeIncludeWithAccessControl } from './access-filter.js'
2
+ import {
3
+ buildIncludeWithAccessControl,
4
+ mergeIncludeWithAccessControl,
5
+ toPrismaInclude,
6
+ } from './access-filter.js'
7
+ import { AccessScopeDepthExceededError } from './errors.js'
8
+ import { READ_INCLUDE_MAX_DEPTH } from './depth-limits.js'
3
9
  import type { OpenSaasConfig, FieldConfig } from '../config/types.js'
4
10
  import type { AccessContext } from './types.js'
5
11
 
@@ -43,11 +49,11 @@ function cyclicConfig(): OpenSaasConfig {
43
49
  } as any
44
50
  }
45
51
 
46
- function makeContext(): AccessContext {
52
+ function makeContext(resolveOutputDepth = 0): AccessContext {
47
53
  return {
48
54
  session: null,
49
55
  _isSudo: false,
50
- _resolveOutputCounter: { depth: 0 },
56
+ _resolveOutputCounter: { depth: resolveOutputDepth },
51
57
  // eslint-disable-next-line @typescript-eslint/no-explicit-any -- minimal context for unit test
52
58
  } as any
53
59
  }
@@ -68,16 +74,67 @@ function includeDepth(include: unknown): number {
68
74
  return max
69
75
  }
70
76
 
77
+ // A straight-line chain of `count` lists, each with a scalar field and a
78
+ // single relationship to the next list: L0 → L1 → … → L(count-1). Every list
79
+ // past the root is query-scoped with a filter unique to it, so a test can
80
+ // assert the merged tree actually carries the right `where` at a given hop
81
+ // (not just that it happens not to throw).
82
+ function chainConfig(count: number): OpenSaasConfig {
83
+ const lists: Record<string, { fields: Record<string, FieldConfig>; access: unknown }> = {}
84
+ for (let i = 0; i < count; i++) {
85
+ const listName = `L${i}`
86
+ const fields: Record<string, FieldConfig> = { name: { type: 'text' } as FieldConfig }
87
+ if (i < count - 1) {
88
+ fields.next = rel(`L${i + 1}.prev`)
89
+ }
90
+ if (i > 0) {
91
+ fields.prev = rel(`L${i - 1}.next`)
92
+ }
93
+ const filter = { ownerId: { equals: listName } }
94
+ lists[listName] = {
95
+ fields,
96
+ access: { operation: { query: i === 0 ? () => true : () => filter } },
97
+ }
98
+ }
99
+ return {
100
+ db: { provider: 'sqlite', url: 'file:./dev.db' },
101
+ lists,
102
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any -- minimal config for unit test
103
+ } as any
104
+ }
105
+
106
+ // A caller `include` value selecting `next` `hops` more times beyond the
107
+ // point at which this value is attached, ending in a bare `true` leaf. E.g.
108
+ // `nestedInclude(0) === true` (stop here); `{ next: nestedInclude(2) }` at the
109
+ // top level names 3 hops total (the outer `next` plus 2 more).
110
+ function nestedInclude(hops: number): Record<string, unknown> {
111
+ if (hops <= 0) return true as unknown as Record<string, unknown>
112
+ return { include: { next: nestedInclude(hops - 1) } }
113
+ }
114
+
115
+ // Read the `where` at the end of a chain of nested `next` includes (used to
116
+ // assert row-scoping survived down to a specific hop).
117
+ function whereAtHop(merged: Record<string, unknown>, hops: number): unknown {
118
+ let current: unknown = merged
119
+ for (let i = 0; i < hops; i++) {
120
+ const entry = (current as { next?: unknown })?.next
121
+ if (i === hops - 1) return (entry as { where?: unknown })?.where
122
+ current = (entry as { include?: unknown })?.include
123
+ }
124
+ return undefined
125
+ }
126
+
71
127
  describe('buildIncludeWithAccessControl — cyclic graph', () => {
72
128
  it('stops re-descending a relationship cycle instead of walking to MAX_DEPTH', async () => {
73
129
  const config = cyclicConfig()
74
- const include = await buildIncludeWithAccessControl(
130
+ const result = await buildIncludeWithAccessControl(
75
131
  config.lists.A.fields,
76
132
  { session: null, context: makeContext() },
77
133
  config,
78
134
  0,
79
135
  ['A'],
80
136
  )
137
+ const include = toPrismaInclude(result)
81
138
 
82
139
  // A → B → C, then C.a closes the cycle back to A → flat (no further nesting).
83
140
  expect(include).toEqual({
@@ -91,7 +148,7 @@ describe('buildIncludeWithAccessControl — cyclic graph', () => {
91
148
  it('flattens a self-referential relationship to a single level', async () => {
92
149
  const allowQuery = () => true
93
150
  const config = {
94
- db: { provider: 'sqlite', url: 'file:./dev.db' },
151
+ db: { provider: 'sqlite' },
95
152
  lists: {
96
153
  Category: {
97
154
  fields: {
@@ -105,13 +162,14 @@ describe('buildIncludeWithAccessControl — cyclic graph', () => {
105
162
  // eslint-disable-next-line @typescript-eslint/no-explicit-any -- minimal config for unit test
106
163
  } as any as OpenSaasConfig
107
164
 
108
- const include = await buildIncludeWithAccessControl(
165
+ const result = await buildIncludeWithAccessControl(
109
166
  config.lists.Category.fields,
110
167
  { session: null, context: makeContext() },
111
168
  config,
112
169
  0,
113
170
  ['Category'],
114
171
  )
172
+ const include = toPrismaInclude(result)
115
173
 
116
174
  // Both self-references come back flat — no infinite parent/children descent.
117
175
  expect(include).toEqual({ parent: true, children: true })
@@ -138,6 +196,7 @@ describe('mergeIncludeWithAccessControl — bare-true leaf on a cyclic graph', (
138
196
  accessControlledInclude,
139
197
  config.lists.A.fields,
140
198
  config,
199
+ 'A',
141
200
  )
142
201
 
143
202
  expect(merged).toEqual({
@@ -159,7 +218,7 @@ describe('mergeIncludeWithAccessControl — caller take on a to-many relation (i
159
218
  // A one-list config whose `posts` to-many is query-scoped by an access filter.
160
219
  function scopedConfig(): OpenSaasConfig {
161
220
  return {
162
- db: { provider: 'sqlite', url: 'file:./dev.db' },
221
+ db: { provider: 'sqlite' },
163
222
  lists: {
164
223
  User: {
165
224
  fields: { name: { type: 'text' } as FieldConfig, posts: rel('Post.author', true) },
@@ -190,6 +249,7 @@ describe('mergeIncludeWithAccessControl — caller take on a to-many relation (i
190
249
  accessControlledInclude,
191
250
  config.lists.User.fields,
192
251
  config,
252
+ 'User',
193
253
  )
194
254
 
195
255
  // The bound rides on top of the access filter — neither is dropped. The
@@ -220,9 +280,196 @@ describe('mergeIncludeWithAccessControl — caller take on a to-many relation (i
220
280
  accessControlledInclude,
221
281
  config.lists.User.fields,
222
282
  config,
283
+ 'User',
223
284
  )
224
285
 
225
286
  // A denied relation is dropped wholesale — the take cannot resurrect it.
226
287
  expect(merged).toEqual({})
227
288
  })
228
289
  })
290
+
291
+ /**
292
+ * Regression coverage for issue #830: the read pipeline used to FAIL OPEN past
293
+ * `READ_INCLUDE_MAX_DEPTH` — a caller-supplied `include` nested deeper than the
294
+ * engine could scope was passed through unscoped rather than denied. These
295
+ * tests pin the exact boundary the fix introduces (ADR-0022): a caller include
296
+ * one level past the cap throws, the same include one level shallower still
297
+ * works, and an unrequested auto-include past the cap stays silent.
298
+ */
299
+ describe('mergeIncludeWithAccessControl — fail-closed at the read-include depth cap (#830)', () => {
300
+ // A relation `hops` hops from the root sits AT the cap boundary (correctly
301
+ // where-scoped, matching the triage report's "F" list); one hop further is
302
+ // the first one the engine cannot scope ("G"). With
303
+ // READ_INCLUDE_MAX_DEPTH = 5 that boundary is hop 5 / hop 6.
304
+ const HOPS_AT_CAP = READ_INCLUDE_MAX_DEPTH
305
+
306
+ it('throws AccessScopeDepthExceededError when the caller include reaches past the cap', async () => {
307
+ // L0 → L1 → … → L6 (one more list than HOPS_AT_CAP + 1) so a caller
308
+ // include can walk `next` one hop past the boundary.
309
+ const chainLength = HOPS_AT_CAP + 2
310
+ const config = chainConfig(chainLength)
311
+
312
+ const accessControlledInclude = await buildIncludeWithAccessControl(
313
+ config.lists.L0.fields,
314
+ { session: null, context: makeContext() },
315
+ config,
316
+ 0,
317
+ ['L0'],
318
+ )
319
+
320
+ // Outer `next` (hop 1) + nestedInclude(HOPS_AT_CAP) (HOPS_AT_CAP more) = HOPS_AT_CAP + 1 hops.
321
+ const callerInclude = { next: nestedInclude(HOPS_AT_CAP) }
322
+
323
+ expect(() =>
324
+ mergeIncludeWithAccessControl(
325
+ callerInclude,
326
+ accessControlledInclude,
327
+ config.lists.L0.fields,
328
+ config,
329
+ 'L0',
330
+ ),
331
+ ).toThrow(AccessScopeDepthExceededError)
332
+ })
333
+
334
+ it('still row-scopes a caller include one level shallower than the cap', async () => {
335
+ const chainLength = HOPS_AT_CAP + 1
336
+ const config = chainConfig(chainLength)
337
+
338
+ const accessControlledInclude = await buildIncludeWithAccessControl(
339
+ config.lists.L0.fields,
340
+ { session: null, context: makeContext() },
341
+ config,
342
+ 0,
343
+ ['L0'],
344
+ )
345
+
346
+ // Outer `next` (hop 1) + nestedInclude(HOPS_AT_CAP - 1) = HOPS_AT_CAP hops total — right at the boundary.
347
+ const callerInclude = { next: nestedInclude(HOPS_AT_CAP - 1) }
348
+
349
+ // Must NOT throw — this depth is within what the engine can scope.
350
+ const merged = mergeIncludeWithAccessControl(
351
+ callerInclude,
352
+ accessControlledInclude,
353
+ config.lists.L0.fields,
354
+ config,
355
+ 'L0',
356
+ )
357
+
358
+ expect(includeDepth(merged)).toBe(HOPS_AT_CAP)
359
+ // The last list in the chain (at the boundary) is genuinely row-scoped —
360
+ // not just present without a throw.
361
+ expect(whereAtHop(merged, HOPS_AT_CAP)).toEqual({ ownerId: { equals: `L${HOPS_AT_CAP}` } })
362
+ })
363
+
364
+ it('does not throw for an ordinary read with no caller include, even on a deep schema', async () => {
365
+ // No caller include at all — the auto-include just stops at the cap
366
+ // silently. This must never throw; only an EXPLICIT caller selection past
367
+ // the cap is a denial.
368
+ const chainLength = HOPS_AT_CAP + 3
369
+ const config = chainConfig(chainLength)
370
+
371
+ const result = await buildIncludeWithAccessControl(
372
+ config.lists.L0.fields,
373
+ { session: null, context: makeContext() },
374
+ config,
375
+ 0,
376
+ ['L0'],
377
+ )
378
+
379
+ expect(() => toPrismaInclude(result)).not.toThrow()
380
+ const include = toPrismaInclude(result)
381
+ expect(includeDepth(include)).toBeLessThanOrEqual(HOPS_AT_CAP)
382
+ })
383
+
384
+ it('a list with no relationships still passes the caller include through unchanged', async () => {
385
+ const config = {
386
+ db: { provider: 'sqlite' },
387
+ lists: {
388
+ Leaf: {
389
+ fields: { name: { type: 'text' } as FieldConfig },
390
+ access: { operation: { query: () => true } },
391
+ },
392
+ },
393
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any -- minimal config for unit test
394
+ } as any as OpenSaasConfig
395
+
396
+ const accessControlledInclude = await buildIncludeWithAccessControl(
397
+ config.lists.Leaf.fields,
398
+ { session: null, context: makeContext() },
399
+ config,
400
+ 0,
401
+ ['Leaf'],
402
+ )
403
+
404
+ expect(accessControlledInclude).toEqual({ kind: 'nothing-to-scope' })
405
+
406
+ // An arbitrary (non-declared-relationship) key passed through unchanged —
407
+ // access control does not govern keys it doesn't recognize as relationships.
408
+ const merged = mergeIncludeWithAccessControl(
409
+ { someUnrelatedKey: true },
410
+ accessControlledInclude,
411
+ config.lists.Leaf.fields,
412
+ config,
413
+ 'Leaf',
414
+ )
415
+ expect(merged).toEqual({ someUnrelatedKey: true })
416
+ })
417
+ })
418
+
419
+ /**
420
+ * Regression coverage for the resolveOutput/virtual-field trigger of #830: a
421
+ * read issued from inside a resolveOutput hook used to lose relation row
422
+ * scoping ENTIRELY (whole-object passthrough) rather than scoping the
423
+ * immediate relation and simply not auto-expanding further. The loop guard
424
+ * that motivated the original passthrough (hooks making DB queries that
425
+ * include relationships back to the same entity) must still hold.
426
+ */
427
+ describe('buildIncludeWithAccessControl — inside a resolveOutput context', () => {
428
+ it('scopes the immediate relation with its access where but does not auto-expand nested relations', async () => {
429
+ const config = chainConfig(4) // L0 → L1 → L2 → L3
430
+ config.lists.L1.access = { operation: { query: () => ({ tenantId: { equals: 'mine' } }) } }
431
+
432
+ const result = await buildIncludeWithAccessControl(
433
+ config.lists.L0.fields,
434
+ { session: null, context: makeContext(1) }, // depth > 0 → inside resolveOutput
435
+ config,
436
+ 0,
437
+ ['L0'],
438
+ )
439
+
440
+ expect(result.kind).toBe('scoped')
441
+ const include = toPrismaInclude(result)
442
+ // L1's own access `where` is applied...
443
+ expect(include).toEqual({ next: { where: { tenantId: { equals: 'mine' } } } })
444
+ // ...but L1's own nested relations (`next` → L2) are NOT auto-included.
445
+ expect(includeDepth(include)).toBe(1)
446
+ })
447
+
448
+ it('terminates on a self-referential relationship instead of looping', async () => {
449
+ const config = {
450
+ db: { provider: 'sqlite' },
451
+ lists: {
452
+ Category: {
453
+ fields: {
454
+ name: { type: 'text' } as FieldConfig,
455
+ parent: rel('Category.children'),
456
+ children: rel('Category.parent', true),
457
+ },
458
+ access: { operation: { query: () => true } },
459
+ },
460
+ },
461
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any -- minimal config for unit test
462
+ } as any as OpenSaasConfig
463
+
464
+ const result = await buildIncludeWithAccessControl(
465
+ config.lists.Category.fields,
466
+ { session: null, context: makeContext(1) },
467
+ config,
468
+ 0,
469
+ ['Category'],
470
+ )
471
+
472
+ const include = toPrismaInclude(result)
473
+ expect(include).toEqual({ parent: true, children: true })
474
+ })
475
+ })
@@ -1,6 +1,8 @@
1
1
  import type { Session, AccessContext, PrismaFilter } from './types.js'
2
2
  import type { OpenSaasConfig, FieldConfig } from '../config/types.js'
3
3
  import { checkAccess, getRelatedListConfig } from './engine.js'
4
+ import { READ_INCLUDE_MAX_DEPTH } from './depth-limits.js'
5
+ import { AccessScopeDepthExceededError } from './errors.js'
4
6
 
5
7
  /**
6
8
  * Access Filter — phase 1 of the two-phase read (pre-query).
@@ -18,9 +20,83 @@ import { checkAccess, getRelatedListConfig } from './engine.js'
18
20
  * glossary in `CONTEXT.md`.
19
21
  */
20
22
 
23
+ /** A single relation entry in a Prisma `include` object (see below). */
24
+ type IncludeEntry = boolean | { where?: PrismaFilter; include?: IncludeObject; take?: number }
25
+ type IncludeObject = Record<string, IncludeEntry>
26
+
27
+ /**
28
+ * The result of trying to compute an access-controlled include for a list's
29
+ * fields. `buildIncludeWithAccessControl` used to collapse three unrelated
30
+ * outcomes into a single overloaded `undefined`: "inside a resolveOutput
31
+ * context", "hit the depth cap", and "no relationships to scope" all looked
32
+ * identical to callers, which is what let a depth-capped relation pass
33
+ * through unscoped (issue #830). This discriminated result keeps them
34
+ * distinguishable all the way to `mergeIncludeWithAccessControl`, which is the
35
+ * only place that knows whether a caller actually asked for the part that
36
+ * couldn't be scoped.
37
+ *
38
+ * - `scoped`: relationships were found and (to the extent depth allows)
39
+ * access-controlled; `include` is the resulting tree.
40
+ * - `nothing-to-scope`: the list genuinely has no relationships to scope, OR
41
+ * we are inside a resolveOutput/virtual-field context and deliberately did
42
+ * not descend into a relation's own nested relations. Passing the caller's
43
+ * include through unchanged here is correct, not a leak.
44
+ * - `depth-exceeded`: we could not evaluate this level at all because it sits
45
+ * at or past `READ_INCLUDE_MAX_DEPTH`. This is a denial: a caller `include`
46
+ * that reaches here must be rejected, not passed through.
47
+ */
48
+ export type AccessIncludeResult =
49
+ | { kind: 'scoped'; include: RichIncludeObject }
50
+ | { kind: 'nothing-to-scope' }
51
+ | { kind: 'depth-exceeded' }
52
+
53
+ /** A relation entry in the rich, provenance-carrying tree `buildIncludeWithAccessControl` builds internally. */
54
+ type RichIncludeEntry = { where?: PrismaFilter; nested: AccessIncludeResult }
55
+ type RichIncludeObject = Record<string, RichIncludeEntry>
56
+
57
+ /**
58
+ * Collapse a rich, provenance-carrying include entry down to the plain
59
+ * Prisma-shaped form. A `nested` result that is `depth-exceeded` or
60
+ * `nothing-to-scope` contributes no `include` key — this is the AUTO-include
61
+ * silently stopping, which is correct when no caller asked for anything past
62
+ * this point (see `AccessIncludeResult` doc comment).
63
+ */
64
+ function toPrismaEntry(entry: RichIncludeEntry): IncludeEntry {
65
+ const result: { where?: PrismaFilter; include?: IncludeObject } = {}
66
+ if (entry.where) result.where = entry.where
67
+ if (entry.nested.kind === 'scoped') {
68
+ const nestedInclude = toPrismaInclude(entry.nested)
69
+ if (nestedInclude && Object.keys(nestedInclude).length > 0) {
70
+ result.include = nestedInclude
71
+ }
72
+ }
73
+ return Object.keys(result).length > 0 ? result : true
74
+ }
75
+
21
76
  /**
22
- * Build Prisma include object with access control filters
23
- * This allows us to filter relationships at the database level instead of in memory
77
+ * Collapse an `AccessIncludeResult` to the plain Prisma `include` shape used
78
+ * when there is no caller-supplied include to merge against (the direct
79
+ * auto-include path). `nothing-to-scope` and `depth-exceeded` both become
80
+ * `undefined` here — at this call site nothing was explicitly requested past
81
+ * either boundary, so there is nothing to deny.
82
+ */
83
+ export function toPrismaInclude(result: AccessIncludeResult): IncludeObject | undefined {
84
+ if (result.kind !== 'scoped') return undefined
85
+ const out: IncludeObject = {}
86
+ for (const [key, entry] of Object.entries(result.include)) {
87
+ out[key] = toPrismaEntry(entry)
88
+ }
89
+ return out
90
+ }
91
+
92
+ /**
93
+ * Build the access-controlled include for a list's fields.
94
+ *
95
+ * This allows us to filter relationships at the database level instead of in
96
+ * memory. Returns an {@link AccessIncludeResult} rather than a plain include
97
+ * object so that `mergeIncludeWithAccessControl` can tell a genuine "nothing
98
+ * to scope" apart from "the engine hit its depth cap" (see that type's doc
99
+ * comment and ADR-0022).
24
100
  */
25
101
  export async function buildIncludeWithAccessControl(
26
102
  fieldConfigs: Record<string, FieldConfig>,
@@ -34,23 +110,22 @@ export async function buildIncludeWithAccessControl(
34
110
  // relationship cycles (A → B → … → A) and stop the auto-include from
35
111
  // re-descending them. Seed it with the root list name at the call site.
36
112
  visitedLists: readonly string[] = [],
37
- ) {
38
- const MAX_DEPTH = 5
39
- if (depth >= MAX_DEPTH) {
40
- return undefined
113
+ ): Promise<AccessIncludeResult> {
114
+ if (depth >= READ_INCLUDE_MAX_DEPTH) {
115
+ return { kind: 'depth-exceeded' }
41
116
  }
42
117
 
43
- // Skip auto-including relationships when inside a resolveOutput hook
44
- // This prevents infinite loops when hooks make DB queries that include
45
- // relationships back to the same entity (e.g., User virtual field queries Posts
46
- // which includes author back to User, triggering the virtual field again)
47
- if (args.context._resolveOutputCounter.depth > 0) {
48
- return undefined
49
- }
50
-
51
- type IncludeEntry = boolean | { where?: PrismaFilter; include?: Record<string, IncludeEntry> }
52
-
53
- const include: Record<string, IncludeEntry> = {}
118
+ // Inside a resolveOutput/virtual-field context we still scope each immediate
119
+ // relation (its own access `where`), but do not auto-expand INTO its nested
120
+ // relations no recursive call is made, so a self-referential relation
121
+ // terminates after one level regardless of cycles. This prevents the
122
+ // infinite loops the original passthrough guarded against (hooks making DB
123
+ // queries that include relationships back to the same entity), while still
124
+ // row-scoping the relation itself rather than skipping scoping altogether
125
+ // (issue #830's second trigger).
126
+ const insideResolveOutput = args.context._resolveOutputCounter.depth > 0
127
+
128
+ const include: RichIncludeObject = {}
54
129
  let hasRelationships = false
55
130
 
56
131
  for (const [fieldName, fieldConfig] of Object.entries(fieldConfigs)) {
@@ -71,13 +146,7 @@ export async function buildIncludeWithAccessControl(
71
146
  continue
72
147
  }
73
148
 
74
- // Build the include entry
75
- const includeEntry: Record<string, unknown> = {}
76
-
77
- // If access returns a filter, add it to the where clause
78
- if (typeof accessResult === 'object') {
79
- includeEntry.where = accessResult
80
- }
149
+ const where = typeof accessResult === 'object' ? accessResult : undefined
81
150
 
82
151
  // Cycle guard: if the related list already appears on the path from the
83
152
  // root, DO NOT auto-include its relationships again. On a cyclic
@@ -90,39 +159,26 @@ export async function buildIncludeWithAccessControl(
90
159
  // genuine single-level fetch, not a re-expansion of the full auto-include.
91
160
  // The relation itself is still included (as a FLAT fetch of its own
92
161
  // columns); only its onward relationships are pruned at the back-edge.
162
+ let nested: AccessIncludeResult = { kind: 'nothing-to-scope' }
93
163
  const relatedListName = relatedConfig.listName
94
- if (!visitedLists.includes(relatedListName)) {
95
- // Recursively build nested includes
96
- const nestedInclude = await buildIncludeWithAccessControl(
164
+ if (!insideResolveOutput && !visitedLists.includes(relatedListName)) {
165
+ nested = await buildIncludeWithAccessControl(
97
166
  relatedConfig.listConfig.fields,
98
167
  args,
99
168
  config,
100
169
  depth + 1,
101
170
  [...visitedLists, relatedListName],
102
171
  )
103
-
104
- if (nestedInclude && Object.keys(nestedInclude).length > 0) {
105
- includeEntry.include = nestedInclude
106
- }
107
172
  }
108
173
 
109
- // Add to include object
110
- include[fieldName] = Object.keys(includeEntry).length > 0 ? includeEntry : true
174
+ include[fieldName] = { where, nested }
111
175
  }
112
176
  }
113
177
  }
114
178
 
115
- return hasRelationships ? include : undefined
179
+ return hasRelationships ? { kind: 'scoped', include } : { kind: 'nothing-to-scope' }
116
180
  }
117
181
 
118
- /**
119
- * A single relation entry in a Prisma `include` object: either a bare `true`
120
- * (fetch with no extra constraints) or an object that scopes the fetch with a
121
- * `where` filter and/or a nested `include`.
122
- */
123
- type IncludeEntry = boolean | { where?: PrismaFilter; include?: IncludeObject; take?: number }
124
- type IncludeObject = Record<string, IncludeEntry>
125
-
126
182
  /** The structured (object) form of a relation include entry. */
127
183
  type IncludeEntryObject = { where?: PrismaFilter; include?: IncludeObject; take?: number }
128
184
 
@@ -193,38 +249,46 @@ function andWhere(
193
249
  * - If the caller names a key that is NOT a config-declared relationship, it is
194
250
  * passed through unchanged (access control does not govern it).
195
251
  *
196
- * The access-controlled include is recursive to `MAX_DEPTH` (see
197
- * `buildIncludeWithAccessControl`); beyond that depth no auto-include exists, so
198
- * deeper caller selections pass through unscoped consistent with the existing
199
- * auto-include behaviour.
252
+ * `accessControlledInclude` is the {@link AccessIncludeResult} for THIS level:
253
+ * - `nothing-to-scope` nothing to merge against (the list has no
254
+ * relationships, or we're inside a resolveOutput context where the caller
255
+ * include is irrelevant to begin with). Pass the caller's include through
256
+ * unchanged — this is a non-denial outcome, not "every relation denied".
257
+ * - `depth-exceeded` → the engine could not compute a scope for THIS level at
258
+ * all because it sits at or past `READ_INCLUDE_MAX_DEPTH`. If the caller
259
+ * named anything here, that is exactly the case that used to pass through
260
+ * unscoped (issue #830): throw `AccessScopeDepthExceededError` instead. An
261
+ * empty caller include at this level (nothing further requested) is not an
262
+ * error — there's simply nothing to do.
263
+ * - `scoped` → the normal per-relation merge below: a declared relationship
264
+ * ABSENT from the access include was denied (drop it); one PRESENT is used
265
+ * as the base, AND-combining `where`s and recursing into nested includes.
200
266
  *
201
- * `accessControlledInclude` being `undefined` is NOT "every relation denied". It
202
- * means no access-controlled include was computed at all — a non-denial outcome
203
- * that `buildIncludeWithAccessControl` returns when inside a `resolveOutput`/
204
- * virtual-field context, at `MAX_DEPTH`, or when the list has no relationships.
205
- * In every one of those cases there is nothing to merge against, so the caller's
206
- * `include` is passed through unchanged (matching the prior `args.include || …`
207
- * fallback). This is distinct from an `undefined` ENTRY inside a defined access
208
- * include, which DOES mean the relation was denied and must be dropped (see the
209
- * per-relation loop below). Only the whole-object `undefined` is a passthrough.
267
+ * `listKey` and `depth` are carried only to build a useful
268
+ * `AccessScopeDepthExceededError` message; they do not affect merge behaviour.
210
269
  */
211
270
  export function mergeIncludeWithAccessControl(
212
271
  callerInclude: Record<string, unknown>,
213
- accessControlledInclude: Record<string, unknown> | undefined,
272
+ accessControlledInclude: AccessIncludeResult,
214
273
  fieldConfigs: Record<string, FieldConfig>,
215
274
  config: OpenSaasConfig,
275
+ listKey: string,
276
+ depth: number = 0,
216
277
  ): Record<string, unknown> {
217
- // No access-controlled include was computed (resolveOutput/virtual context,
218
- // MAX_DEPTH, or a list with no relationships) → nothing to scope against, so
219
- // pass the caller's include through unchanged. Dropping relations here would be
220
- // fail-closed data loss, not a denial. Denied relations are dropped only when a
221
- // defined access include OMITS them (handled per-relation below).
222
- if (accessControlledInclude === undefined) {
278
+ if (accessControlledInclude.kind === 'nothing-to-scope') {
223
279
  return callerInclude
224
280
  }
225
281
 
282
+ if (accessControlledInclude.kind === 'depth-exceeded') {
283
+ const [firstRelationName] = Object.keys(callerInclude)
284
+ if (firstRelationName !== undefined) {
285
+ throw new AccessScopeDepthExceededError(listKey, firstRelationName, depth)
286
+ }
287
+ return {}
288
+ }
289
+
226
290
  const merged: Record<string, unknown> = {}
227
- const accessInclude = accessControlledInclude
291
+ const accessInclude = accessControlledInclude.include
228
292
 
229
293
  for (const [relationName, callerValue] of Object.entries(callerInclude)) {
230
294
  const fieldConfig = fieldConfigs[relationName]
@@ -237,36 +301,41 @@ export function mergeIncludeWithAccessControl(
237
301
  continue
238
302
  }
239
303
 
240
- const accessValue = accessInclude[relationName]
304
+ const accessEntry = accessInclude[relationName]
241
305
 
242
306
  // Declared relationship absent from the access include → query access denied → drop it.
243
- if (accessValue === undefined) {
307
+ if (accessEntry === undefined) {
244
308
  continue
245
309
  }
246
310
 
247
- const accessEntry = asEntryObject(accessValue)
248
311
  const callerEntry = asEntryObject(callerValue)
249
312
 
250
313
  // Resolve the related list's field configs so nested includes merge recursively.
251
314
  const relatedConfig = getRelatedListConfig(fieldConfig.ref as string, config)
252
315
  const relatedFields = relatedConfig?.listConfig.fields
253
316
 
254
- const mergedWhere = andWhere(accessEntry?.where, callerEntry?.where)
317
+ const mergedWhere = andWhere(accessEntry.where, callerEntry?.where)
255
318
 
256
319
  let mergedNested: Record<string, unknown> | undefined
257
- if (callerEntry?.include && relatedFields) {
258
- // Recurse: scope the caller's nested selection against the nested access include.
320
+ if (callerEntry?.include && relatedFields && relatedConfig) {
321
+ // Recurse: scope the caller's nested selection against the nested access
322
+ // result. If that result is 'depth-exceeded', the recursive call itself
323
+ // throws — the caller named something the engine cannot scope.
259
324
  mergedNested = mergeIncludeWithAccessControl(
260
325
  callerEntry.include,
261
- accessEntry?.include,
326
+ accessEntry.nested,
262
327
  relatedFields,
263
328
  config,
329
+ relatedConfig.listName,
330
+ depth + 1,
264
331
  )
265
- } else if (accessEntry?.include) {
332
+ } else if (accessEntry.nested.kind === 'scoped') {
266
333
  // Caller selected the relation bare (no nested include); keep the
267
334
  // access-controlled nested include so deeper relations stay filtered.
268
- mergedNested = accessEntry.include
335
+ mergedNested = toPrismaInclude(accessEntry.nested)
269
336
  }
337
+ // If the caller's entry has no nested include, a 'depth-exceeded' nested
338
+ // result is silent here too — nothing was asked for past this point.
270
339
 
271
340
  const entry: { where?: PrismaFilter; include?: Record<string, unknown>; take?: number } = {}
272
341
  if (mergedWhere) entry.where = mergedWhere
@@ -0,0 +1,11 @@
1
+ /**
2
+ * Maximum nesting depth of relation `include`s that the Access Filter
3
+ * (`buildIncludeWithAccessControl`) will auto-scope on a read.
4
+ *
5
+ * Security implication: this is an access-control boundary, not just a cost
6
+ * bound. Past this depth the engine cannot compute a row/field scope for a
7
+ * relation, so a caller-supplied `include` naming a relation at or beyond it
8
+ * must be treated as a denial (see `AccessScopeDepthExceededError`) rather
9
+ * than passed through unscoped — see ADR-0022 and issue #830.
10
+ */
11
+ export const READ_INCLUDE_MAX_DEPTH = 5