@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.
- package/.turbo/turbo-build.log +1 -1
- package/CHANGELOG.md +26 -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
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
import { READ_INCLUDE_MAX_DEPTH } from './depth-limits.js'
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Thrown when a caller-supplied `include` names a relation nested deeper than
|
|
5
|
+
* the Access Filter can scope (see `READ_INCLUDE_MAX_DEPTH`). Deliberately
|
|
6
|
+
* distinct from `ValidationError`: this is not bad user input, it is the
|
|
7
|
+
* engine refusing to return data it cannot prove is row/field scoped. Code
|
|
8
|
+
* that catches `ValidationError` to report form errors must not silently
|
|
9
|
+
* swallow this.
|
|
10
|
+
*
|
|
11
|
+
* Only an explicit caller selection past the depth cap triggers this — the
|
|
12
|
+
* auto-include silently stopping at the cap (no caller `include` involved)
|
|
13
|
+
* never throws. See ADR-0022 and issue #830.
|
|
14
|
+
*/
|
|
15
|
+
export class AccessScopeDepthExceededError extends Error {
|
|
16
|
+
public listKey: string
|
|
17
|
+
public fieldKey: string
|
|
18
|
+
public depth: number
|
|
19
|
+
|
|
20
|
+
constructor(listKey: string, fieldKey: string, depth: number) {
|
|
21
|
+
super(
|
|
22
|
+
`Cannot compute an access scope for "${listKey}.${fieldKey}" at include depth ${depth}: ` +
|
|
23
|
+
`this exceeds the Access Filter's maximum read-include depth (${READ_INCLUDE_MAX_DEPTH}). ` +
|
|
24
|
+
`A caller-supplied include this deep cannot be row- and field-scoped, so the read is denied ` +
|
|
25
|
+
`rather than returned unscoped. Restructure the query to fetch this relation separately.`,
|
|
26
|
+
)
|
|
27
|
+
this.name = 'AccessScopeDepthExceededError'
|
|
28
|
+
this.listKey = listKey
|
|
29
|
+
this.fieldKey = fieldKey
|
|
30
|
+
this.depth = depth
|
|
31
|
+
}
|
|
32
|
+
}
|
|
@@ -116,7 +116,6 @@ export async function filterReadableFields<T extends Record<string, unknown>>(
|
|
|
116
116
|
listKey?: string,
|
|
117
117
|
): Promise<Partial<T>> {
|
|
118
118
|
const filtered: Record<string, unknown> = {}
|
|
119
|
-
const MAX_DEPTH = 5 // Prevent infinite recursion
|
|
120
119
|
|
|
121
120
|
// Multi-column fields (e.g. storage image()/file() in Keystone-parity mode)
|
|
122
121
|
// back several physical columns rather than one. Before the per-field pass,
|
|
@@ -153,14 +152,22 @@ export async function filterReadableFields<T extends Record<string, unknown>>(
|
|
|
153
152
|
// Handle relationship fields - recursively filter fields within related items
|
|
154
153
|
// Note: Access control filtering is now done at database level via buildIncludeWithAccessControl
|
|
155
154
|
// This only handles field-level access (hiding sensitive fields)
|
|
155
|
+
//
|
|
156
|
+
// Deliberately uncapped: the row/relation scoping in access-filter.ts bounds
|
|
157
|
+
// what gets FETCHED (a caller include past its depth cap is now a denial,
|
|
158
|
+
// not a passthrough — see ADR-0022), so by the time a result reaches this
|
|
159
|
+
// function it is already a finite, acyclic tree whose depth was decided at
|
|
160
|
+
// the pre-query phase. Capping recursion again here independently of that
|
|
161
|
+
// cap used to let a relation be scoped correctly at the DB level while
|
|
162
|
+
// still returning with unfiltered fields past this function's own,
|
|
163
|
+
// separately-tracked limit (issue #830).
|
|
156
164
|
if (
|
|
157
165
|
config &&
|
|
158
166
|
fieldConfig?.type === 'relationship' &&
|
|
159
167
|
'ref' in fieldConfig &&
|
|
160
168
|
fieldConfig.ref &&
|
|
161
169
|
value !== null &&
|
|
162
|
-
value !== undefined
|
|
163
|
-
depth < MAX_DEPTH
|
|
170
|
+
value !== undefined
|
|
164
171
|
) {
|
|
165
172
|
// Gate the relationship on read access before recursing.
|
|
166
173
|
const canRead = await checkFieldAccess(fieldConfig?.access, 'read', {
|
package/src/access/index.ts
CHANGED
|
@@ -26,6 +26,10 @@ export {
|
|
|
26
26
|
buildIncludeWithAccessControl,
|
|
27
27
|
mergeIncludeWithAccessControl,
|
|
28
28
|
stripVirtualFieldsFromInclude,
|
|
29
|
+
toPrismaInclude,
|
|
29
30
|
} from './access-filter.js'
|
|
31
|
+
export type { AccessIncludeResult } from './access-filter.js'
|
|
30
32
|
// Phase 2 — Field Visibility (post-query field stripping + resolveOutput).
|
|
31
33
|
export { filterReadableFields } from './field-visibility.js'
|
|
34
|
+
// Thrown when a caller include reaches past the depth the Access Filter can scope.
|
|
35
|
+
export { AccessScopeDepthExceededError } from './errors.js'
|
package/src/context/index.ts
CHANGED
|
@@ -7,6 +7,7 @@ import {
|
|
|
7
7
|
buildIncludeWithAccessControl,
|
|
8
8
|
mergeIncludeWithAccessControl,
|
|
9
9
|
stripVirtualFieldsFromInclude,
|
|
10
|
+
toPrismaInclude,
|
|
10
11
|
} from '../access/index.js'
|
|
11
12
|
import { ValidationError, DatabaseError } from '../hooks/index.js'
|
|
12
13
|
import { getDbKey } from '../lib/case-utils.js'
|
|
@@ -994,15 +995,19 @@ function createFindUnique<TPrisma extends PrismaClientLike>(
|
|
|
994
995
|
// MERGE (not replace) a caller-supplied include with the access-controlled
|
|
995
996
|
// include: the caller selects WHICH relations to fetch, access control
|
|
996
997
|
// decides WHETHER and WITH WHAT filter (#566). A bare auto-include (no
|
|
997
|
-
// caller include) still uses the access-controlled include directly.
|
|
998
|
+
// caller include) still uses the access-controlled include directly. A
|
|
999
|
+
// caller include naming a relation past the depth the engine can scope
|
|
1000
|
+
// throws `AccessScopeDepthExceededError` (issue #830) rather than being
|
|
1001
|
+
// returned unscoped.
|
|
998
1002
|
include = args.include
|
|
999
1003
|
? mergeIncludeWithAccessControl(
|
|
1000
1004
|
args.include,
|
|
1001
1005
|
accessControlledInclude,
|
|
1002
1006
|
listConfig.fields,
|
|
1003
1007
|
config,
|
|
1008
|
+
listName,
|
|
1004
1009
|
)
|
|
1005
|
-
: accessControlledInclude
|
|
1010
|
+
: toPrismaInclude(accessControlledInclude)
|
|
1006
1011
|
}
|
|
1007
1012
|
|
|
1008
1013
|
// Virtual fields have no database column. Whichever path produced
|
|
@@ -1128,15 +1133,19 @@ function createFindMany<TPrisma extends PrismaClientLike>(
|
|
|
1128
1133
|
// MERGE (not replace) a caller-supplied include with the access-controlled
|
|
1129
1134
|
// include: the caller selects WHICH relations to fetch, access control
|
|
1130
1135
|
// decides WHETHER and WITH WHAT filter (#566). A bare auto-include (no
|
|
1131
|
-
// caller include) still uses the access-controlled include directly.
|
|
1136
|
+
// caller include) still uses the access-controlled include directly. A
|
|
1137
|
+
// caller include naming a relation past the depth the engine can scope
|
|
1138
|
+
// throws `AccessScopeDepthExceededError` (issue #830) rather than being
|
|
1139
|
+
// returned unscoped.
|
|
1132
1140
|
include = args?.include
|
|
1133
1141
|
? mergeIncludeWithAccessControl(
|
|
1134
1142
|
args.include,
|
|
1135
1143
|
accessControlledInclude,
|
|
1136
1144
|
listConfig.fields,
|
|
1137
1145
|
config,
|
|
1146
|
+
listName,
|
|
1138
1147
|
)
|
|
1139
|
-
: accessControlledInclude
|
|
1148
|
+
: toPrismaInclude(accessControlledInclude)
|
|
1140
1149
|
}
|
|
1141
1150
|
|
|
1142
1151
|
// Virtual fields have no database column. Whichever path produced
|
|
@@ -1445,7 +1454,7 @@ function createGet<TPrisma extends PrismaClientLike>(
|
|
|
1445
1454
|
// Try to find the record
|
|
1446
1455
|
const item = await model.findFirst({
|
|
1447
1456
|
where,
|
|
1448
|
-
include: accessControlledInclude,
|
|
1457
|
+
include: toPrismaInclude(accessControlledInclude),
|
|
1449
1458
|
})
|
|
1450
1459
|
|
|
1451
1460
|
// If record exists, return it
|
|
@@ -1312,17 +1312,10 @@ export async function processNestedOperations(
|
|
|
1312
1312
|
// finding) so item-/inputData-dependent field-access rules cannot diverge between
|
|
1313
1313
|
// Phase 5 and the connect site. `undefined` is tolerated (defaults to `{}`).
|
|
1314
1314
|
parentInputData: Record<string, unknown> | undefined = undefined,
|
|
1315
|
-
depth: number = 0,
|
|
1316
1315
|
): Promise<NestedOpsResult> {
|
|
1317
|
-
const MAX_DEPTH = 5
|
|
1318
|
-
|
|
1319
1316
|
const afterTasks: AfterTask[] = []
|
|
1320
1317
|
const includeFields = new Set<string>()
|
|
1321
1318
|
|
|
1322
|
-
if (depth >= MAX_DEPTH) {
|
|
1323
|
-
return { data, afterTasks, includeFields }
|
|
1324
|
-
}
|
|
1325
|
-
|
|
1326
1319
|
const processed: Record<string, unknown> = {}
|
|
1327
1320
|
|
|
1328
1321
|
for (const [fieldName, value] of Object.entries(data)) {
|
|
@@ -55,9 +55,6 @@ export interface InvolvedList {
|
|
|
55
55
|
originalItem: Record<string, unknown> | undefined
|
|
56
56
|
}
|
|
57
57
|
|
|
58
|
-
/** Max nesting depth walked when enumerating involved lists (matches nested-operations). */
|
|
59
|
-
const MAX_DEPTH = 5
|
|
60
|
-
|
|
61
58
|
/** Nested-op kinds whose payloads imply an involved list + operation. */
|
|
62
59
|
const NESTED_OP_OPERATIONS: ReadonlyArray<{ kind: string; operation: WriteOperation }> = [
|
|
63
60
|
{ kind: 'create', operation: 'create' },
|
|
@@ -67,10 +64,49 @@ const NESTED_OP_OPERATIONS: ReadonlyArray<{ kind: string; operation: WriteOperat
|
|
|
67
64
|
{ kind: 'connectOrCreate', operation: 'create' },
|
|
68
65
|
]
|
|
69
66
|
|
|
67
|
+
/** Distinct dedupe-key operations `NESTED_OP_OPERATIONS` can produce (create/update/delete). */
|
|
68
|
+
const DISTINCT_OPERATION_COUNT = new Set(NESTED_OP_OPERATIONS.map((o) => o.operation)).size
|
|
69
|
+
|
|
70
70
|
function isRelationshipField(fieldConfig: FieldConfig | undefined): boolean {
|
|
71
71
|
return fieldConfig?.type === 'relationship'
|
|
72
72
|
}
|
|
73
73
|
|
|
74
|
+
/**
|
|
75
|
+
* The number of distinct (listKey, operation) involvement pairs the walk
|
|
76
|
+
* could ever record starting from `startListName` — computed from the
|
|
77
|
+
* CONFIG's relationship graph (not the payload), so it bounds the walk by
|
|
78
|
+
* what the schema can reach rather than by an arbitrary depth.
|
|
79
|
+
*
|
|
80
|
+
* Used as the saturation bound: once `walkNested` has recorded this many
|
|
81
|
+
* pairs, no further pair can be new, so it stops descending. This replaces
|
|
82
|
+
* the old depth cap as the cost bound (#835) — a payload nesting the config's
|
|
83
|
+
* lists more deeply than any previous cap no longer loses their
|
|
84
|
+
* transaction-boundary hooks, while a payload that repeats the same few
|
|
85
|
+
* lists still terminates promptly instead of walking every entry.
|
|
86
|
+
*/
|
|
87
|
+
function countReachableInvolvementPairs(
|
|
88
|
+
startListName: string,
|
|
89
|
+
startListConfig: ListConfig<any>, // eslint-disable-line @typescript-eslint/no-explicit-any -- ListConfig must accept any TypeInfo
|
|
90
|
+
config: OpenSaasConfig,
|
|
91
|
+
): number {
|
|
92
|
+
const visited = new Set<string>([startListName])
|
|
93
|
+
const queue: Array<ListConfig<any>> = [startListConfig] // eslint-disable-line @typescript-eslint/no-explicit-any -- ListConfig must accept any TypeInfo
|
|
94
|
+
|
|
95
|
+
while (queue.length > 0) {
|
|
96
|
+
const current = queue.shift()!
|
|
97
|
+
for (const fieldConfig of Object.values(current.fields)) {
|
|
98
|
+
if (!isRelationshipField(fieldConfig)) continue
|
|
99
|
+
const relationshipField = fieldConfig as { type: 'relationship'; ref: string }
|
|
100
|
+
const related = getRelatedListConfig(relationshipField.ref, config)
|
|
101
|
+
if (!related || visited.has(related.listName)) continue
|
|
102
|
+
visited.add(related.listName)
|
|
103
|
+
queue.push(related.listConfig)
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
return visited.size * DISTINCT_OPERATION_COUNT
|
|
108
|
+
}
|
|
109
|
+
|
|
74
110
|
function asRecordArray(value: unknown): Array<Record<string, unknown>> {
|
|
75
111
|
if (value == null) return []
|
|
76
112
|
if (Array.isArray(value)) return value.filter((v) => v && typeof v === 'object')
|
|
@@ -117,9 +153,11 @@ function walkNested(
|
|
|
117
153
|
config: OpenSaasConfig,
|
|
118
154
|
out: InvolvedList[],
|
|
119
155
|
seen: Set<string>,
|
|
120
|
-
|
|
156
|
+
maxPairs: number,
|
|
121
157
|
): void {
|
|
122
|
-
|
|
158
|
+
// Every reachable pair is already recorded — no further recursion can add
|
|
159
|
+
// anything new, so stop instead of re-walking the rest of the payload.
|
|
160
|
+
if (!data || seen.size >= maxPairs) return
|
|
123
161
|
|
|
124
162
|
for (const [fieldName, value] of Object.entries(data)) {
|
|
125
163
|
const fieldConfig = fieldConfigs[fieldName]
|
|
@@ -150,10 +188,12 @@ function walkNested(
|
|
|
150
188
|
})
|
|
151
189
|
}
|
|
152
190
|
|
|
191
|
+
if (seen.size >= maxPairs) return
|
|
192
|
+
|
|
153
193
|
// Recurse into each nested entry's own relationship payload.
|
|
154
194
|
for (const entry of entries) {
|
|
155
195
|
const childData = nestedInputData(kind, entry)
|
|
156
|
-
walkNested(childData, relatedListConfig.fields, config, out, seen,
|
|
196
|
+
walkNested(childData, relatedListConfig.fields, config, out, seen, maxPairs)
|
|
157
197
|
}
|
|
158
198
|
}
|
|
159
199
|
}
|
|
@@ -187,9 +227,10 @@ export function enumerateInvolvedLists(args: {
|
|
|
187
227
|
},
|
|
188
228
|
]
|
|
189
229
|
const seen = new Set<string>([`${listName}:${operation}`])
|
|
230
|
+
const maxPairs = countReachableInvolvementPairs(listName, listConfig, config)
|
|
190
231
|
|
|
191
232
|
// Delete has no nested payload to walk (inputData is undefined).
|
|
192
|
-
walkNested(inputData, listConfig.fields, config, out, seen,
|
|
233
|
+
walkNested(inputData, listConfig.fields, config, out, seen, maxPairs)
|
|
193
234
|
|
|
194
235
|
return out
|
|
195
236
|
}
|
package/src/index.ts
CHANGED
|
@@ -62,6 +62,12 @@ export { resolveNavCounts, isListQueryStaticallyDenied } from './config/nav-coun
|
|
|
62
62
|
// Validation error surfaced by write operations
|
|
63
63
|
export { ValidationError } from './hooks/index.js'
|
|
64
64
|
|
|
65
|
+
// Thrown by a read when a caller-supplied `include` names a relation nested
|
|
66
|
+
// deeper than the Access Filter can scope (see ADR-0022). Distinct from
|
|
67
|
+
// `ValidationError` — this is the engine refusing to return unscoped data, not
|
|
68
|
+
// a user-input validation failure.
|
|
69
|
+
export { AccessScopeDepthExceededError } from './access/index.js'
|
|
70
|
+
|
|
65
71
|
// Field self-containment validation — checks each field implements the
|
|
66
72
|
// generation contract (getPrismaType / getTypeScriptType / getZodSchema, or
|
|
67
73
|
// getPrismaRelation for relationships) so a misimplemented field fails early
|
|
@@ -319,9 +319,10 @@ describe('Relationship Access Control', () => {
|
|
|
319
319
|
}
|
|
320
320
|
|
|
321
321
|
// Test that buildIncludeWithAccessControl creates the right where clause
|
|
322
|
-
const { buildIncludeWithAccessControl } =
|
|
322
|
+
const { buildIncludeWithAccessControl, toPrismaInclude } =
|
|
323
|
+
await import('../src/access/index.js')
|
|
323
324
|
|
|
324
|
-
const
|
|
325
|
+
const result = await buildIncludeWithAccessControl(
|
|
325
326
|
config.lists.User.fields,
|
|
326
327
|
{
|
|
327
328
|
session: null,
|
|
@@ -329,6 +330,7 @@ describe('Relationship Access Control', () => {
|
|
|
329
330
|
},
|
|
330
331
|
config,
|
|
331
332
|
)
|
|
333
|
+
const include = toPrismaInclude(result)
|
|
332
334
|
|
|
333
335
|
// Should include posts with a where filter
|
|
334
336
|
expect(include).toBeDefined()
|
|
@@ -486,9 +488,10 @@ describe('Relationship Access Control', () => {
|
|
|
486
488
|
}
|
|
487
489
|
|
|
488
490
|
// Test that buildIncludeWithAccessControl creates session-based where clause
|
|
489
|
-
const { buildIncludeWithAccessControl } =
|
|
491
|
+
const { buildIncludeWithAccessControl, toPrismaInclude } =
|
|
492
|
+
await import('../src/access/index.js')
|
|
490
493
|
|
|
491
|
-
const
|
|
494
|
+
const result = await buildIncludeWithAccessControl(
|
|
492
495
|
config.lists.User.fields,
|
|
493
496
|
{
|
|
494
497
|
session: { userId: '1' },
|
|
@@ -496,6 +499,7 @@ describe('Relationship Access Control', () => {
|
|
|
496
499
|
},
|
|
497
500
|
config,
|
|
498
501
|
)
|
|
502
|
+
const include = toPrismaInclude(result)
|
|
499
503
|
|
|
500
504
|
// Should include posts with session-based where filter
|
|
501
505
|
expect(include).toBeDefined()
|
|
@@ -505,75 +509,85 @@ describe('Relationship Access Control', () => {
|
|
|
505
509
|
})
|
|
506
510
|
})
|
|
507
511
|
|
|
508
|
-
describe('depth
|
|
509
|
-
|
|
510
|
-
|
|
511
|
-
|
|
512
|
-
|
|
513
|
-
|
|
514
|
-
|
|
515
|
-
|
|
516
|
-
|
|
517
|
-
|
|
518
|
-
|
|
519
|
-
|
|
520
|
-
|
|
521
|
-
|
|
522
|
-
|
|
523
|
-
|
|
524
|
-
|
|
525
|
-
|
|
526
|
-
|
|
527
|
-
|
|
528
|
-
|
|
529
|
-
|
|
512
|
+
describe('no depth cap on an acyclic chain (issue #830)', () => {
|
|
513
|
+
/**
|
|
514
|
+
* `filterReadableFields` used to stop recursing into relationships past
|
|
515
|
+
* `MAX_DEPTH` (5), so a nested row past that depth was returned with its
|
|
516
|
+
* field-level `read` access never evaluated and its `resolveOutput` hooks
|
|
517
|
+
* never run — even though the row-scoping half of the pipeline
|
|
518
|
+
* (`buildIncludeWithAccessControl`) may have correctly scoped it. The fix
|
|
519
|
+
* removes this cap: by the time a result reaches this function it is
|
|
520
|
+
* already a finite, ACYCLIC tree bounded by whatever the (now fail-closed)
|
|
521
|
+
* pre-query phase permitted, so there is no infinite-recursion risk left
|
|
522
|
+
* to guard against. This test builds an 8-level-deep chain — deeper than
|
|
523
|
+
* the old cap — with a read-denied field and a resolveOutput hook on the
|
|
524
|
+
* deepest list, and asserts both are applied at every level.
|
|
525
|
+
*/
|
|
526
|
+
it('applies field-level read access and resolveOutput at depth 6+', async () => {
|
|
527
|
+
const allowQuery = () => true
|
|
528
|
+
const chainLength = 8
|
|
529
|
+
const lists: OpenSaasConfig['lists'] = {}
|
|
530
|
+
for (let i = 0; i < chainLength; i++) {
|
|
531
|
+
const isLast = i === chainLength - 1
|
|
532
|
+
lists[`L${i}`] = {
|
|
533
|
+
fields: {
|
|
534
|
+
name: { type: 'text' },
|
|
535
|
+
...(isLast
|
|
536
|
+
? {
|
|
537
|
+
secret: {
|
|
538
|
+
type: 'text',
|
|
539
|
+
access: { read: () => false },
|
|
540
|
+
},
|
|
541
|
+
label: {
|
|
542
|
+
type: 'text',
|
|
543
|
+
hooks: {
|
|
544
|
+
resolveOutput: ({ value }: { value: unknown }) => `resolved:${value}`,
|
|
545
|
+
},
|
|
546
|
+
},
|
|
547
|
+
}
|
|
548
|
+
: {}),
|
|
549
|
+
...(i < chainLength - 1
|
|
550
|
+
? { next: { type: 'relationship', ref: `L${i + 1}.prev` } }
|
|
551
|
+
: {}),
|
|
552
|
+
...(i > 0 ? { prev: { type: 'relationship', ref: `L${i - 1}.next` } } : {}),
|
|
530
553
|
},
|
|
531
|
-
|
|
532
|
-
|
|
533
|
-
title: { type: 'text' },
|
|
534
|
-
author: {
|
|
535
|
-
type: 'relationship',
|
|
536
|
-
ref: 'User.posts',
|
|
537
|
-
},
|
|
538
|
-
},
|
|
539
|
-
access: {
|
|
540
|
-
operation: {
|
|
541
|
-
query: () => true,
|
|
542
|
-
},
|
|
543
|
-
},
|
|
544
|
-
},
|
|
545
|
-
},
|
|
554
|
+
access: { operation: { query: allowQuery } },
|
|
555
|
+
}
|
|
546
556
|
}
|
|
547
|
-
|
|
548
|
-
|
|
549
|
-
|
|
550
|
-
id: '1',
|
|
551
|
-
name: 'John Doe',
|
|
552
|
-
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
553
|
-
posts: [] as any[],
|
|
557
|
+
const config: OpenSaasConfig = {
|
|
558
|
+
db: { provider: 'postgresql', url: 'postgresql://localhost:5432/test' },
|
|
559
|
+
lists,
|
|
554
560
|
}
|
|
555
561
|
|
|
556
|
-
|
|
557
|
-
|
|
558
|
-
|
|
559
|
-
|
|
562
|
+
// Build a genuinely acyclic, materialized nested object — exactly what
|
|
563
|
+
// Prisma would return for this include shape (a fresh object per level,
|
|
564
|
+
// no shared/back references).
|
|
565
|
+
let deepest: Record<string, unknown> = {
|
|
566
|
+
id: `${chainLength - 1}`,
|
|
567
|
+
name: `L${chainLength - 1}`,
|
|
568
|
+
secret: 'TOP-SECRET',
|
|
569
|
+
label: 'raw-value',
|
|
570
|
+
}
|
|
571
|
+
for (let i = chainLength - 2; i >= 0; i--) {
|
|
572
|
+
deepest = { id: `${i}`, name: `L${i}`, next: deepest }
|
|
560
573
|
}
|
|
561
|
-
|
|
562
|
-
user.posts = [post]
|
|
563
574
|
|
|
564
575
|
const result = await filterReadableFields(
|
|
565
|
-
|
|
566
|
-
config.lists.
|
|
567
|
-
{
|
|
568
|
-
session: null,
|
|
569
|
-
context: mockContext,
|
|
570
|
-
},
|
|
576
|
+
deepest,
|
|
577
|
+
config.lists.L0.fields,
|
|
578
|
+
{ session: null, context: mockContext },
|
|
571
579
|
config,
|
|
572
580
|
)
|
|
573
581
|
|
|
574
|
-
//
|
|
575
|
-
|
|
576
|
-
|
|
582
|
+
// Walk down to the deepest level in the filtered result.
|
|
583
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
584
|
+
let current: any = result
|
|
585
|
+
for (let i = 0; i < chainLength - 1; i++) {
|
|
586
|
+
current = current.next
|
|
587
|
+
}
|
|
588
|
+
|
|
589
|
+
expect(current.secret).toBeUndefined() // read-denied field stripped
|
|
590
|
+
expect(current.label).toBe('resolved:raw-value') // resolveOutput ran
|
|
577
591
|
})
|
|
578
592
|
})
|
|
579
593
|
|
package/tests/context.test.ts
CHANGED
|
@@ -2,6 +2,8 @@ import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'
|
|
|
2
2
|
import { getContext } from '../src/context/index.js'
|
|
3
3
|
import { defineFragment } from '../src/query/index.js'
|
|
4
4
|
import { virtual } from '../src/fields/index.js'
|
|
5
|
+
import { AccessScopeDepthExceededError } from '../src/access/index.js'
|
|
6
|
+
import { READ_INCLUDE_MAX_DEPTH } from '../src/access/depth-limits.js'
|
|
5
7
|
import type { OpenSaasConfig } from '../src/config/types.js'
|
|
6
8
|
|
|
7
9
|
describe('getContext', () => {
|
|
@@ -1425,20 +1427,19 @@ describe('getContext', () => {
|
|
|
1425
1427
|
expect(call.include).toEqual({ posts: true })
|
|
1426
1428
|
})
|
|
1427
1429
|
|
|
1428
|
-
// Regression:
|
|
1429
|
-
//
|
|
1430
|
-
//
|
|
1431
|
-
//
|
|
1432
|
-
//
|
|
1433
|
-
//
|
|
1434
|
-
//
|
|
1435
|
-
//
|
|
1436
|
-
|
|
1430
|
+
// Regression for issue #830: a read issued from inside a `resolveOutput`
|
|
1431
|
+
// hook used to lose relation row scoping ENTIRELY — `buildIncludeWithAccessControl`
|
|
1432
|
+
// returned a whole-object `undefined` for the inner read (any
|
|
1433
|
+
// `_resolveOutputCounter.depth > 0`), which `mergeIncludeWithAccessControl`
|
|
1434
|
+
// treated as "nothing to merge against" and passed the caller's include
|
|
1435
|
+
// through completely unscoped. The fix scopes each immediate relation with
|
|
1436
|
+
// its own access `where` while still not auto-EXPANDING into that
|
|
1437
|
+
// relation's own nested relations (preserving the original loop-prevention
|
|
1438
|
+
// — see the self-referential coverage in `access-filter.test.ts`).
|
|
1439
|
+
describe('scopes (without expanding) a caller include used inside a resolveOutput hook (#830)', () => {
|
|
1437
1440
|
// Build an Author config with a virtual field whose resolveOutput issues a
|
|
1438
1441
|
// read WITH an explicit include. While that hook runs,
|
|
1439
|
-
// _resolveOutputCounter.depth > 0
|
|
1440
|
-
// returns undefined for the inner read — exercising the
|
|
1441
|
-
// `accessControlledInclude === undefined` passthrough path.
|
|
1442
|
+
// _resolveOutputCounter.depth > 0.
|
|
1442
1443
|
function configWithResolveOutputProbe(
|
|
1443
1444
|
callerInclude: Record<string, unknown>,
|
|
1444
1445
|
capture: (include: unknown) => void,
|
|
@@ -1467,7 +1468,7 @@ describe('getContext', () => {
|
|
|
1467
1468
|
}
|
|
1468
1469
|
}
|
|
1469
1470
|
|
|
1470
|
-
it('findUnique inside a resolveOutput hook
|
|
1471
|
+
it('findUnique inside a resolveOutput hook row-scopes the relation instead of dropping the where', async () => {
|
|
1471
1472
|
let innerIncludeSeen: unknown
|
|
1472
1473
|
const hookConfig = configWithResolveOutputProbe({ post: true }, (include) => {
|
|
1473
1474
|
innerIncludeSeen = include
|
|
@@ -1479,12 +1480,12 @@ describe('getContext', () => {
|
|
|
1479
1480
|
const context = await getContext(hookConfig, relPrisma, null)
|
|
1480
1481
|
await context.db.author.findUnique({ where: { id: 'a1' } })
|
|
1481
1482
|
|
|
1482
|
-
//
|
|
1483
|
-
//
|
|
1484
|
-
expect(innerIncludeSeen).toEqual({ post:
|
|
1483
|
+
// `post` is still fetched (not dropped) but now carries Post's own
|
|
1484
|
+
// query-access `where` — it is no longer a bare, unscoped `true`.
|
|
1485
|
+
expect(innerIncludeSeen).toEqual({ post: { where: { status: { equals: 'published' } } } })
|
|
1485
1486
|
})
|
|
1486
1487
|
|
|
1487
|
-
it('findMany inside a resolveOutput hook
|
|
1488
|
+
it('findMany inside a resolveOutput hook scopes the relation but does not auto-expand its nested include', async () => {
|
|
1488
1489
|
let innerIncludeSeen: unknown
|
|
1489
1490
|
const hookConfig = configWithResolveOutputProbe(
|
|
1490
1491
|
{ post: { include: { author: true } } },
|
|
@@ -1499,15 +1500,96 @@ describe('getContext', () => {
|
|
|
1499
1500
|
const context = await getContext(hookConfig, relPrisma, null)
|
|
1500
1501
|
await context.db.author.findMany()
|
|
1501
1502
|
|
|
1502
|
-
// The
|
|
1503
|
-
//
|
|
1504
|
-
|
|
1503
|
+
// The caller's own nested selection (`post.include.author`) is honoured
|
|
1504
|
+
// as-is (access control does not auto-descend further here), while
|
|
1505
|
+
// `post` itself picks up its access `where`.
|
|
1506
|
+
expect(innerIncludeSeen).toEqual({
|
|
1507
|
+
post: { where: { status: { equals: 'published' } }, include: { author: true } },
|
|
1508
|
+
})
|
|
1505
1509
|
})
|
|
1510
|
+
})
|
|
1511
|
+
|
|
1512
|
+
// Regression for issue #830: a caller `include` nested deeper than the
|
|
1513
|
+
// Access Filter can scope used to be returned unscoped rather than
|
|
1514
|
+
// denied. This exercises the fix end-to-end through `context.db`,
|
|
1515
|
+
// matching the reproduction in the issue: a chain of lists deep enough
|
|
1516
|
+
// to cross `READ_INCLUDE_MAX_DEPTH`, read as a non-privileged session.
|
|
1517
|
+
describe('fail-closed at the read-include depth cap through context.db (#830)', () => {
|
|
1518
|
+
function chainListConfig(count: number): OpenSaasConfig['lists'] {
|
|
1519
|
+
const lists: OpenSaasConfig['lists'] = {}
|
|
1520
|
+
for (let i = 0; i < count; i++) {
|
|
1521
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- minimal config for unit test
|
|
1522
|
+
const fields: Record<string, any> = { name: { type: 'text' } }
|
|
1523
|
+
if (i < count - 1) fields.next = { type: 'relationship', ref: `C${i + 1}.prev` }
|
|
1524
|
+
if (i > 0) fields.prev = { type: 'relationship', ref: `C${i - 1}.next` }
|
|
1525
|
+
lists[`C${i}`] = {
|
|
1526
|
+
fields,
|
|
1527
|
+
access: {
|
|
1528
|
+
operation: { query: () => (i === 0 ? true : { ownerId: { equals: `C${i}` } }) },
|
|
1529
|
+
},
|
|
1530
|
+
}
|
|
1531
|
+
}
|
|
1532
|
+
return lists
|
|
1533
|
+
}
|
|
1534
|
+
|
|
1535
|
+
// A caller include selecting `next` `hops` more times, ending bare.
|
|
1536
|
+
function nestedCallerInclude(hops: number): Record<string, unknown> {
|
|
1537
|
+
if (hops <= 0) return true as unknown as Record<string, unknown>
|
|
1538
|
+
return { include: { next: nestedCallerInclude(hops - 1) } }
|
|
1539
|
+
}
|
|
1540
|
+
|
|
1541
|
+
it('throws AccessScopeDepthExceededError for a caller include one hop past the cap', async () => {
|
|
1542
|
+
const chainLength = READ_INCLUDE_MAX_DEPTH + 2
|
|
1543
|
+
const chainConfig: OpenSaasConfig = {
|
|
1544
|
+
db: { provider: 'postgresql', url: 'postgresql://localhost:5432/test' },
|
|
1545
|
+
lists: chainListConfig(chainLength),
|
|
1546
|
+
}
|
|
1547
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
1548
|
+
const chainPrisma: any = {}
|
|
1549
|
+
for (let i = 0; i < chainLength; i++) {
|
|
1550
|
+
chainPrisma[`c${i}`] = { findMany: vi.fn(), findFirst: vi.fn(), findUnique: vi.fn() }
|
|
1551
|
+
}
|
|
1552
|
+
|
|
1553
|
+
const context = await getContext(chainConfig, chainPrisma, null)
|
|
1506
1554
|
|
|
1507
|
-
|
|
1508
|
-
|
|
1509
|
-
|
|
1510
|
-
|
|
1555
|
+
await expect(
|
|
1556
|
+
context.db.c0.findMany({
|
|
1557
|
+
include: { next: nestedCallerInclude(READ_INCLUDE_MAX_DEPTH) },
|
|
1558
|
+
}),
|
|
1559
|
+
).rejects.toThrow(AccessScopeDepthExceededError)
|
|
1560
|
+
|
|
1561
|
+
// The database is never even queried — the denial happens before the
|
|
1562
|
+
// Prisma call, not as a post-hoc filter on returned data.
|
|
1563
|
+
expect(chainPrisma.c0.findMany).not.toHaveBeenCalled()
|
|
1564
|
+
})
|
|
1565
|
+
|
|
1566
|
+
it('still returns correctly row-scoped data for the same include one hop shallower', async () => {
|
|
1567
|
+
const chainLength = READ_INCLUDE_MAX_DEPTH + 1
|
|
1568
|
+
const chainConfig: OpenSaasConfig = {
|
|
1569
|
+
db: { provider: 'postgresql', url: 'postgresql://localhost:5432/test' },
|
|
1570
|
+
lists: chainListConfig(chainLength),
|
|
1571
|
+
}
|
|
1572
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
1573
|
+
const chainPrisma: any = {}
|
|
1574
|
+
for (let i = 0; i < chainLength; i++) {
|
|
1575
|
+
chainPrisma[`c${i}`] = { findMany: vi.fn(), findFirst: vi.fn(), findUnique: vi.fn() }
|
|
1576
|
+
}
|
|
1577
|
+
chainPrisma.c0.findMany.mockResolvedValue([])
|
|
1578
|
+
|
|
1579
|
+
const context = await getContext(chainConfig, chainPrisma, null)
|
|
1580
|
+
|
|
1581
|
+
await context.db.c0.findMany({
|
|
1582
|
+
include: { next: nestedCallerInclude(READ_INCLUDE_MAX_DEPTH - 1) },
|
|
1583
|
+
})
|
|
1584
|
+
|
|
1585
|
+
// Walk the built include down to the last list — it must carry that
|
|
1586
|
+
// list's own access `where`, proving row scoping, not just "no throw".
|
|
1587
|
+
let entry = chainPrisma.c0.findMany.mock.calls[0][0].include.next
|
|
1588
|
+
for (let i = 1; i < READ_INCLUDE_MAX_DEPTH; i++) {
|
|
1589
|
+
entry = entry.include.next
|
|
1590
|
+
}
|
|
1591
|
+
expect(entry.where).toEqual({ ownerId: { equals: `C${READ_INCLUDE_MAX_DEPTH}` } })
|
|
1592
|
+
})
|
|
1511
1593
|
})
|
|
1512
1594
|
|
|
1513
1595
|
// Regression for #628: a virtual field named in `include` used to be
|