@opensaas/stack-core 0.40.0 → 0.41.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 +39 -0
- package/CLAUDE.md +52 -0
- package/dist/access/access-filter.d.ts +88 -18
- package/dist/access/access-filter.d.ts.map +1 -1
- package/dist/access/access-filter.js +343 -36
- package/dist/access/access-filter.js.map +1 -1
- package/dist/access/access-filter.test.js +759 -6
- package/dist/access/access-filter.test.js.map +1 -1
- package/dist/access/denied-relation-visibility.test.d.ts +2 -0
- package/dist/access/denied-relation-visibility.test.d.ts.map +1 -0
- package/dist/access/denied-relation-visibility.test.js +114 -0
- package/dist/access/denied-relation-visibility.test.js.map +1 -0
- package/dist/access/engine.d.ts +11 -0
- package/dist/access/engine.d.ts.map +1 -1
- package/dist/access/engine.js +26 -0
- package/dist/access/engine.js.map +1 -1
- package/dist/access/errors.d.ts +34 -0
- package/dist/access/errors.d.ts.map +1 -1
- package/dist/access/errors.js +47 -0
- package/dist/access/errors.js.map +1 -1
- package/dist/access/field-visibility.d.ts +2 -2
- package/dist/access/field-visibility.d.ts.map +1 -1
- package/dist/access/field-visibility.js +73 -23
- package/dist/access/field-visibility.js.map +1 -1
- package/dist/access/index.d.ts +7 -3
- package/dist/access/index.d.ts.map +1 -1
- package/dist/access/index.js +9 -2
- package/dist/access/index.js.map +1 -1
- package/dist/access/multi-column-read-write.test.js.map +1 -1
- package/dist/access/query-validation.d.ts +31 -0
- package/dist/access/query-validation.d.ts.map +1 -1
- package/dist/access/query-validation.js +75 -16
- package/dist/access/query-validation.js.map +1 -1
- package/dist/access/relationship-count.d.ts +41 -3
- package/dist/access/relationship-count.d.ts.map +1 -1
- package/dist/access/relationship-count.js +44 -7
- package/dist/access/relationship-count.js.map +1 -1
- package/dist/access/relationship-count.test.js +62 -0
- package/dist/access/relationship-count.test.js.map +1 -1
- package/dist/access/synthetic-include-read.test.d.ts +2 -0
- package/dist/access/synthetic-include-read.test.d.ts.map +1 -0
- package/dist/access/synthetic-include-read.test.js +121 -0
- package/dist/access/synthetic-include-read.test.js.map +1 -0
- package/dist/config/types.d.ts +22 -22
- package/dist/config/types.d.ts.map +1 -1
- package/dist/context/hook-pipeline.d.ts +2 -2
- package/dist/context/hook-pipeline.d.ts.map +1 -1
- package/dist/context/hook-pipeline.js.map +1 -1
- package/dist/context/index.d.ts +16 -1
- package/dist/context/index.d.ts.map +1 -1
- package/dist/context/index.js +76 -26
- package/dist/context/index.js.map +1 -1
- package/dist/context/nested-operations.d.ts +2 -2
- package/dist/context/nested-operations.d.ts.map +1 -1
- package/dist/context/nested-operations.js +21 -3
- package/dist/context/nested-operations.js.map +1 -1
- package/dist/context/write-pipeline.d.ts.map +1 -1
- package/dist/context/write-pipeline.js +23 -29
- package/dist/context/write-pipeline.js.map +1 -1
- package/dist/hooks/index.d.ts +16 -15
- package/dist/hooks/index.d.ts.map +1 -1
- package/dist/hooks/index.js.map +1 -1
- package/package.json +2 -2
- package/src/access/access-filter.test.ts +1403 -159
- package/src/access/access-filter.ts +525 -47
- package/src/access/denied-relation-visibility.test.ts +173 -0
- package/src/access/engine.ts +28 -0
- package/src/access/errors.ts +55 -0
- package/src/access/field-visibility.ts +105 -24
- package/src/access/index.ts +11 -0
- package/src/access/multi-column-read-write.test.ts +3 -2
- package/src/access/query-validation.ts +136 -9
- package/src/access/relationship-count.test.ts +75 -0
- package/src/access/relationship-count.ts +53 -10
- package/src/access/synthetic-include-read.test.ts +179 -0
- package/src/config/types.ts +22 -22
- package/src/context/hook-pipeline.ts +4 -2
- package/src/context/index.ts +120 -39
- package/src/context/nested-operations.ts +35 -11
- package/src/context/write-pipeline.ts +52 -32
- package/src/hooks/index.ts +22 -15
- package/tests/context.test.ts +193 -5
- package/tests/hook-context-secured.test.ts +424 -0
- package/tests/nested-access-and-hooks.test.ts +324 -0
- package/tsconfig.tsbuildinfo +1 -1
|
@@ -46,6 +46,20 @@ import { ValidationError } from '../hooks/index.js'
|
|
|
46
46
|
* deliberately no second copy of either the shape-recognition or the
|
|
47
47
|
* field-read check: `access-filter.ts` supplies the RELATED list at each
|
|
48
48
|
* hop and calls back into the same primitives this module already owns.
|
|
49
|
+
*
|
|
50
|
+
* #1092 — `validateQueryKeys` is also the include-nested counterpart: a
|
|
51
|
+
* `where`/`orderBy` a caller nests inside an `include` entry gets the same
|
|
52
|
+
* #912 check, called by `buildAccessScopedInclude` against the RELATED list
|
|
53
|
+
* instead of the current one — no second key-existence walker for that
|
|
54
|
+
* position. `validateQueryFieldReadAccess` needs no equivalent call there:
|
|
55
|
+
* `checkKeyReadableOrThrow` already treats a key `resolveQueryField` cannot
|
|
56
|
+
* resolve as "already handled elsewhere" and skips it, which is exactly the
|
|
57
|
+
* synthetic-back-relation tolerance below needs — nothing to change. The one
|
|
58
|
+
* thing this position needs that the top-level `where`/`orderBy` never did:
|
|
59
|
+
* a key neither call resolves is tried against the optional
|
|
60
|
+
* `resolveSyntheticRelation` hook before being rejected, because a synthetic
|
|
61
|
+
* back-relation (#1082) is nameable in a nested predicate too. Every
|
|
62
|
+
* existing caller omits the hook, so top-level behavior is unchanged.
|
|
49
63
|
*/
|
|
50
64
|
|
|
51
65
|
// Prisma's logical combinators for a WHERE clause — never field names.
|
|
@@ -68,6 +82,23 @@ export interface ResolvedQueryField {
|
|
|
68
82
|
isRelationship: boolean
|
|
69
83
|
}
|
|
70
84
|
|
|
85
|
+
/**
|
|
86
|
+
* Where an unresolved key's nested predicate should recurse when it turns out
|
|
87
|
+
* to name a synthetic back-relation (#1082) rather than a declared field —
|
|
88
|
+
* the source list the back-relation stands for, NOT `getRelatedListConfig`'s
|
|
89
|
+
* target (a synthetic field carries no `ref` of its own to follow).
|
|
90
|
+
*/
|
|
91
|
+
export interface SyntheticRelationTarget {
|
|
92
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- ListConfig must accept any TypeInfo
|
|
93
|
+
listConfig: ListConfig<any>
|
|
94
|
+
listName: string
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
export type ResolveSyntheticRelation = (
|
|
98
|
+
key: string,
|
|
99
|
+
listName: string,
|
|
100
|
+
) => SyntheticRelationTarget | null
|
|
101
|
+
|
|
71
102
|
/**
|
|
72
103
|
* Resolve a `where`/`orderBy` key against a list's declared fields.
|
|
73
104
|
*
|
|
@@ -133,22 +164,61 @@ function walkWhere(
|
|
|
133
164
|
listName: string,
|
|
134
165
|
config: OpenSaasConfig,
|
|
135
166
|
isSudo: boolean,
|
|
167
|
+
resolveSyntheticRelation?: ResolveSyntheticRelation,
|
|
136
168
|
): void {
|
|
137
169
|
if (where === null || typeof where !== 'object') return
|
|
138
170
|
|
|
139
171
|
if (Array.isArray(where)) {
|
|
140
|
-
for (const entry of where)
|
|
172
|
+
for (const entry of where) {
|
|
173
|
+
walkWhere(entry, listConfig, listName, config, isSudo, resolveSyntheticRelation)
|
|
174
|
+
}
|
|
141
175
|
return
|
|
142
176
|
}
|
|
143
177
|
|
|
144
178
|
for (const [key, value] of Object.entries(where as Record<string, unknown>)) {
|
|
145
179
|
if (LOGICAL_OPERATORS.has(key)) {
|
|
146
|
-
walkWhere(value, listConfig, listName, config, isSudo)
|
|
180
|
+
walkWhere(value, listConfig, listName, config, isSudo, resolveSyntheticRelation)
|
|
147
181
|
continue
|
|
148
182
|
}
|
|
149
183
|
|
|
150
184
|
const resolved = resolveQueryField(key, listConfig.fields)
|
|
151
185
|
if (!resolved) {
|
|
186
|
+
const synthetic = resolveSyntheticRelation?.(key, listName)
|
|
187
|
+
if (synthetic) {
|
|
188
|
+
// A synthetic back-relation is always to-many (#1082's own
|
|
189
|
+
// construction site has no arity branch), so its nested value takes
|
|
190
|
+
// the same two shapes a declared to-many relationship's filter does
|
|
191
|
+
// — walk it exactly like the resolved-relationship branch below,
|
|
192
|
+
// against the synthetic's source list instead of `getRelatedListConfig`.
|
|
193
|
+
if (value !== null && typeof value === 'object' && !Array.isArray(value)) {
|
|
194
|
+
const syntheticEntries = Object.entries(value as Record<string, unknown>)
|
|
195
|
+
const syntheticHasQuantifier = syntheticEntries.some(([k]) => RELATION_QUANTIFIERS.has(k))
|
|
196
|
+
if (syntheticHasQuantifier) {
|
|
197
|
+
for (const [quantifier, quantifierValue] of syntheticEntries) {
|
|
198
|
+
if (RELATION_QUANTIFIERS.has(quantifier)) {
|
|
199
|
+
walkWhere(
|
|
200
|
+
quantifierValue,
|
|
201
|
+
synthetic.listConfig,
|
|
202
|
+
synthetic.listName,
|
|
203
|
+
config,
|
|
204
|
+
isSudo,
|
|
205
|
+
resolveSyntheticRelation,
|
|
206
|
+
)
|
|
207
|
+
}
|
|
208
|
+
}
|
|
209
|
+
} else {
|
|
210
|
+
walkWhere(
|
|
211
|
+
value,
|
|
212
|
+
synthetic.listConfig,
|
|
213
|
+
synthetic.listName,
|
|
214
|
+
config,
|
|
215
|
+
isSudo,
|
|
216
|
+
resolveSyntheticRelation,
|
|
217
|
+
)
|
|
218
|
+
}
|
|
219
|
+
}
|
|
220
|
+
continue
|
|
221
|
+
}
|
|
152
222
|
if (isSudo) continue
|
|
153
223
|
rejectUndeclaredKey(listName, key, 'where')
|
|
154
224
|
}
|
|
@@ -175,7 +245,14 @@ function walkWhere(
|
|
|
175
245
|
// related list.
|
|
176
246
|
for (const [quantifier, quantifierValue] of relationEntries) {
|
|
177
247
|
if (RELATION_QUANTIFIERS.has(quantifier)) {
|
|
178
|
-
walkWhere(
|
|
248
|
+
walkWhere(
|
|
249
|
+
quantifierValue,
|
|
250
|
+
related.listConfig,
|
|
251
|
+
related.listName,
|
|
252
|
+
config,
|
|
253
|
+
isSudo,
|
|
254
|
+
resolveSyntheticRelation,
|
|
255
|
+
)
|
|
179
256
|
}
|
|
180
257
|
}
|
|
181
258
|
} else {
|
|
@@ -185,7 +262,14 @@ function walkWhere(
|
|
|
185
262
|
// whole value object IS the nested WHERE clause here — walk it
|
|
186
263
|
// directly, or an undeclared key reached exactly this way (one hop
|
|
187
264
|
// through a to-one relation) would pass through unchecked.
|
|
188
|
-
walkWhere(
|
|
265
|
+
walkWhere(
|
|
266
|
+
value,
|
|
267
|
+
related.listConfig,
|
|
268
|
+
related.listName,
|
|
269
|
+
config,
|
|
270
|
+
isSudo,
|
|
271
|
+
resolveSyntheticRelation,
|
|
272
|
+
)
|
|
189
273
|
}
|
|
190
274
|
}
|
|
191
275
|
}
|
|
@@ -198,17 +282,41 @@ function walkOrderBy(
|
|
|
198
282
|
listName: string,
|
|
199
283
|
config: OpenSaasConfig,
|
|
200
284
|
isSudo: boolean,
|
|
285
|
+
resolveSyntheticRelation?: ResolveSyntheticRelation,
|
|
201
286
|
): void {
|
|
202
287
|
if (orderBy === null || typeof orderBy !== 'object') return
|
|
203
288
|
|
|
204
289
|
if (Array.isArray(orderBy)) {
|
|
205
|
-
for (const entry of orderBy)
|
|
290
|
+
for (const entry of orderBy) {
|
|
291
|
+
walkOrderBy(entry, listConfig, listName, config, isSudo, resolveSyntheticRelation)
|
|
292
|
+
}
|
|
206
293
|
return
|
|
207
294
|
}
|
|
208
295
|
|
|
209
296
|
for (const [key, value] of Object.entries(orderBy as Record<string, unknown>)) {
|
|
210
297
|
const resolved = resolveQueryField(key, listConfig.fields)
|
|
211
298
|
if (!resolved) {
|
|
299
|
+
const synthetic = resolveSyntheticRelation?.(key, listName)
|
|
300
|
+
if (synthetic) {
|
|
301
|
+
// `{ relation: { _count: 'asc' } }` orders by an aggregate — no
|
|
302
|
+
// nested field name to resolve, matching the declared-relationship
|
|
303
|
+
// branch below.
|
|
304
|
+
if (
|
|
305
|
+
value !== null &&
|
|
306
|
+
typeof value === 'object' &&
|
|
307
|
+
!('_count' in (value as Record<string, unknown>))
|
|
308
|
+
) {
|
|
309
|
+
walkOrderBy(
|
|
310
|
+
value,
|
|
311
|
+
synthetic.listConfig,
|
|
312
|
+
synthetic.listName,
|
|
313
|
+
config,
|
|
314
|
+
isSudo,
|
|
315
|
+
resolveSyntheticRelation,
|
|
316
|
+
)
|
|
317
|
+
}
|
|
318
|
+
continue
|
|
319
|
+
}
|
|
212
320
|
if (isSudo) continue
|
|
213
321
|
rejectUndeclaredKey(listName, key, 'orderBy')
|
|
214
322
|
}
|
|
@@ -220,7 +328,16 @@ function walkOrderBy(
|
|
|
220
328
|
if ('_count' in (value as Record<string, unknown>)) continue
|
|
221
329
|
|
|
222
330
|
const related = getRelatedListConfig(resolved.fieldConfig.ref, config)
|
|
223
|
-
if (related)
|
|
331
|
+
if (related) {
|
|
332
|
+
walkOrderBy(
|
|
333
|
+
value,
|
|
334
|
+
related.listConfig,
|
|
335
|
+
related.listName,
|
|
336
|
+
config,
|
|
337
|
+
isSudo,
|
|
338
|
+
resolveSyntheticRelation,
|
|
339
|
+
)
|
|
340
|
+
}
|
|
224
341
|
}
|
|
225
342
|
}
|
|
226
343
|
}
|
|
@@ -231,6 +348,11 @@ function walkOrderBy(
|
|
|
231
348
|
* Throws a `ValidationError` naming the list and the offending key on the
|
|
232
349
|
* first undeclared key found. `isSudo` bypasses the check entirely, matching
|
|
233
350
|
* the write path's `sudo` escape hatch.
|
|
351
|
+
*
|
|
352
|
+
* `resolveSyntheticRelation` is the include-nested position's own addition
|
|
353
|
+
* (#1092, see module doc comment) — omit it (every top-level `where`/`orderBy`
|
|
354
|
+
* caller does) and a key `resolveQueryField` can't resolve rejects exactly as
|
|
355
|
+
* before.
|
|
234
356
|
*/
|
|
235
357
|
export function validateQueryKeys(args: {
|
|
236
358
|
where?: unknown
|
|
@@ -240,10 +362,15 @@ export function validateQueryKeys(args: {
|
|
|
240
362
|
listName: string
|
|
241
363
|
config: OpenSaasConfig
|
|
242
364
|
isSudo: boolean
|
|
365
|
+
resolveSyntheticRelation?: ResolveSyntheticRelation
|
|
243
366
|
}): void {
|
|
244
|
-
const { where, orderBy, listConfig, listName, config, isSudo } = args
|
|
245
|
-
if (where !== undefined)
|
|
246
|
-
|
|
367
|
+
const { where, orderBy, listConfig, listName, config, isSudo, resolveSyntheticRelation } = args
|
|
368
|
+
if (where !== undefined) {
|
|
369
|
+
walkWhere(where, listConfig, listName, config, isSudo, resolveSyntheticRelation)
|
|
370
|
+
}
|
|
371
|
+
if (orderBy !== undefined) {
|
|
372
|
+
walkOrderBy(orderBy, listConfig, listName, config, isSudo, resolveSyntheticRelation)
|
|
373
|
+
}
|
|
247
374
|
}
|
|
248
375
|
|
|
249
376
|
/**
|
|
@@ -111,6 +111,41 @@ describe('buildRelationshipCountSelect', () => {
|
|
|
111
111
|
)
|
|
112
112
|
expect(select).toBeUndefined()
|
|
113
113
|
})
|
|
114
|
+
|
|
115
|
+
// Issue #1111: a relationship hidden via field-level `read` access must not
|
|
116
|
+
// leak its true count, even when the related list's rows are otherwise
|
|
117
|
+
// fully readable — the same denial an ordinary `include` of that field
|
|
118
|
+
// already gets from `filterReadableFields`'s `checkFieldAccess` call.
|
|
119
|
+
it('omits a relation whose field-level `read` access denies it, even though the related list is fully readable', async () => {
|
|
120
|
+
const config: OpenSaasConfig = {
|
|
121
|
+
db: { provider: 'sqlite', prismaClientConstructor: () => null as never },
|
|
122
|
+
lists: {
|
|
123
|
+
User: list({
|
|
124
|
+
fields: {
|
|
125
|
+
name: text(),
|
|
126
|
+
posts: relationship({
|
|
127
|
+
ref: 'Post.author',
|
|
128
|
+
many: true,
|
|
129
|
+
access: { read: () => false },
|
|
130
|
+
}),
|
|
131
|
+
},
|
|
132
|
+
access: { operation: { query: () => true } },
|
|
133
|
+
}),
|
|
134
|
+
Post: list({
|
|
135
|
+
fields: { title: text(), author: relationship({ ref: 'User.posts' }) },
|
|
136
|
+
access: { operation: { query: () => true } },
|
|
137
|
+
}),
|
|
138
|
+
},
|
|
139
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- minimal config for unit test
|
|
140
|
+
} as any
|
|
141
|
+
|
|
142
|
+
const select = await buildRelationshipCountSelect(
|
|
143
|
+
config.lists.User,
|
|
144
|
+
{ session: null, context: makeContext() },
|
|
145
|
+
config,
|
|
146
|
+
)
|
|
147
|
+
expect(select).toBeUndefined()
|
|
148
|
+
})
|
|
114
149
|
})
|
|
115
150
|
|
|
116
151
|
describe('resolveRelationshipCountFilters', () => {
|
|
@@ -186,6 +221,46 @@ describe('resolveRelationshipCountFilters', () => {
|
|
|
186
221
|
expect(findMany).not.toHaveBeenCalled()
|
|
187
222
|
})
|
|
188
223
|
|
|
224
|
+
// Issue #1111: a relationship denied at the FIELD level (as opposed to the
|
|
225
|
+
// related list's operation-level `query` access, covered above) must also
|
|
226
|
+
// resolve to "always 0, no query needed" — the related list here (Post) is
|
|
227
|
+
// otherwise fully readable, so only the field-level check on `posts` itself
|
|
228
|
+
// can be what's denying it.
|
|
229
|
+
it('resolves a field-level-denied relationship without any query: count is always 0', async () => {
|
|
230
|
+
const config: OpenSaasConfig = {
|
|
231
|
+
db: { provider: 'sqlite', prismaClientConstructor: () => null as never },
|
|
232
|
+
lists: {
|
|
233
|
+
User: list({
|
|
234
|
+
fields: {
|
|
235
|
+
name: text(),
|
|
236
|
+
posts: relationship({
|
|
237
|
+
ref: 'Post.author',
|
|
238
|
+
many: true,
|
|
239
|
+
access: { read: () => false },
|
|
240
|
+
}),
|
|
241
|
+
},
|
|
242
|
+
access: { operation: { query: () => true } },
|
|
243
|
+
}),
|
|
244
|
+
Post: list({
|
|
245
|
+
fields: { title: text(), author: relationship({ ref: 'User.posts' }) },
|
|
246
|
+
access: { operation: { query: () => true } },
|
|
247
|
+
}),
|
|
248
|
+
},
|
|
249
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- minimal config for unit test
|
|
250
|
+
} as any
|
|
251
|
+
const findMany = vi.fn(async () => [])
|
|
252
|
+
const where = { posts: { [RELATIONSHIP_COUNT_FILTER_KEY]: { operator: 'gt', value: 0 } } }
|
|
253
|
+
const resolved = await resolveRelationshipCountFilters(
|
|
254
|
+
where,
|
|
255
|
+
config.lists.User,
|
|
256
|
+
'User',
|
|
257
|
+
{ session: null, context: makeContext(findMany) },
|
|
258
|
+
config,
|
|
259
|
+
)
|
|
260
|
+
expect(resolved).toEqual({ id: { in: [] } })
|
|
261
|
+
expect(findMany).not.toHaveBeenCalled()
|
|
262
|
+
})
|
|
263
|
+
|
|
189
264
|
it('preserves sibling conditions, ANDing the resolved id constraint', async () => {
|
|
190
265
|
const config = makeConfig()
|
|
191
266
|
const findMany = vi.fn(async () => [{ id: 'u2', _count: { posts: 7 } }])
|
|
@@ -1,8 +1,9 @@
|
|
|
1
|
-
import type { Session, AccessContext, PrismaFilter } from './types.js'
|
|
1
|
+
import type { Session, AccessContext, PrismaFilter, FieldAccess } from './types.js'
|
|
2
2
|
import type { OpenSaasConfig, ListConfig, FieldConfig } from '../config/types.js'
|
|
3
3
|
import type { FilterOperator, RelationshipCountFilterMarker } from '../filter/types.js'
|
|
4
4
|
import { RELATIONSHIP_COUNT_FILTER_KEY } from '../filter/types.js'
|
|
5
5
|
import { checkAccess, getRelatedListConfig } from './engine.js'
|
|
6
|
+
import { isFieldReadableForPredicate } from './field-access.js'
|
|
6
7
|
import { getDbKey } from '../lib/case-utils.js'
|
|
7
8
|
|
|
8
9
|
/**
|
|
@@ -14,8 +15,10 @@ import { getDbKey } from '../lib/case-utils.js'
|
|
|
14
15
|
* `_count` (`_count: { select: { orders: { where: <access filter> } } }`), so no
|
|
15
16
|
* per-row query is issued and the count can never include rows the session
|
|
16
17
|
* cannot read. This module is the single place the related list's
|
|
17
|
-
* operation-level `query` access
|
|
18
|
-
* `
|
|
18
|
+
* operation-level `query` access, AND the counting list's own field-level
|
|
19
|
+
* `read` access on the relationship field being counted (issue #1111), are
|
|
20
|
+
* folded into that `_count`, mirroring how `buildAccessScopedInclude` folds
|
|
21
|
+
* both into relation includes.
|
|
19
22
|
*
|
|
20
23
|
* It also resolves the count Filter spec's markers: Prisma cannot compare a
|
|
21
24
|
* relation count in a `where`, so a to-many relationship's Filter spec emits a
|
|
@@ -46,11 +49,56 @@ export function isToManyRelationshipField(field: FieldConfig | undefined): boole
|
|
|
46
49
|
}
|
|
47
50
|
|
|
48
51
|
/** The per-relation entry the count `_count.select` uses for one relationship. */
|
|
49
|
-
type CountAccessEntry =
|
|
52
|
+
export type CountAccessEntry =
|
|
50
53
|
| { kind: 'all' } // related list fully readable → count every related row
|
|
51
54
|
| { kind: 'scoped'; where: PrismaFilter } // count only rows matching the access filter
|
|
52
55
|
| { kind: 'denied' } // related list not readable at all → count is always 0
|
|
53
56
|
|
|
57
|
+
/**
|
|
58
|
+
* Resolve one related list's operation-level `query` access, and — when the
|
|
59
|
+
* counting list has a field of its own for the relationship being counted —
|
|
60
|
+
* that field's own field-level `read` access, into the entry its `_count`
|
|
61
|
+
* select needs. The shared decision both `relationshipCountAccessEntry` below
|
|
62
|
+
* (field → related list, for this module's own admin-list-view and
|
|
63
|
+
* count-filter callers) and `access-filter.ts`'s caller-`_count` scoping
|
|
64
|
+
* (issue #1087, which already has the related list resolved — including a
|
|
65
|
+
* synthetic back-relation's, which has no field of its own on the counting
|
|
66
|
+
* list) build on.
|
|
67
|
+
*
|
|
68
|
+
* `fieldAccess` is the COUNTING list's own relationship field access — not
|
|
69
|
+
* the related list's — mirroring the check `filterReadableFields`
|
|
70
|
+
* (`field-visibility.ts`) runs before returning an ordinary `include` of that
|
|
71
|
+
* same field (issue #1111: a `_count` used to leak the true count of a
|
|
72
|
+
* relationship whose field-level `read` access denied the relationship
|
|
73
|
+
* itself, even when the related list's own rows were otherwise fully
|
|
74
|
+
* readable). Evaluated via `isFieldReadableForPredicate` — the pre-query
|
|
75
|
+
* evaluator #915 already built for the identical problem on `where`/`orderBy`
|
|
76
|
+
* — since there is no fetched row yet at `_count`-select-build time; a rule
|
|
77
|
+
* that depends on one denies here, the same as it would there. Checked BEFORE
|
|
78
|
+
* the related list's `query` access so a field-level denial short-circuits
|
|
79
|
+
* without needing the related list's access rule at all. Omitted entirely
|
|
80
|
+
* (`undefined`) for a synthetic back-relation, which has no field of its own
|
|
81
|
+
* on the counting list to evaluate.
|
|
82
|
+
*/
|
|
83
|
+
export async function resolveCountAccessEntryForList(
|
|
84
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- ListConfig must accept any TypeInfo
|
|
85
|
+
relatedListConfig: ListConfig<any>,
|
|
86
|
+
args: CountArgs,
|
|
87
|
+
fieldAccess?: FieldAccess,
|
|
88
|
+
): Promise<CountAccessEntry> {
|
|
89
|
+
if (fieldAccess) {
|
|
90
|
+
const canReadField = await isFieldReadableForPredicate(fieldAccess, args)
|
|
91
|
+
if (!canReadField) return { kind: 'denied' }
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
const queryAccess = relatedListConfig.access?.operation?.query
|
|
95
|
+
const result = await checkAccess(queryAccess, { session: args.session, context: args.context })
|
|
96
|
+
|
|
97
|
+
if (result === false) return { kind: 'denied' }
|
|
98
|
+
if (typeof result === 'object') return { kind: 'scoped', where: result }
|
|
99
|
+
return { kind: 'all' }
|
|
100
|
+
}
|
|
101
|
+
|
|
54
102
|
/**
|
|
55
103
|
* Resolve the related list's operation-level `query` access for one to-many
|
|
56
104
|
* relationship into the entry its `_count` select needs. Returns `null` when the
|
|
@@ -69,12 +117,7 @@ async function relationshipCountAccessEntry(
|
|
|
69
117
|
const related = getRelatedListConfig(ref, config)
|
|
70
118
|
if (!related) return null
|
|
71
119
|
|
|
72
|
-
|
|
73
|
-
const result = await checkAccess(queryAccess, { session: args.session, context: args.context })
|
|
74
|
-
|
|
75
|
-
if (result === false) return { kind: 'denied' }
|
|
76
|
-
if (typeof result === 'object') return { kind: 'scoped', where: result }
|
|
77
|
-
return { kind: 'all' }
|
|
120
|
+
return resolveCountAccessEntryForList(related.listConfig, args, field.access)
|
|
78
121
|
}
|
|
79
122
|
|
|
80
123
|
/**
|
|
@@ -0,0 +1,179 @@
|
|
|
1
|
+
import { describe, it, expect } from 'vitest'
|
|
2
|
+
import { filterReadableFields } from './field-visibility.js'
|
|
3
|
+
import type { OpenSaasConfig, FieldConfig } from '../config/types.js'
|
|
4
|
+
import type { AccessContext } from './types.js'
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* Regression coverage for issue #1082, Field Visibility half.
|
|
8
|
+
*
|
|
9
|
+
* `access-filter.test.ts` covers the pre-query scoping of a synthetic
|
|
10
|
+
* back-relation (`buildAccessScopedInclude`); these tests cover what happens
|
|
11
|
+
* to the rows it fetches. Before this fix, `filterReadableFields` recognised
|
|
12
|
+
* only a DECLARED relationship field (`fieldConfig?.type === 'relationship'`)
|
|
13
|
+
* as something to recurse into — a synthetic key has no declared field on the
|
|
14
|
+
* list it's fetched through, so it fell to the generic non-relationship
|
|
15
|
+
* branch and was copied into the result verbatim: no field-level `read` gate
|
|
16
|
+
* on the related rows, and no virtual field computed on them.
|
|
17
|
+
*/
|
|
18
|
+
|
|
19
|
+
// A relationship field pointing at another list.
|
|
20
|
+
function rel(ref: string, many = false): FieldConfig {
|
|
21
|
+
return { type: 'relationship', ref, many } as unknown as FieldConfig
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
// A virtual field computed via resolveOutput.
|
|
25
|
+
function virtualField(
|
|
26
|
+
resolveOutput: (args: { item: Record<string, unknown> }) => unknown,
|
|
27
|
+
): FieldConfig {
|
|
28
|
+
return {
|
|
29
|
+
type: 'virtual',
|
|
30
|
+
virtual: true,
|
|
31
|
+
hooks: { resolveOutput },
|
|
32
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- minimal field config for unit test
|
|
33
|
+
} as any as FieldConfig
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
function makeContext(): AccessContext {
|
|
37
|
+
return {
|
|
38
|
+
session: null,
|
|
39
|
+
_isSudo: false,
|
|
40
|
+
_resolveOutputChain: [],
|
|
41
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- minimal context for unit test
|
|
42
|
+
} as any
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
// Term ← Bill.term (list-only ref, no field on Term) — schema generation
|
|
46
|
+
// synthesizes `from_Bill_term` on Term, which no list config declares.
|
|
47
|
+
function syntheticConfig(): OpenSaasConfig {
|
|
48
|
+
return {
|
|
49
|
+
db: { provider: 'sqlite' },
|
|
50
|
+
lists: {
|
|
51
|
+
Term: {
|
|
52
|
+
fields: { name: { type: 'text' } as FieldConfig },
|
|
53
|
+
access: { operation: { query: () => true } },
|
|
54
|
+
},
|
|
55
|
+
Bill: {
|
|
56
|
+
fields: {
|
|
57
|
+
amount: { type: 'integer' } as FieldConfig,
|
|
58
|
+
term: rel('Term'),
|
|
59
|
+
internalNotes: {
|
|
60
|
+
type: 'text',
|
|
61
|
+
access: { read: () => false },
|
|
62
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- minimal field config for unit test
|
|
63
|
+
} as any as FieldConfig,
|
|
64
|
+
label: virtualField(({ item }) => `Bill #${item.amount}`),
|
|
65
|
+
},
|
|
66
|
+
access: { operation: { query: () => true } },
|
|
67
|
+
},
|
|
68
|
+
},
|
|
69
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- minimal config for unit test
|
|
70
|
+
} as any as OpenSaasConfig
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
describe('filterReadableFields — synthetic back-relation (#1082)', () => {
|
|
74
|
+
it('applies the owning list field-level read access to rows fetched through a synthetic key', async () => {
|
|
75
|
+
const config = syntheticConfig()
|
|
76
|
+
const termRow = {
|
|
77
|
+
id: 't1',
|
|
78
|
+
name: 'Term 1',
|
|
79
|
+
from_Bill_term: [{ id: 'b1', amount: 5, internalNotes: 'secret' }],
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
const result = await filterReadableFields(
|
|
83
|
+
termRow,
|
|
84
|
+
config.lists.Term.fields,
|
|
85
|
+
{ session: null, context: makeContext() },
|
|
86
|
+
config,
|
|
87
|
+
0,
|
|
88
|
+
'Term',
|
|
89
|
+
)
|
|
90
|
+
|
|
91
|
+
const bills = result.from_Bill_term as Record<string, unknown>[]
|
|
92
|
+
expect(bills).toHaveLength(1)
|
|
93
|
+
expect(bills[0].amount).toBe(5)
|
|
94
|
+
expect('internalNotes' in bills[0]).toBe(false)
|
|
95
|
+
})
|
|
96
|
+
|
|
97
|
+
it('computes a virtual field on rows fetched through a synthetic key', async () => {
|
|
98
|
+
const config = syntheticConfig()
|
|
99
|
+
const termRow = {
|
|
100
|
+
id: 't1',
|
|
101
|
+
name: 'Term 1',
|
|
102
|
+
from_Bill_term: [{ id: 'b1', amount: 5 }],
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
const result = await filterReadableFields(
|
|
106
|
+
termRow,
|
|
107
|
+
config.lists.Term.fields,
|
|
108
|
+
{ session: null, context: makeContext() },
|
|
109
|
+
config,
|
|
110
|
+
0,
|
|
111
|
+
'Term',
|
|
112
|
+
)
|
|
113
|
+
|
|
114
|
+
const bills = result.from_Bill_term as Record<string, unknown>[]
|
|
115
|
+
expect(bills[0].label).toBe('Bill #5')
|
|
116
|
+
})
|
|
117
|
+
|
|
118
|
+
it('recurses through a to-one value fetched through a synthetic key the same as a to-many', async () => {
|
|
119
|
+
const config = syntheticConfig()
|
|
120
|
+
const termRow = {
|
|
121
|
+
id: 't1',
|
|
122
|
+
name: 'Term 1',
|
|
123
|
+
from_Bill_term: { id: 'b1', amount: 5, internalNotes: 'secret' },
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
const result = await filterReadableFields(
|
|
127
|
+
termRow,
|
|
128
|
+
config.lists.Term.fields,
|
|
129
|
+
{ session: null, context: makeContext() },
|
|
130
|
+
config,
|
|
131
|
+
0,
|
|
132
|
+
'Term',
|
|
133
|
+
)
|
|
134
|
+
|
|
135
|
+
const bill = result.from_Bill_term as Record<string, unknown>
|
|
136
|
+
expect(bill.amount).toBe(5)
|
|
137
|
+
expect(bill.label).toBe('Bill #5')
|
|
138
|
+
expect('internalNotes' in bill).toBe(false)
|
|
139
|
+
})
|
|
140
|
+
|
|
141
|
+
it('leaves a declared, non-relationship field on the row untouched by the synthetic path', async () => {
|
|
142
|
+
// `name` is a declared scalar field on Term itself — the synthetic
|
|
143
|
+
// fallback only ever applies to a key ABSENT from `fieldConfigs`, so it
|
|
144
|
+
// must never interfere with the list's own ordinary fields.
|
|
145
|
+
const config = syntheticConfig()
|
|
146
|
+
const termRow = { id: 't1', name: 'Term 1' }
|
|
147
|
+
|
|
148
|
+
const result = await filterReadableFields(
|
|
149
|
+
termRow,
|
|
150
|
+
config.lists.Term.fields,
|
|
151
|
+
{ session: null, context: makeContext() },
|
|
152
|
+
config,
|
|
153
|
+
0,
|
|
154
|
+
'Term',
|
|
155
|
+
)
|
|
156
|
+
|
|
157
|
+
expect(result).toEqual({ id: 't1', name: 'Term 1' })
|
|
158
|
+
})
|
|
159
|
+
|
|
160
|
+
it('leaves an undeclared, non-synthetic key untouched (no config supplied)', async () => {
|
|
161
|
+
// Mirrors the pre-#1082 narrow-unit-test contract: without `config`
|
|
162
|
+
// there is nothing to resolve a synthetic key against, so the field
|
|
163
|
+
// passes through as a plain value — the same fallback
|
|
164
|
+
// `filterWritableFields` documents for its own `config`-less callers.
|
|
165
|
+
const config = syntheticConfig()
|
|
166
|
+
const termRow = { id: 't1', name: 'Term 1', from_Bill_term: [{ id: 'b1', amount: 5 }] }
|
|
167
|
+
|
|
168
|
+
const result = await filterReadableFields(
|
|
169
|
+
termRow,
|
|
170
|
+
config.lists.Term.fields,
|
|
171
|
+
{ session: null, context: makeContext() },
|
|
172
|
+
undefined,
|
|
173
|
+
0,
|
|
174
|
+
'Term',
|
|
175
|
+
)
|
|
176
|
+
|
|
177
|
+
expect(result.from_Bill_term).toEqual([{ id: 'b1', amount: 5 }])
|
|
178
|
+
})
|
|
179
|
+
})
|