@opensaas/stack-core 0.31.1 → 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.
- package/.turbo/turbo-build.log +1 -1
- package/CHANGELOG.md +28 -0
- package/dist/access/access-filter.d.ts +76 -20
- package/dist/access/access-filter.d.ts.map +1 -1
- package/dist/access/access-filter.js +97 -70
- package/dist/access/access-filter.js.map +1 -1
- package/dist/access/access-filter.test.js +171 -10
- package/dist/access/access-filter.test.js.map +1 -1
- package/dist/access/depth-limits.d.ts +12 -0
- package/dist/access/depth-limits.d.ts.map +1 -0
- package/dist/access/depth-limits.js +12 -0
- package/dist/access/depth-limits.js.map +1 -0
- package/dist/access/errors.d.ts +19 -0
- package/dist/access/errors.d.ts.map +1 -0
- package/dist/access/errors.js +29 -0
- package/dist/access/errors.js.map +1 -0
- package/dist/access/field-visibility.d.ts.map +1 -1
- package/dist/access/field-visibility.js +10 -3
- package/dist/access/field-visibility.js.map +1 -1
- package/dist/access/index.d.ts +3 -1
- package/dist/access/index.d.ts.map +1 -1
- package/dist/access/index.js +3 -1
- package/dist/access/index.js.map +1 -1
- package/dist/context/index.d.ts.map +1 -1
- package/dist/context/index.js +14 -8
- package/dist/context/index.js.map +1 -1
- package/dist/context/nested-operations.d.ts +1 -1
- package/dist/context/nested-operations.d.ts.map +1 -1
- package/dist/context/nested-operations.js +1 -5
- package/dist/context/nested-operations.js.map +1 -1
- package/dist/context/transaction-boundary.d.ts.map +1 -1
- package/dist/context/transaction-boundary.js +43 -6
- package/dist/context/transaction-boundary.js.map +1 -1
- package/dist/index.d.ts +1 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +5 -0
- package/dist/index.js.map +1 -1
- package/package.json +1 -1
- package/src/access/access-filter.test.ts +254 -7
- package/src/access/access-filter.ts +141 -72
- package/src/access/depth-limits.ts +11 -0
- package/src/access/errors.ts +32 -0
- package/src/access/field-visibility.ts +10 -3
- package/src/access/index.ts +4 -0
- package/src/context/index.ts +14 -5
- package/src/context/nested-operations.ts +0 -7
- package/src/context/transaction-boundary.ts +48 -7
- package/src/index.ts +6 -0
- package/tests/access-relationships.test.ts +77 -63
- package/tests/context.test.ts +106 -24
- package/tests/transaction-boundary-hooks.test.ts +246 -1
- package/tsconfig.tsbuildinfo +1 -1
|
@@ -1,5 +1,11 @@
|
|
|
1
1
|
import { describe, it, expect } from 'vitest'
|
|
2
|
-
import {
|
|
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:
|
|
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
|
|
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'
|
|
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
|
|
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'
|
|
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
|
-
*
|
|
23
|
-
*
|
|
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
|
-
|
|
39
|
-
|
|
40
|
-
return undefined
|
|
113
|
+
): Promise<AccessIncludeResult> {
|
|
114
|
+
if (depth >= READ_INCLUDE_MAX_DEPTH) {
|
|
115
|
+
return { kind: 'depth-exceeded' }
|
|
41
116
|
}
|
|
42
117
|
|
|
43
|
-
//
|
|
44
|
-
//
|
|
45
|
-
//
|
|
46
|
-
//
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
const include:
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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 :
|
|
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
|
-
*
|
|
197
|
-
* `
|
|
198
|
-
*
|
|
199
|
-
*
|
|
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
|
-
* `
|
|
202
|
-
*
|
|
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:
|
|
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
|
-
|
|
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
|
|
304
|
+
const accessEntry = accessInclude[relationName]
|
|
241
305
|
|
|
242
306
|
// Declared relationship absent from the access include → query access denied → drop it.
|
|
243
|
-
if (
|
|
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
|
|
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
|
|
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
|
|
326
|
+
accessEntry.nested,
|
|
262
327
|
relatedFields,
|
|
263
328
|
config,
|
|
329
|
+
relatedConfig.listName,
|
|
330
|
+
depth + 1,
|
|
264
331
|
)
|
|
265
|
-
} else if (accessEntry
|
|
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.
|
|
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
|