@opensaas/stack-core 0.39.2 → 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 +189 -0
- package/CLAUDE.md +103 -0
- package/dist/access/access-filter.d.ts +157 -14
- package/dist/access/access-filter.d.ts.map +1 -1
- package/dist/access/access-filter.js +470 -42
- package/dist/access/access-filter.js.map +1 -1
- package/dist/access/access-filter.test.js +982 -17
- 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 +54 -1
- package/dist/access/engine.d.ts.map +1 -1
- package/dist/access/engine.js +84 -0
- package/dist/access/engine.js.map +1 -1
- package/dist/access/engine.test.d.ts +2 -0
- package/dist/access/engine.test.d.ts.map +1 -0
- package/dist/access/engine.test.js +56 -0
- package/dist/access/engine.test.js.map +1 -0
- package/dist/access/errors.d.ts +62 -0
- package/dist/access/errors.d.ts.map +1 -1
- package/dist/access/errors.js +85 -2
- package/dist/access/errors.js.map +1 -1
- package/dist/access/field-access.d.ts +12 -0
- package/dist/access/field-access.d.ts.map +1 -1
- package/dist/access/field-access.js +22 -2
- package/dist/access/field-access.js.map +1 -1
- package/dist/access/field-access.test.js +66 -0
- package/dist/access/field-access.test.js.map +1 -1
- package/dist/access/field-visibility.d.ts +2 -1
- package/dist/access/field-visibility.d.ts.map +1 -1
- package/dist/access/field-visibility.js +107 -15
- package/dist/access/field-visibility.js.map +1 -1
- package/dist/access/index.d.ts +9 -2
- package/dist/access/index.d.ts.map +1 -1
- package/dist/access/index.js +11 -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 +98 -32
- 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 +35 -2
- package/dist/context/index.d.ts.map +1 -1
- package/dist/context/index.js +121 -27
- 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 +74 -15
- package/dist/context/nested-operations.js.map +1 -1
- package/dist/context/write-pipeline.d.ts +3 -2
- package/dist/context/write-pipeline.d.ts.map +1 -1
- package/dist/context/write-pipeline.js +31 -34
- package/dist/context/write-pipeline.js.map +1 -1
- package/dist/fields/index.d.ts +13 -1
- package/dist/fields/index.d.ts.map +1 -1
- package/dist/fields/index.js +50 -5
- package/dist/fields/index.js.map +1 -1
- package/dist/fields/relationship-foreign-key.test.d.ts +2 -0
- package/dist/fields/relationship-foreign-key.test.d.ts.map +1 -0
- package/dist/fields/relationship-foreign-key.test.js +18 -0
- package/dist/fields/relationship-foreign-key.test.js.map +1 -0
- 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/dist/index.d.ts +4 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +14 -1
- package/dist/index.js.map +1 -1
- package/dist/lib/case-utils.d.ts +15 -0
- package/dist/lib/case-utils.d.ts.map +1 -1
- package/dist/lib/case-utils.js +21 -0
- package/dist/lib/case-utils.js.map +1 -1
- package/dist/lib/case-utils.test.js +23 -1
- package/dist/lib/case-utils.test.js.map +1 -1
- package/dist/lib/prisma-errors.d.ts +38 -0
- package/dist/lib/prisma-errors.d.ts.map +1 -0
- package/dist/lib/prisma-errors.js +53 -0
- package/dist/lib/prisma-errors.js.map +1 -0
- package/dist/mcp/constants.d.ts +23 -0
- package/dist/mcp/constants.d.ts.map +1 -0
- package/dist/mcp/constants.js +23 -0
- package/dist/mcp/constants.js.map +1 -0
- package/dist/mcp/field-schema.d.ts +8 -0
- package/dist/mcp/field-schema.d.ts.map +1 -0
- package/dist/mcp/field-schema.js +69 -0
- package/dist/mcp/field-schema.js.map +1 -0
- package/dist/mcp/handler.d.ts.map +1 -1
- package/dist/mcp/handler.js +76 -90
- package/dist/mcp/handler.js.map +1 -1
- package/dist/mcp/projection.d.ts +62 -0
- package/dist/mcp/projection.d.ts.map +1 -0
- package/dist/mcp/projection.js +390 -0
- package/dist/mcp/projection.js.map +1 -0
- package/dist/utils/password.d.ts +3 -1
- package/dist/utils/password.d.ts.map +1 -1
- package/dist/utils/password.js +4 -2
- package/dist/utils/password.js.map +1 -1
- package/package.json +2 -2
- package/src/access/access-filter.test.ts +1528 -18
- package/src/access/access-filter.ts +708 -42
- package/src/access/denied-relation-visibility.test.ts +173 -0
- package/src/access/engine.test.ts +67 -0
- package/src/access/engine.ts +109 -1
- package/src/access/errors.ts +97 -2
- package/src/access/field-access.test.ts +78 -0
- package/src/access/field-access.ts +34 -2
- package/src/access/field-visibility.ts +171 -25
- package/src/access/index.ts +25 -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 +98 -32
- package/src/context/hook-pipeline.ts +4 -2
- package/src/context/index.ts +203 -42
- package/src/context/nested-operations.ts +96 -27
- package/src/context/write-pipeline.ts +60 -36
- package/src/fields/index.ts +55 -5
- package/src/fields/relationship-foreign-key.test.ts +29 -0
- package/src/hooks/index.ts +22 -15
- package/src/index.ts +17 -1
- package/src/lib/case-utils.test.ts +32 -0
- package/src/lib/case-utils.ts +26 -0
- package/src/lib/prisma-errors.ts +86 -0
- package/src/mcp/constants.ts +24 -0
- package/src/mcp/field-schema.ts +84 -0
- package/src/mcp/handler.ts +109 -107
- package/src/mcp/projection.ts +497 -0
- package/src/utils/password.ts +5 -3
- package/tests/access-relationships.test.ts +3 -3
- package/tests/access.test.ts +83 -0
- package/tests/context.test.ts +452 -17
- package/tests/field-types.test.ts +113 -0
- package/tests/hook-context-secured.test.ts +424 -0
- package/tests/mcp-fields-projection-access.test.ts +250 -0
- package/tests/mcp-handler.test.ts +695 -4
- package/tests/nested-access-and-hooks.test.ts +389 -0
- package/tests/nested-write-synthetic-relation.test.ts +437 -0
- package/tests/password-types.test.ts +28 -0
- package/tests/password.test.ts +16 -2
- package/tests/prisma-errors.test.ts +141 -0
- package/tests/singleton.test.ts +9 -1
- package/tests/to-one-include-access-filter.test.ts +172 -0
- package/tests/with-session.test.ts +243 -0
- package/tests/write-pipeline.test.ts +29 -0
- package/tsconfig.tsbuildinfo +1 -1
|
@@ -1,12 +1,18 @@
|
|
|
1
1
|
import type { Session, AccessContext } from './types.js'
|
|
2
|
-
import type { OpenSaasConfig, FieldConfig } from '../config/types.js'
|
|
3
|
-
import { getRelatedListConfig } from './engine.js'
|
|
2
|
+
import type { OpenSaasConfig, FieldConfig, ListConfig } from '../config/types.js'
|
|
3
|
+
import { getRelatedListConfig, resolveSyntheticReverseRelation } from './engine.js'
|
|
4
4
|
import { checkFieldAccess } from './field-access.js'
|
|
5
5
|
import { RESOLVE_CHAIN_MAX_LENGTH } from './depth-limits.js'
|
|
6
6
|
import { ResolveOutputCycleError } from './errors.js'
|
|
7
7
|
import type { DeclaredOnlyTree } from './declared-dependencies.js'
|
|
8
8
|
import { emptyDeclaredOnlyTree } from './declared-dependencies.js'
|
|
9
9
|
import type { FieldSelectionScope } from '../query/index.js'
|
|
10
|
+
import type { ToOneAccessVisibilityTree, CountAccessDenialTree } from './access-filter.js'
|
|
11
|
+
import {
|
|
12
|
+
emptyToOneAccessVisibilityTree,
|
|
13
|
+
isToOneRelationship,
|
|
14
|
+
emptyCountAccessDenialTree,
|
|
15
|
+
} from './access-filter.js'
|
|
10
16
|
// NOTE: `context/index.ts` imports `filterReadableFields` from this module
|
|
11
17
|
// (via the `access/index.ts` barrel) — this is an intentional cyclic
|
|
12
18
|
// dependency, the same shape and for the same reason as the one documented in
|
|
@@ -36,6 +42,42 @@ import { buildDbDelegate } from '../context/index.js'
|
|
|
36
42
|
* Phase 1 (pre-query row/relation scoping) lives in `access-filter.ts`. See
|
|
37
43
|
* `docs/adr/0001-access-control-is-a-two-phase-read.md` and the access-control
|
|
38
44
|
* glossary in `CONTEXT.md`.
|
|
45
|
+
*
|
|
46
|
+
* **To-one relation nulling (issue #974).** A to-one relation whose related
|
|
47
|
+
* list's `query` access resolves to a filter cannot be scoped by Prisma's own
|
|
48
|
+
* `where` (it only accepts one on a to-many include — see the "To-one
|
|
49
|
+
* relations" section of `access-filter.ts`'s module doc), so
|
|
50
|
+
* `buildAccessScopedInclude` fetches it unscoped and hands this module a
|
|
51
|
+
* `ToOneAccessVisibilityTree` — already resolved, via one batched existence
|
|
52
|
+
* check per relation across the whole read, by
|
|
53
|
+
* `resolveToOneAccessVisibility`. This module is where that resolution
|
|
54
|
+
* actually becomes the caller-visible `null`: a `kind: 'denied'` key is
|
|
55
|
+
* forced to `null` even though it was never fetched at all (the key is
|
|
56
|
+
* absent from `workingItem`), and a `kind: 'visible'` key's fetched row is
|
|
57
|
+
* nulled out unless its id survived the existence check — in both branches,
|
|
58
|
+
* before any field-level access check or `resolveOutput` hook runs, so the
|
|
59
|
+
* rest of the pipeline sees exactly what a denied to-one read has always
|
|
60
|
+
* meant elsewhere: `null`, never a thrown error.
|
|
61
|
+
*
|
|
62
|
+
* **A denied to-many relation is forced to `[]`, the same way (issue
|
|
63
|
+
* #1103).** `buildAccessScopedInclude` drops a to-many relation from
|
|
64
|
+
* `include` on the same outright `query` denial as a to-one one, and records
|
|
65
|
+
* the same `kind: 'denied'` entry for it. The key is therefore just as absent
|
|
66
|
+
* from `workingItem` as a denied to-one key, and the fix is the same fixup
|
|
67
|
+
* loop — it now forces the key present using the field's own declared
|
|
68
|
+
* arity: `null` for a to-one relation (unchanged), `[]` for a to-many one,
|
|
69
|
+
* rather than leaving a to-many key silently missing where the fragment
|
|
70
|
+
* API's `ResultOf` type (`query/index.ts`) promises an array.
|
|
71
|
+
*
|
|
72
|
+
* **`_count` denial injection (issue #1087).** A caller-supplied `_count.select`
|
|
73
|
+
* key whose related list denies `query` access outright is omitted from the
|
|
74
|
+
* select `buildAccessScopedInclude` sends to Prisma — there is no way to ask
|
|
75
|
+
* Prisma for a guaranteed `0`, and no query is needed to know one (unlike the
|
|
76
|
+
* to-one existence check above). This module is where that becomes the
|
|
77
|
+
* caller-visible `0`: every key in a `CountAccessDenialTree` at this level is
|
|
78
|
+
* written into `filtered._count` as `0`, whether or not `_count` came back
|
|
79
|
+
* from the database at all — a count is a session-relative value, and `0` is
|
|
80
|
+
* what "no visible rows" means for it, never an absent key.
|
|
39
81
|
*/
|
|
40
82
|
|
|
41
83
|
type ResolveOutputHookRuntime = (args: {
|
|
@@ -193,6 +235,15 @@ export async function filterReadableFields<T extends Record<string, unknown>>(
|
|
|
193
235
|
// The fragment scope this level was reached under (ADR-0027, see module doc
|
|
194
236
|
// above), and the same tree one level down for each nested relation.
|
|
195
237
|
selection?: FieldSelectionScope,
|
|
238
|
+
// Resolved to-one existence checks at THIS level (issue #974, see module
|
|
239
|
+
// doc above), and the same tree one level down for each nested relation —
|
|
240
|
+
// regardless of that relation's own arity, since a filtered to-one can sit
|
|
241
|
+
// beneath a to-many hop.
|
|
242
|
+
toOneVisibility: ToOneAccessVisibilityTree = emptyToOneAccessVisibilityTree(),
|
|
243
|
+
// `_count.select` keys denied outright at THIS level (issue #1087, see
|
|
244
|
+
// module doc above), and the same tree one level down for each nested
|
|
245
|
+
// relation whose own nested include named a further `_count`.
|
|
246
|
+
countDenials: CountAccessDenialTree = emptyCountAccessDenialTree(),
|
|
196
247
|
): Promise<Partial<T>> {
|
|
197
248
|
const filtered: Record<string, unknown> = {}
|
|
198
249
|
|
|
@@ -256,25 +307,49 @@ export async function filterReadableFields<T extends Record<string, unknown>>(
|
|
|
256
307
|
// cap used to let a relation be scoped correctly at the DB level while
|
|
257
308
|
// still returning with unfiltered fields past this function's own,
|
|
258
309
|
// separately-tracked limit (issue #830).
|
|
310
|
+
const isDeclaredRelationshipField =
|
|
311
|
+
fieldConfig?.type === 'relationship' && 'ref' in fieldConfig && !!fieldConfig.ref
|
|
312
|
+
// A synthetic back-relation (#1082) — no declared field of its own on
|
|
313
|
+
// this list, so resolved by name against the owning list's relationship
|
|
314
|
+
// field instead. `undefined` (not `fieldConfig`) is the signal it's
|
|
315
|
+
// worth trying: a declared-but-non-relationship field (e.g. a scalar or
|
|
316
|
+
// virtual) must fall through to the generic path below, unchanged.
|
|
317
|
+
const synthetic =
|
|
318
|
+
!isDeclaredRelationshipField && fieldConfig === undefined && config && listKey
|
|
319
|
+
? resolveSyntheticReverseRelation(fieldName, listKey, config)
|
|
320
|
+
: null
|
|
321
|
+
|
|
259
322
|
if (
|
|
260
323
|
config &&
|
|
261
|
-
|
|
262
|
-
'ref' in fieldConfig &&
|
|
263
|
-
fieldConfig.ref &&
|
|
324
|
+
(isDeclaredRelationshipField || synthetic) &&
|
|
264
325
|
value !== null &&
|
|
265
326
|
value !== undefined
|
|
266
327
|
) {
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
328
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- ListConfig must accept any TypeInfo
|
|
329
|
+
let relatedConfig: { listName: string; listConfig: ListConfig<any> } | null = null
|
|
330
|
+
|
|
331
|
+
if (isDeclaredRelationshipField) {
|
|
332
|
+
const canRead = await checkFieldAccess(fieldConfig?.access, 'read', {
|
|
333
|
+
...args,
|
|
334
|
+
item: workingItem,
|
|
335
|
+
})
|
|
336
|
+
|
|
337
|
+
if (!canRead) {
|
|
338
|
+
accessDeniedKeys.add(fieldName)
|
|
339
|
+
continue
|
|
340
|
+
}
|
|
276
341
|
|
|
277
|
-
|
|
342
|
+
relatedConfig = getRelatedListConfig(fieldConfig.ref as string, config)
|
|
343
|
+
} else if (synthetic) {
|
|
344
|
+
// No declared field means no field-level `read` gate of its own to
|
|
345
|
+
// check here — the owning list's OWN field-level access is enforced
|
|
346
|
+
// by the recursive `filterReadableFields` call below, exactly as it
|
|
347
|
+
// would be for a declared relationship's related rows.
|
|
348
|
+
relatedConfig = {
|
|
349
|
+
listName: synthetic.sourceListName,
|
|
350
|
+
listConfig: synthetic.sourceListConfig,
|
|
351
|
+
}
|
|
352
|
+
}
|
|
278
353
|
// The declared-only tree for whatever THIS relation's own list computes,
|
|
279
354
|
// e.g. a field on the related list that declares its own `needs`. Falls
|
|
280
355
|
// back to an empty tree when this relation isn't declaration-related at
|
|
@@ -286,6 +361,20 @@ export async function filterReadableFields<T extends Record<string, unknown>>(
|
|
|
286
361
|
// unrestricted — matching what naming a relation without narrowing it
|
|
287
362
|
// further has always meant.
|
|
288
363
|
const nestedSelection = selection?.nested[fieldName]
|
|
364
|
+
// This relation's own resolved to-one visibility, if
|
|
365
|
+
// `buildAccessScopedInclude` flagged anything beneath it (issue #974).
|
|
366
|
+
// Falls back to empty — the common case for a relation with no
|
|
367
|
+
// filtered to-one anywhere in its own nested include.
|
|
368
|
+
const nestedToOneVisibility =
|
|
369
|
+
toOneVisibility.nested[fieldName] ?? emptyToOneAccessVisibilityTree()
|
|
370
|
+
// This key's OWN to-one existence check, if `fieldName` itself is a
|
|
371
|
+
// filtered to-one relation (as opposed to one further down its tree).
|
|
372
|
+
const toOneEntry = toOneVisibility.filters[fieldName]
|
|
373
|
+
// This relation's own denied `_count` keys, if `buildAccessScopedInclude`
|
|
374
|
+
// flagged any beneath it (issue #1087). Falls back to empty — the
|
|
375
|
+
// common case for a relation with no denied `_count` anywhere in its
|
|
376
|
+
// own nested include.
|
|
377
|
+
const nestedCountDenials = countDenials.nested[fieldName] ?? emptyCountAccessDenialTree()
|
|
289
378
|
|
|
290
379
|
if (relatedConfig) {
|
|
291
380
|
if (Array.isArray(value)) {
|
|
@@ -300,20 +389,30 @@ export async function filterReadableFields<T extends Record<string, unknown>>(
|
|
|
300
389
|
relatedConfig.listName,
|
|
301
390
|
nestedDeclaredOnly,
|
|
302
391
|
nestedSelection,
|
|
392
|
+
nestedToOneVisibility,
|
|
393
|
+
nestedCountDenials,
|
|
303
394
|
),
|
|
304
395
|
),
|
|
305
396
|
)
|
|
306
397
|
} else if (typeof value === 'object') {
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
398
|
+
const relatedId = (value as Record<string, unknown>).id
|
|
399
|
+
const isVisible =
|
|
400
|
+
!toOneEntry || toOneEntry.kind !== 'visible' || toOneEntry.ids.has(String(relatedId))
|
|
401
|
+
|
|
402
|
+
filtered[fieldName] = isVisible
|
|
403
|
+
? await filterReadableFields(
|
|
404
|
+
value as Record<string, unknown>,
|
|
405
|
+
relatedConfig.listConfig.fields,
|
|
406
|
+
args,
|
|
407
|
+
config,
|
|
408
|
+
depth + 1,
|
|
409
|
+
relatedConfig.listName,
|
|
410
|
+
nestedDeclaredOnly,
|
|
411
|
+
nestedSelection,
|
|
412
|
+
nestedToOneVisibility,
|
|
413
|
+
nestedCountDenials,
|
|
414
|
+
)
|
|
415
|
+
: null
|
|
317
416
|
}
|
|
318
417
|
} else {
|
|
319
418
|
filtered[fieldName] = value
|
|
@@ -341,6 +440,53 @@ export async function filterReadableFields<T extends Record<string, unknown>>(
|
|
|
341
440
|
}
|
|
342
441
|
}
|
|
343
442
|
|
|
443
|
+
// Relations `buildAccessScopedInclude` denied outright (to-one: issue #974,
|
|
444
|
+
// to-many: issue #1103) were never asked of Prisma at all, so `fieldName`
|
|
445
|
+
// has no entry in `workingItem` and the loop above never visits it. Force
|
|
446
|
+
// it present here — matching what a denied read means everywhere else in
|
|
447
|
+
// the context — rather than leaving the key silently absent: `null` for a
|
|
448
|
+
// to-one relation, `[]` for a to-many one. A synthetic back-relation
|
|
449
|
+
// (`fieldConfig` undefined — #1082) is always to-many, the same arity
|
|
450
|
+
// `buildAccessScopedInclude` assumes for it.
|
|
451
|
+
for (const [fieldName, entry] of Object.entries(toOneVisibility.filters)) {
|
|
452
|
+
if (entry.kind !== 'denied') continue
|
|
453
|
+
if (fieldName in filtered || fieldName in workingItem) continue
|
|
454
|
+
if (selection?.fields && !selection.fields.has(fieldName)) continue
|
|
455
|
+
|
|
456
|
+
const fieldConfig = fieldConfigs[fieldName]
|
|
457
|
+
const canRead = await checkFieldAccess(fieldConfig?.access, 'read', {
|
|
458
|
+
...args,
|
|
459
|
+
item: workingItem,
|
|
460
|
+
})
|
|
461
|
+
|
|
462
|
+
if (!canRead) {
|
|
463
|
+
accessDeniedKeys.add(fieldName)
|
|
464
|
+
continue
|
|
465
|
+
}
|
|
466
|
+
|
|
467
|
+
const isToMany = !fieldConfig || !isToOneRelationship(fieldConfig)
|
|
468
|
+
filtered[fieldName] = isToMany ? [] : null
|
|
469
|
+
}
|
|
470
|
+
|
|
471
|
+
// `_count.select` keys `buildAccessScopedInclude` denied outright (issue
|
|
472
|
+
// #1087) were omitted from the select sent to Prisma, so `_count` may be
|
|
473
|
+
// absent from `workingItem` entirely, or present but missing exactly these
|
|
474
|
+
// keys. Write each denied key in as `0` — matching what a denied count has
|
|
475
|
+
// always meant for the admin list view's own scoped counts — unless a
|
|
476
|
+
// fragment's own selection excluded `_count` altogether, in which case
|
|
477
|
+
// there is nothing to inject it into.
|
|
478
|
+
if (countDenials.keys.size > 0 && !(selection?.fields && !selection.fields.has('_count'))) {
|
|
479
|
+
const existingCount =
|
|
480
|
+
filtered._count && typeof filtered._count === 'object'
|
|
481
|
+
? (filtered._count as Record<string, unknown>)
|
|
482
|
+
: {}
|
|
483
|
+
const mergedCount = { ...existingCount }
|
|
484
|
+
for (const key of countDenials.keys) {
|
|
485
|
+
mergedCount[key] = 0
|
|
486
|
+
}
|
|
487
|
+
filtered._count = mergedCount
|
|
488
|
+
}
|
|
489
|
+
|
|
344
490
|
// The item a virtual field's hook sees: stored columns and fetched
|
|
345
491
|
// relations (from `workingItem`, never a resolved value — no hook's output
|
|
346
492
|
// is ever written back into `workingItem`). A key is visible here if it
|
package/src/access/index.ts
CHANGED
|
@@ -14,11 +14,15 @@ export type {
|
|
|
14
14
|
// Operation-level access primitives and shared ref-parsing helper.
|
|
15
15
|
export {
|
|
16
16
|
checkAccess,
|
|
17
|
+
checkCreateAccess,
|
|
17
18
|
mergeFilters,
|
|
18
19
|
isBoolean,
|
|
19
20
|
isPrismaFilter,
|
|
20
21
|
getRelatedListConfig,
|
|
22
|
+
resolveSyntheticReverseRelation,
|
|
23
|
+
listSyntheticReverseRelationNames,
|
|
21
24
|
} from './engine.js'
|
|
25
|
+
export type { SyntheticReverseRelation } from './engine.js'
|
|
22
26
|
// Canonical field-level access evaluation (shared by read and write paths).
|
|
23
27
|
export {
|
|
24
28
|
checkFieldAccess,
|
|
@@ -36,7 +40,22 @@ export {
|
|
|
36
40
|
buildAccessScopedInclude,
|
|
37
41
|
buildAccessScopedWhere,
|
|
38
42
|
stripVirtualFieldsFromInclude,
|
|
43
|
+
resolveToOneAccessVisibility,
|
|
44
|
+
emptyToOneAccessFilterTree,
|
|
45
|
+
emptyToOneAccessVisibilityTree,
|
|
46
|
+
emptyCountAccessDenialTree,
|
|
39
47
|
} from './access-filter.js'
|
|
48
|
+
export type {
|
|
49
|
+
ToOneAccessFilterTree,
|
|
50
|
+
ToOneAccessFilterEntry,
|
|
51
|
+
ToOneAccessVisibilityTree,
|
|
52
|
+
ToOneVisibility,
|
|
53
|
+
CountAccessDenialTree,
|
|
54
|
+
} from './access-filter.js'
|
|
55
|
+
// Access-scoped to-many relationship counts (admin list view, issue #732)
|
|
56
|
+
// and the shared per-relation resolver `_count` scoping (issue #1087) reuses.
|
|
57
|
+
export { isToManyRelationshipField, resolveCountAccessEntryForList } from './relationship-count.js'
|
|
58
|
+
export type { CountAccessEntry } from './relationship-count.js'
|
|
40
59
|
// Phase 2 — Field Visibility (post-query field stripping + resolveOutput).
|
|
41
60
|
export { filterReadableFields } from './field-visibility.js'
|
|
42
61
|
// Declared Dependencies — folding `needs` into an include without widening
|
|
@@ -53,5 +72,11 @@ export { AccessScopeDepthExceededError } from './errors.js'
|
|
|
53
72
|
export { ResolveOutputCycleError } from './errors.js'
|
|
54
73
|
// Thrown when a field-level access control function returns a non-boolean result.
|
|
55
74
|
export { InvalidFieldAccessResultError } from './errors.js'
|
|
75
|
+
// Thrown when operation-level `create` access control returns a non-boolean result (#1009).
|
|
76
|
+
export { InvalidCreateAccessResultError } from './errors.js'
|
|
56
77
|
// Thrown when a relation filter's related list denies query access outright (#916).
|
|
57
78
|
export { RelationFilterAccessDeniedError } from './errors.js'
|
|
79
|
+
// Thrown when a caller `include` names a key that is neither declared, synthetic, nor `_count` (#1082).
|
|
80
|
+
export { UndeclaredIncludeKeyError } from './errors.js'
|
|
81
|
+
// Thrown when a caller `_count.select` names a key that is not a countable to-many relation (#1087).
|
|
82
|
+
export { UndeclaredCountKeyError } from './errors.js'
|
|
@@ -2,7 +2,8 @@ import { describe, it, expect } from 'vitest'
|
|
|
2
2
|
import { filterReadableFields } from './field-visibility.js'
|
|
3
3
|
import { executeFieldResolveInputHooks, splitMultiColumnFields } from '../hooks/index.js'
|
|
4
4
|
import type { FieldConfig } from '../config/types.js'
|
|
5
|
-
import type {
|
|
5
|
+
import type { FieldAccess } from './types.js'
|
|
6
|
+
import type { StackContext } from '../context/index.js'
|
|
6
7
|
|
|
7
8
|
/**
|
|
8
9
|
* Generic core wiring for multi-column fields (the contract storage
|
|
@@ -48,7 +49,7 @@ function multiColumnField(access?: FieldAccess): FieldConfig {
|
|
|
48
49
|
} as unknown as FieldConfig
|
|
49
50
|
}
|
|
50
51
|
|
|
51
|
-
function makeContext(overrides: { isSudo?: boolean } = {}):
|
|
52
|
+
function makeContext(overrides: { isSudo?: boolean } = {}): StackContext {
|
|
52
53
|
return {
|
|
53
54
|
session: null,
|
|
54
55
|
_isSudo: overrides.isSudo ?? false,
|
|
@@ -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 } }])
|