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