@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
|
@@ -1,14 +1,29 @@
|
|
|
1
1
|
import type { Session, AccessContext, PrismaFilter } from './types.js'
|
|
2
2
|
import type { OpenSaasConfig, FieldConfig, ListConfig } from '../config/types.js'
|
|
3
|
-
import {
|
|
3
|
+
import {
|
|
4
|
+
checkAccess,
|
|
5
|
+
getRelatedListConfig,
|
|
6
|
+
resolveSyntheticReverseRelation,
|
|
7
|
+
listSyntheticReverseRelationNames,
|
|
8
|
+
} from './engine.js'
|
|
4
9
|
import { READ_INCLUDE_MAX_DEPTH } from './depth-limits.js'
|
|
5
|
-
import {
|
|
10
|
+
import {
|
|
11
|
+
AccessScopeDepthExceededError,
|
|
12
|
+
RelationFilterAccessDeniedError,
|
|
13
|
+
UndeclaredCountKeyError,
|
|
14
|
+
UndeclaredIncludeKeyError,
|
|
15
|
+
} from './errors.js'
|
|
6
16
|
import {
|
|
7
17
|
LOGICAL_OPERATORS,
|
|
8
18
|
RELATION_QUANTIFIERS,
|
|
9
19
|
resolveQueryField,
|
|
20
|
+
validateQueryFieldReadAccess,
|
|
21
|
+
validateQueryKeys,
|
|
10
22
|
walkWhereReadAccess,
|
|
23
|
+
type ResolveSyntheticRelation,
|
|
24
|
+
type SyntheticRelationTarget,
|
|
11
25
|
} from './query-validation.js'
|
|
26
|
+
import { isToManyRelationshipField, resolveCountAccessEntryForList } from './relationship-count.js'
|
|
12
27
|
import { getDbKey } from '../lib/case-utils.js'
|
|
13
28
|
|
|
14
29
|
/**
|
|
@@ -55,6 +70,61 @@ import { getDbKey } from '../lib/case-utils.js'
|
|
|
55
70
|
* former is left as `true` in `include`, unchanged from before; the latter
|
|
56
71
|
* is recorded as `{ kind: 'denied' }`, and `field-visibility.ts` forces the
|
|
57
72
|
* key to `null` without ever asking Prisma for it.
|
|
73
|
+
*
|
|
74
|
+
* **A denied to-many relation is recorded the same way (issue #1103).** An
|
|
75
|
+
* outright `query` denial (`=== false`) drops the relation from `include`
|
|
76
|
+
* regardless of arity — a to-many relation has no more of a `where` shape
|
|
77
|
+
* for "denied" than a to-one one does. Before this, only the to-one branch
|
|
78
|
+
* recorded anything; a denied to-many key was simply absent from the raw
|
|
79
|
+
* row, and `filterReadableFields`'s main loop — which only ever visits keys
|
|
80
|
+
* `Object.entries(workingItem)` actually contains — had nothing to force it
|
|
81
|
+
* with, so it stayed missing from the result instead of coming back `[]`.
|
|
82
|
+
* Now both arities record `{ kind: 'denied' }` in `toOneAccessFilters`, and
|
|
83
|
+
* `field-visibility.ts`'s post-query pass forces the key present using the
|
|
84
|
+
* field's own declared arity: `null` for a to-one relation, `[]` for a
|
|
85
|
+
* to-many one.
|
|
86
|
+
*
|
|
87
|
+
* **A synthetic back-relation is a declared relationship wherever a caller
|
|
88
|
+
* can name one (issue #1082).** A list-only `ref` (`ref: 'Other'`,
|
|
89
|
+
* no target field) makes schema generation synthesize a back-relation on
|
|
90
|
+
* `Other` (`from_<List>_<field>`) because the ORM requires an opposite field
|
|
91
|
+
* there — but no list config declares it, so it is absent from `Other`'s own
|
|
92
|
+
* `fieldConfigs`. An include key that fails the declared-relationship test is
|
|
93
|
+
* resolved via `resolveSyntheticReverseRelation` before being treated as
|
|
94
|
+
* unrecognised; a hit is scoped exactly like the declared relationship field
|
|
95
|
+
* it stands for (its owning list's `query` access, folded `where`, nested
|
|
96
|
+
* recursion, depth), always as a to-many relation — a list-only ref has one
|
|
97
|
+
* construction site and no arity branch. A key that resolves to neither a
|
|
98
|
+
* declared relationship nor a synthetic one is **rejected** (`_count` is
|
|
99
|
+
* handled separately, scoped per named relation rather than resolved as a
|
|
100
|
+
* single relationship key — see `buildAccessScopedCountSelect` below and
|
|
101
|
+
* issue #1087), restoring this module's own denial rule for the one key
|
|
102
|
+
* shape that used to fail open.
|
|
103
|
+
*
|
|
104
|
+
* **A `where`/`orderBy` a caller nests inside an `include` entry is validated
|
|
105
|
+
* against the RELATED list, before it is AND-folded (issue #1092).** Before
|
|
106
|
+
* this, the AND-fold below merged a caller's nested `where` with the access
|
|
107
|
+
* filter and passed the result through unexamined — a key naming a field the
|
|
108
|
+
* session cannot read reached Prisma, a probing oracle over exactly the
|
|
109
|
+
* fields #915 exists to close one level up. `validateQueryKeys`/
|
|
110
|
+
* `validateQueryFieldReadAccess` (`query-validation.ts`, #912/#915) are
|
|
111
|
+
* reused as-is, called here against `relatedConfig` instead of the current
|
|
112
|
+
* list — the same two checks the top-level `where`/`orderBy` already gets.
|
|
113
|
+
* The one addition this position needs: an unresolved key is tried against
|
|
114
|
+
* `resolveSyntheticReverseRelation` (via `validateQueryKeys`'s
|
|
115
|
+
* `resolveSyntheticRelation` hook) before being rejected, so a nested
|
|
116
|
+
* predicate naming a synthetic back-relation resolves rather than throwing.
|
|
117
|
+
*
|
|
118
|
+
* Those two checks only reach the entry's OWN top-level keys. A relation
|
|
119
|
+
* quantifier (`some`/`every`/`none`/`is`/`isNot`) nested inside that `where`
|
|
120
|
+
* names a list one hop further away, which needs the same treatment the
|
|
121
|
+
* top-level `where` already gets from `buildAccessScopedWhere` (#916,
|
|
122
|
+
* below): scope it by THAT list's own `query` access and check its fields'
|
|
123
|
+
* read access, recursing through every further hop. Skipping this for a
|
|
124
|
+
* to-many entry would just move #1092's oracle one hop further out instead
|
|
125
|
+
* of closing it — `buildAccessScopedWhere` is called on `requestedEntry.where`
|
|
126
|
+
* before the AND-fold, with its own `resolveSyntheticRelation` param (below)
|
|
127
|
+
* so a synthetic key stays resolved at this deeper level too.
|
|
58
128
|
*/
|
|
59
129
|
|
|
60
130
|
/** The structured (object) form of a relation include entry — caller/fold-supplied or produced by this module. */
|
|
@@ -114,22 +184,35 @@ function andWhere(
|
|
|
114
184
|
return accessWhere ?? callerWhere
|
|
115
185
|
}
|
|
116
186
|
|
|
117
|
-
/**
|
|
187
|
+
/**
|
|
188
|
+
* One relation's recorded access filter, or an outright denial — see the
|
|
189
|
+
* module doc's "To-one relations" section. `kind: 'scoped'` is to-one only
|
|
190
|
+
* (a to-many filter is attached as Prisma `where` instead, never recorded
|
|
191
|
+
* here); `kind: 'denied'` is recorded for BOTH arities (issue #1103) — a
|
|
192
|
+
* to-many relation has no `where`-based way to record "zero rows, and the
|
|
193
|
+
* key itself absent" either, so it shares the same post-query mechanism a
|
|
194
|
+
* denied to-one already used.
|
|
195
|
+
*/
|
|
118
196
|
export type ToOneAccessFilterEntry =
|
|
119
197
|
{ kind: 'scoped'; relatedListName: string; accessWhere: PrismaFilter } | { kind: 'denied' }
|
|
120
198
|
|
|
121
199
|
/**
|
|
122
|
-
* Which
|
|
123
|
-
*
|
|
124
|
-
*
|
|
125
|
-
*
|
|
126
|
-
*
|
|
127
|
-
*
|
|
200
|
+
* Which relations, at which nesting level of an `include`, need a post-query
|
|
201
|
+
* fixup rather than a Prisma-side `where` — a to-one relation whose related
|
|
202
|
+
* list's `query` access resolved to a filter (`kind: 'scoped'`) or a denial
|
|
203
|
+
* (`kind: 'denied'`), Prisma cannot express either as a nested `where` on a
|
|
204
|
+
* to-one include; a to-many relation whose related list denies `query`
|
|
205
|
+
* access outright (`kind: 'denied'` only — a to-many filter is attached as
|
|
206
|
+
* `where` and never reaches this tree) is dropped from `include` entirely,
|
|
207
|
+
* so nothing marks its key present in the raw row either (issue #1103).
|
|
208
|
+
* `resolveToOneAccessVisibility` consumes this tree; `filterReadableFields`
|
|
209
|
+
* (`field-visibility.ts`) applies its result — forcing a denied key to `null`
|
|
210
|
+
* for a to-one relation, `[]` for a to-many one.
|
|
128
211
|
*/
|
|
129
212
|
export type ToOneAccessFilterTree = {
|
|
130
|
-
/**
|
|
213
|
+
/** Relation keys at THIS level needing a post-query fixup. */
|
|
131
214
|
filters: Record<string, ToOneAccessFilterEntry>
|
|
132
|
-
/** Per-key trees for relations present in the include for other reasons, whose own nested include may contain further
|
|
215
|
+
/** Per-key trees for relations present in the include for other reasons, whose own nested include may contain further filters. */
|
|
133
216
|
nested: Record<string, ToOneAccessFilterTree>
|
|
134
217
|
}
|
|
135
218
|
|
|
@@ -141,26 +224,270 @@ function isToOneAccessFilterTreeEmpty(tree: ToOneAccessFilterTree): boolean {
|
|
|
141
224
|
return Object.keys(tree.filters).length === 0 && Object.keys(tree.nested).length === 0
|
|
142
225
|
}
|
|
143
226
|
|
|
144
|
-
/**
|
|
145
|
-
|
|
227
|
+
/**
|
|
228
|
+
* Whether a relationship field is to-one (at most one related row) rather
|
|
229
|
+
* than to-many. Exported so `field-visibility.ts` can pick the same `null`
|
|
230
|
+
* (to-one) vs `[]` (to-many) shape for a denied relation's forced value
|
|
231
|
+
* (issue #1103) that this module used to decide whether to record the
|
|
232
|
+
* denial in the first place — one source of truth for arity, not two.
|
|
233
|
+
*/
|
|
234
|
+
export function isToOneRelationship(fieldConfig: FieldConfig): boolean {
|
|
146
235
|
return !('many' in fieldConfig && fieldConfig.many === true)
|
|
147
236
|
}
|
|
148
237
|
|
|
238
|
+
/**
|
|
239
|
+
* Which `_count.select` keys, at which nesting level of an `include`, were
|
|
240
|
+
* denied outright by their related list's `query` access — omitted from the
|
|
241
|
+
* `_count.select` sent to Prisma (issue #1087), so the row Prisma returns
|
|
242
|
+
* either lacks the key entirely or lacks a `_count` object at all. Consumed
|
|
243
|
+
* post-query by `filterReadableFields` (`field-visibility.ts`), which injects
|
|
244
|
+
* `0` for each — a count is a session-relative value, and `0` is what "no
|
|
245
|
+
* visible rows" means for it, never an absent key (mirroring the to-one
|
|
246
|
+
* `null` injection this module already does for issue #974, though a denied
|
|
247
|
+
* count needs no existence check: `0` requires no query at all).
|
|
248
|
+
*/
|
|
249
|
+
export type CountAccessDenialTree = {
|
|
250
|
+
/** `_count.select` keys denied at THIS level. */
|
|
251
|
+
keys: Set<string>
|
|
252
|
+
/** Per-relation trees for relations present in the include for other reasons, whose own nested include may contain a further `_count`. */
|
|
253
|
+
nested: Record<string, CountAccessDenialTree>
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
export function emptyCountAccessDenialTree(): CountAccessDenialTree {
|
|
257
|
+
return { keys: new Set(), nested: {} }
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
function isCountAccessDenialTreeEmpty(tree: CountAccessDenialTree): boolean {
|
|
261
|
+
return tree.keys.size === 0 && Object.keys(tree.nested).length === 0
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
/**
|
|
265
|
+
* Normalize a caller's `_count` include value to the `_count.select` map it
|
|
266
|
+
* names. `true` (Prisma's "count every relation" shorthand) expands to every
|
|
267
|
+
* countable relation Prisma itself carries on this model — every DECLARED
|
|
268
|
+
* to-many relationship, plus every synthetic back-relation a list-only `ref`
|
|
269
|
+
* elsewhere in the config synthesizes onto it (issue #1082): Prisma's own
|
|
270
|
+
* `_count: true` has always counted both, and dropping the synthetic ones
|
|
271
|
+
* here would silently stop counting a relation the caller used to get a
|
|
272
|
+
* (previously unscoped) count for. Returns `null` for a shape that requests
|
|
273
|
+
* nothing countable (`false`, or an object with no usable `select`).
|
|
274
|
+
*/
|
|
275
|
+
function normalizeCountSelect(
|
|
276
|
+
requestedValue: unknown,
|
|
277
|
+
fieldConfigs: Record<string, FieldConfig>,
|
|
278
|
+
listKey: string,
|
|
279
|
+
config: OpenSaasConfig,
|
|
280
|
+
): Record<string, unknown> | null {
|
|
281
|
+
if (requestedValue === true) {
|
|
282
|
+
const expanded: Record<string, unknown> = {}
|
|
283
|
+
for (const [fieldName, fieldConfig] of Object.entries(fieldConfigs)) {
|
|
284
|
+
if (isToManyRelationshipField(fieldConfig)) expanded[fieldName] = true
|
|
285
|
+
}
|
|
286
|
+
for (const syntheticName of listSyntheticReverseRelationNames(listKey, config)) {
|
|
287
|
+
expanded[syntheticName] = true
|
|
288
|
+
}
|
|
289
|
+
return expanded
|
|
290
|
+
}
|
|
291
|
+
if (isPlainObject(requestedValue) && isPlainObject(requestedValue.select)) {
|
|
292
|
+
return requestedValue.select
|
|
293
|
+
}
|
|
294
|
+
return null
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
/**
|
|
298
|
+
* Scope a caller-supplied `_count` include value by each named relation's own
|
|
299
|
+
* `query` access — the `_count` counterpart to the rest of this module's
|
|
300
|
+
* relation scoping (issue #1087, closing the one key `buildAccessScopedInclude`
|
|
301
|
+
* used to allowlist through unscoped, #1082's "Out of scope").
|
|
302
|
+
*
|
|
303
|
+
* For each key in the caller's `_count.select` (or, for bare `_count: true`,
|
|
304
|
+
* every declared to-many relation — see `normalizeCountSelect`):
|
|
305
|
+
* - Not a declared to-many relationship and not a synthetic back-relation
|
|
306
|
+
* (#1082 — always genuinely countable) → THROWN as
|
|
307
|
+
* `UndeclaredCountKeyError`, matching `buildAccessScopedInclude`'s own
|
|
308
|
+
* rejection for the ordinary walk. A declared to-many relationship whose
|
|
309
|
+
* `ref` cannot be resolved is skipped instead, matching that same walk's
|
|
310
|
+
* handling of a config-level dangling ref (not a caller error).
|
|
311
|
+
* - The counting list's OWN field-level `read` access on the relationship
|
|
312
|
+
* field being counted denies it, or the related list's `query` access
|
|
313
|
+
* denies it (`=== false`) → omitted from the select sent to Prisma and
|
|
314
|
+
* added to the returned `deniedKeys`, so `filterReadableFields` can inject
|
|
315
|
+
* `0` post-query (a count is session-relative; denial doesn't mean "no such
|
|
316
|
+
* relation"). The field-level check has nothing to evaluate for a synthetic
|
|
317
|
+
* back-relation (no field of its own on this list) and is skipped for it —
|
|
318
|
+
* `resolveCountAccessEntryForList` (issue #1111). Both checks run BEFORE
|
|
319
|
+
* any caller-supplied nested `where` is validated — validating first would
|
|
320
|
+
* let a caller who cannot read a single row of the related list learn its
|
|
321
|
+
* field names and field-level read rules from a thrown `ValidationError`
|
|
322
|
+
* alone, reopening the exact oracle #915/ADR-0031 closed for a top-level
|
|
323
|
+
* predicate.
|
|
324
|
+
* - Otherwise → the caller-supplied nested `where` at that key (if any) is
|
|
325
|
+
* key- and read-access-validated against the RELATED list via the same
|
|
326
|
+
* `validateQueryKeys`/`validateQueryFieldReadAccess` primitives
|
|
327
|
+
* `createFindMany` already runs on a top-level `where` (#912/#915), then
|
|
328
|
+
* run through `buildAccessScopedWhere` — the same fold `createFindMany`
|
|
329
|
+
* applies to a top-level `where` (#916) — so a relation filter nested
|
|
330
|
+
* inside IT (e.g. `_count.select.posts.where.comments.some`) is scoped by
|
|
331
|
+
* THAT further list's own `query` access too, not just the counted
|
|
332
|
+
* relation's. The result is AND-combined with the counted relation's own
|
|
333
|
+
* access filter (if any) — reusing `andWhere`, never replacing the
|
|
334
|
+
* caller's condition, mirroring `buildAccessScopedInclude` itself.
|
|
335
|
+
*/
|
|
336
|
+
async function buildAccessScopedCountSelect(
|
|
337
|
+
requestedValue: unknown,
|
|
338
|
+
fieldConfigs: Record<string, FieldConfig>,
|
|
339
|
+
args: { session: Session | null; context: AccessContext },
|
|
340
|
+
config: OpenSaasConfig,
|
|
341
|
+
listKey: string,
|
|
342
|
+
): Promise<{ select: Record<string, unknown> | undefined; deniedKeys: Set<string> }> {
|
|
343
|
+
const requestedSelect = normalizeCountSelect(requestedValue, fieldConfigs, listKey, config)
|
|
344
|
+
const deniedKeys = new Set<string>()
|
|
345
|
+
if (!requestedSelect) return { select: undefined, deniedKeys }
|
|
346
|
+
|
|
347
|
+
const select: Record<string, unknown> = {}
|
|
348
|
+
|
|
349
|
+
for (const [key, entryValue] of Object.entries(requestedSelect)) {
|
|
350
|
+
// A caller can explicitly exclude a key from the `true`-expanded set the
|
|
351
|
+
// same way Prisma's own `select` excludes a field.
|
|
352
|
+
if (entryValue === false) continue
|
|
353
|
+
|
|
354
|
+
const fieldConfig = fieldConfigs[key]
|
|
355
|
+
const isDeclaredToMany = isToManyRelationshipField(fieldConfig)
|
|
356
|
+
|
|
357
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- ListConfig must accept any TypeInfo
|
|
358
|
+
let relatedConfig: { listName: string; listConfig: ListConfig<any> } | null = null
|
|
359
|
+
if (isDeclaredToMany && fieldConfig && 'ref' in fieldConfig) {
|
|
360
|
+
relatedConfig = getRelatedListConfig(fieldConfig.ref as string, config)
|
|
361
|
+
}
|
|
362
|
+
if (!relatedConfig && !isDeclaredToMany) {
|
|
363
|
+
const synthetic = resolveSyntheticReverseRelation(key, listKey, config)
|
|
364
|
+
if (synthetic) {
|
|
365
|
+
relatedConfig = {
|
|
366
|
+
listName: synthetic.sourceListName,
|
|
367
|
+
listConfig: synthetic.sourceListConfig,
|
|
368
|
+
}
|
|
369
|
+
}
|
|
370
|
+
}
|
|
371
|
+
if (!relatedConfig) {
|
|
372
|
+
// A declared to-many field whose `ref` didn't resolve is a config
|
|
373
|
+
// issue, not a caller error — skip it exactly like the ordinary
|
|
374
|
+
// include walk does for the same case.
|
|
375
|
+
if (isDeclaredToMany) continue
|
|
376
|
+
throw new UndeclaredCountKeyError(listKey, key)
|
|
377
|
+
}
|
|
378
|
+
|
|
379
|
+
// `fieldConfig` is undefined for a synthetic back-relation (#1082) — it has
|
|
380
|
+
// no field of its own on THIS list, so there is no field-level `read`
|
|
381
|
+
// access to fold in; `resolveCountAccessEntryForList` treats a missing
|
|
382
|
+
// `fieldAccess` as exempt, matching the ordinary include path's own lack
|
|
383
|
+
// of a field-level gate for a synthetic key (issue #1111).
|
|
384
|
+
const accessEntry = await resolveCountAccessEntryForList(
|
|
385
|
+
relatedConfig.listConfig,
|
|
386
|
+
args,
|
|
387
|
+
fieldConfig?.access,
|
|
388
|
+
)
|
|
389
|
+
|
|
390
|
+
// Denial is checked BEFORE the caller's nested `where` is validated —
|
|
391
|
+
// mirroring `buildAccessScopedWhere`'s own ordering below. A fully denied
|
|
392
|
+
// relation counts `0` no matter what `where` the caller supplied, so
|
|
393
|
+
// validating it first would let a caller who cannot read a single row of
|
|
394
|
+
// the related list learn its field names and field-level read rules from
|
|
395
|
+
// a `ValidationError`'s message alone — the exact oracle #915/ADR-0031
|
|
396
|
+
// closed for a top-level predicate, reopened here if this ran first.
|
|
397
|
+
if (accessEntry.kind === 'denied') {
|
|
398
|
+
deniedKeys.add(key)
|
|
399
|
+
continue
|
|
400
|
+
}
|
|
401
|
+
|
|
402
|
+
const requestedWhere =
|
|
403
|
+
isPlainObject(entryValue) && isPlainObject(entryValue.where)
|
|
404
|
+
? (entryValue.where as Record<string, unknown>)
|
|
405
|
+
: undefined
|
|
406
|
+
|
|
407
|
+
let scopedRequestedWhere: Record<string, unknown> | undefined
|
|
408
|
+
if (requestedWhere) {
|
|
409
|
+
// Mirrors `buildAccessScopedInclude`'s own `resolveSyntheticRelation`
|
|
410
|
+
// closure (#1092/#916) — a synthetic back-relation (#1082) named inside
|
|
411
|
+
// this count entry's own `where` resolves against its source list
|
|
412
|
+
// rather than being rejected as undeclared, at every hop.
|
|
413
|
+
const resolveSyntheticRelation = (
|
|
414
|
+
syntheticKey: string,
|
|
415
|
+
fromListName: string,
|
|
416
|
+
): SyntheticRelationTarget | null => {
|
|
417
|
+
const synthetic = resolveSyntheticReverseRelation(syntheticKey, fromListName, config)
|
|
418
|
+
return synthetic
|
|
419
|
+
? { listConfig: synthetic.sourceListConfig, listName: synthetic.sourceListName }
|
|
420
|
+
: null
|
|
421
|
+
}
|
|
422
|
+
validateQueryKeys({
|
|
423
|
+
where: requestedWhere,
|
|
424
|
+
listConfig: relatedConfig.listConfig,
|
|
425
|
+
listName: relatedConfig.listName,
|
|
426
|
+
config,
|
|
427
|
+
isSudo: false,
|
|
428
|
+
resolveSyntheticRelation,
|
|
429
|
+
})
|
|
430
|
+
await validateQueryFieldReadAccess({
|
|
431
|
+
where: requestedWhere,
|
|
432
|
+
listConfig: relatedConfig.listConfig,
|
|
433
|
+
listName: relatedConfig.listName,
|
|
434
|
+
session: args.session,
|
|
435
|
+
context: args.context,
|
|
436
|
+
isSudo: false,
|
|
437
|
+
})
|
|
438
|
+
// A relation filter (`some`/`every`/`none`/`is`/`isNot`) nested inside
|
|
439
|
+
// the caller's own `where` names a THIRD list one hop further out —
|
|
440
|
+
// e.g. `_count.select.posts.where.comments.some`. Validation above only
|
|
441
|
+
// checked keys against the counted relation's OWN fields; without this,
|
|
442
|
+
// that nested relation would reach Prisma unscoped by ITS list's
|
|
443
|
+
// `query` access, letting the resulting count reveal whether
|
|
444
|
+
// inaccessible rows over there exist. `buildAccessScopedWhere` is the
|
|
445
|
+
// same fold `createFindMany` runs on an ordinary top-level `where`
|
|
446
|
+
// (#916) — reused here rather than re-derived.
|
|
447
|
+
scopedRequestedWhere = (await buildAccessScopedWhere(
|
|
448
|
+
requestedWhere,
|
|
449
|
+
relatedConfig.listConfig,
|
|
450
|
+
relatedConfig.listName,
|
|
451
|
+
config,
|
|
452
|
+
args,
|
|
453
|
+
resolveSyntheticRelation,
|
|
454
|
+
)) as Record<string, unknown> | undefined
|
|
455
|
+
}
|
|
456
|
+
|
|
457
|
+
const scopedWhere = andWhere(
|
|
458
|
+
accessEntry.kind === 'scoped' ? accessEntry.where : undefined,
|
|
459
|
+
scopedRequestedWhere,
|
|
460
|
+
)
|
|
461
|
+
select[key] = scopedWhere ? { where: scopedWhere } : true
|
|
462
|
+
}
|
|
463
|
+
|
|
464
|
+
return { select: Object.keys(select).length > 0 ? select : undefined, deniedKeys }
|
|
465
|
+
}
|
|
466
|
+
|
|
149
467
|
/**
|
|
150
468
|
* Build the access-scoped `include` for exactly the relations a read
|
|
151
469
|
* requested, recursing only into branches `requestedInclude` itself names.
|
|
152
470
|
*
|
|
153
471
|
* For each key in `requestedInclude`:
|
|
154
|
-
* -
|
|
155
|
-
* passed through unchanged (
|
|
156
|
-
*
|
|
472
|
+
* - A declared field that isn't a relationship (scalar, virtual, …) → access
|
|
473
|
+
* control does not govern it; passed through unchanged (a virtual key is
|
|
474
|
+
* stripped later by `stripVirtualFieldsFromInclude`, #628).
|
|
475
|
+
* - Not declared at all → resolved via `resolveSyntheticReverseRelation`
|
|
476
|
+
* (the synthetic-back-relation case above); `_count` is scoped by
|
|
477
|
+
* `buildAccessScopedCountSelect` (issue #1087 — each named relation's own
|
|
478
|
+
* `query` access, exactly like any other relation this walk scopes; a
|
|
479
|
+
* denied one is recorded for `filterReadableFields` to inject `0` for,
|
|
480
|
+
* post-query, since Prisma cannot be asked for a guaranteed `0`); anything
|
|
481
|
+
* else throws `UndeclaredIncludeKeyError` rather than reaching the
|
|
482
|
+
* database unscoped.
|
|
157
483
|
* - A declared relationship whose related list's `query` access denies it
|
|
158
484
|
* (`=== false`) → dropped entirely, no matter what the request asked for
|
|
159
485
|
* nested beneath it (#566): the caller chooses *which* relations, access
|
|
160
|
-
* control chooses *whether* and *with what filter*.
|
|
161
|
-
*
|
|
162
|
-
*
|
|
163
|
-
*
|
|
486
|
+
* control chooses *whether* and *with what filter*. This denial is also
|
|
487
|
+
* recorded in `toOneAccessFilters` (`kind: 'denied'`), for either arity, so
|
|
488
|
+
* `filterReadableFields` can still surface an explicit `null` (to-one,
|
|
489
|
+
* issue #974) or `[]` (to-many, issue #1103) for it rather than an absent
|
|
490
|
+
* key.
|
|
164
491
|
* - Otherwise, for a to-**many** relation → the access `where` is
|
|
165
492
|
* AND-combined with any caller-supplied nested `where` (never replaced —
|
|
166
493
|
* the other half of #566), and a caller-supplied `take` rides through
|
|
@@ -192,7 +519,11 @@ export async function buildAccessScopedInclude(
|
|
|
192
519
|
config: OpenSaasConfig,
|
|
193
520
|
listKey: string,
|
|
194
521
|
depth: number = 0,
|
|
195
|
-
): Promise<{
|
|
522
|
+
): Promise<{
|
|
523
|
+
include: Record<string, unknown>
|
|
524
|
+
toOneAccessFilters: ToOneAccessFilterTree
|
|
525
|
+
countDenials: CountAccessDenialTree
|
|
526
|
+
}> {
|
|
196
527
|
const requestedKeys = Object.keys(requestedInclude)
|
|
197
528
|
if (depth >= READ_INCLUDE_MAX_DEPTH && requestedKeys.length > 0) {
|
|
198
529
|
throw new AccessScopeDepthExceededError(listKey, requestedKeys[0], depth)
|
|
@@ -200,22 +531,60 @@ export async function buildAccessScopedInclude(
|
|
|
200
531
|
|
|
201
532
|
const result: Record<string, unknown> = {}
|
|
202
533
|
const toOneAccessFilters = emptyToOneAccessFilterTree()
|
|
534
|
+
const countDenials = emptyCountAccessDenialTree()
|
|
203
535
|
|
|
204
536
|
for (const [relationName, requestedValue] of Object.entries(requestedInclude)) {
|
|
537
|
+
// A caller can explicitly opt a relation OUT of a Prisma include with
|
|
538
|
+
// `false` (its `Include` type allows a bare `boolean`), or end up with an
|
|
539
|
+
// `undefined` value from a conditionally-built object
|
|
540
|
+
// (`{ posts: cond ? true : undefined }`) — Prisma treats both the same as
|
|
541
|
+
// the key being absent. Treat them identically here too, before this key
|
|
542
|
+
// ever reaches access evaluation: an explicitly-disabled relation was
|
|
543
|
+
// never actually requested, so it must not be evaluated, dropped as
|
|
544
|
+
// "denied", or recorded as needing a post-query fixup (issue #1103 code
|
|
545
|
+
// review) — any of those would surface a value (`[]`/`null`) for a key
|
|
546
|
+
// the caller deliberately excluded.
|
|
547
|
+
if (requestedValue === false || requestedValue === undefined) continue
|
|
548
|
+
|
|
205
549
|
const fieldConfig = fieldConfigs[relationName]
|
|
206
550
|
const isDeclaredRelationship =
|
|
207
551
|
fieldConfig?.type === 'relationship' && 'ref' in fieldConfig && !!fieldConfig.ref
|
|
208
552
|
|
|
209
|
-
|
|
553
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- ListConfig must accept any TypeInfo
|
|
554
|
+
let relatedConfig: { listName: string; listConfig: ListConfig<any> } | null
|
|
555
|
+
let isToOne: boolean
|
|
556
|
+
|
|
557
|
+
if (isDeclaredRelationship) {
|
|
558
|
+
relatedConfig = getRelatedListConfig(fieldConfig.ref as string, config)
|
|
559
|
+
if (!relatedConfig) continue
|
|
560
|
+
isToOne = isToOneRelationship(fieldConfig)
|
|
561
|
+
} else if (fieldConfig) {
|
|
562
|
+
// A declared field that is not a relationship — access control does
|
|
563
|
+
// not govern it here; passed through unchanged.
|
|
210
564
|
result[relationName] = requestedValue
|
|
211
565
|
continue
|
|
566
|
+
} else if (relationName === '_count') {
|
|
567
|
+
const { select, deniedKeys } = await buildAccessScopedCountSelect(
|
|
568
|
+
requestedValue,
|
|
569
|
+
fieldConfigs,
|
|
570
|
+
args,
|
|
571
|
+
config,
|
|
572
|
+
listKey,
|
|
573
|
+
)
|
|
574
|
+
if (select) result[relationName] = { select }
|
|
575
|
+
if (deniedKeys.size > 0) countDenials.keys = deniedKeys
|
|
576
|
+
continue
|
|
577
|
+
} else {
|
|
578
|
+
const synthetic = resolveSyntheticReverseRelation(relationName, listKey, config)
|
|
579
|
+
if (!synthetic) {
|
|
580
|
+
throw new UndeclaredIncludeKeyError(listKey, relationName)
|
|
581
|
+
}
|
|
582
|
+
relatedConfig = { listName: synthetic.sourceListName, listConfig: synthetic.sourceListConfig }
|
|
583
|
+
// Always to-many — a list-only ref has one construction site and no
|
|
584
|
+
// arity branch, and one-to-one is structurally impossible for it.
|
|
585
|
+
isToOne = false
|
|
212
586
|
}
|
|
213
587
|
|
|
214
|
-
const relatedConfig = getRelatedListConfig(fieldConfig.ref as string, config)
|
|
215
|
-
if (!relatedConfig) continue
|
|
216
|
-
|
|
217
|
-
const isToOne = isToOneRelationship(fieldConfig)
|
|
218
|
-
|
|
219
588
|
const queryAccess = relatedConfig.listConfig.access?.operation?.query
|
|
220
589
|
const accessResult = await checkAccess(queryAccess, {
|
|
221
590
|
session: args.session,
|
|
@@ -223,17 +592,74 @@ export async function buildAccessScopedInclude(
|
|
|
223
592
|
})
|
|
224
593
|
|
|
225
594
|
if (accessResult === false) {
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
595
|
+
// Recorded for either arity (issue #1103) — a to-many relation is
|
|
596
|
+
// dropped from `include` here exactly like a to-one one, and needs the
|
|
597
|
+
// same post-query fixup so its key comes back `[]`, not silently
|
|
598
|
+
// absent from the row.
|
|
599
|
+
toOneAccessFilters.filters[relationName] = { kind: 'denied' }
|
|
229
600
|
continue
|
|
230
601
|
}
|
|
231
602
|
|
|
232
603
|
const accessWhere = typeof accessResult === 'object' ? accessResult : undefined
|
|
233
604
|
const requestedEntry = asEntryObject(requestedValue)
|
|
234
605
|
|
|
606
|
+
// #1092 — a nested `where`/`orderBy` gets the same #912/#915 checks the
|
|
607
|
+
// top-level `where`/`orderBy` already gets, resolved against the RELATED
|
|
608
|
+
// list (`relatedConfig`) rather than the current one, and run only once
|
|
609
|
+
// the relation is known to be accessible at all (same ordering reason as
|
|
610
|
+
// the top-level checks: don't leak a field's name/read-gating status to a
|
|
611
|
+
// caller who has zero access to the relation to begin with).
|
|
612
|
+
const resolveSyntheticRelation = (
|
|
613
|
+
key: string,
|
|
614
|
+
fromListName: string,
|
|
615
|
+
): SyntheticRelationTarget | null => {
|
|
616
|
+
const synthetic = resolveSyntheticReverseRelation(key, fromListName, config)
|
|
617
|
+
return synthetic
|
|
618
|
+
? { listConfig: synthetic.sourceListConfig, listName: synthetic.sourceListName }
|
|
619
|
+
: null
|
|
620
|
+
}
|
|
621
|
+
validateQueryKeys({
|
|
622
|
+
where: requestedEntry?.where,
|
|
623
|
+
orderBy: requestedEntry?.orderBy,
|
|
624
|
+
listConfig: relatedConfig.listConfig,
|
|
625
|
+
listName: relatedConfig.listName,
|
|
626
|
+
config,
|
|
627
|
+
isSudo: false,
|
|
628
|
+
resolveSyntheticRelation,
|
|
629
|
+
})
|
|
630
|
+
await validateQueryFieldReadAccess({
|
|
631
|
+
where: requestedEntry?.where,
|
|
632
|
+
orderBy: requestedEntry?.orderBy,
|
|
633
|
+
listConfig: relatedConfig.listConfig,
|
|
634
|
+
listName: relatedConfig.listName,
|
|
635
|
+
session: args.session,
|
|
636
|
+
context: args.context,
|
|
637
|
+
isSudo: false,
|
|
638
|
+
})
|
|
639
|
+
|
|
640
|
+
// The two checks above only reach the entry's own top-level keys — a
|
|
641
|
+
// relation quantifier (`some`/`every`/`none`/`is`/`isNot`) nested inside
|
|
642
|
+
// this `where` names a DEEPER related list, which needs the same
|
|
643
|
+
// treatment the top-level `where` already gets from `buildAccessScopedWhere`
|
|
644
|
+
// (#916): scope it by that deeper list's own `query` access and check ITS
|
|
645
|
+
// fields' read access, recursing through every further hop. Only for
|
|
646
|
+
// to-many — a to-one entry never carries `requestedEntry.where` through to
|
|
647
|
+
// Prisma at all (see below), so there is nothing here to scope.
|
|
648
|
+
const scopedRequestedWhere =
|
|
649
|
+
!isToOne && requestedEntry?.where !== undefined
|
|
650
|
+
? ((await buildAccessScopedWhere(
|
|
651
|
+
requestedEntry.where,
|
|
652
|
+
relatedConfig.listConfig,
|
|
653
|
+
relatedConfig.listName,
|
|
654
|
+
config,
|
|
655
|
+
args,
|
|
656
|
+
resolveSyntheticRelation,
|
|
657
|
+
)) as PrismaFilter)
|
|
658
|
+
: requestedEntry?.where
|
|
659
|
+
|
|
235
660
|
let nestedInclude: Record<string, unknown> | undefined
|
|
236
661
|
let nestedToOneFilters: ToOneAccessFilterTree | undefined
|
|
662
|
+
let nestedCountDenials: CountAccessDenialTree | undefined
|
|
237
663
|
if (requestedEntry?.include) {
|
|
238
664
|
const nested = await buildAccessScopedInclude(
|
|
239
665
|
requestedEntry.include,
|
|
@@ -245,6 +671,7 @@ export async function buildAccessScopedInclude(
|
|
|
245
671
|
)
|
|
246
672
|
nestedInclude = nested.include
|
|
247
673
|
nestedToOneFilters = nested.toOneAccessFilters
|
|
674
|
+
nestedCountDenials = nested.countDenials
|
|
248
675
|
}
|
|
249
676
|
|
|
250
677
|
const entry: IncludeEntryObject = {}
|
|
@@ -257,7 +684,7 @@ export async function buildAccessScopedInclude(
|
|
|
257
684
|
}
|
|
258
685
|
}
|
|
259
686
|
} else {
|
|
260
|
-
const mergedWhere = andWhere(accessWhere,
|
|
687
|
+
const mergedWhere = andWhere(accessWhere, scopedRequestedWhere)
|
|
261
688
|
if (mergedWhere) entry.where = mergedWhere
|
|
262
689
|
if (requestedEntry?.take !== undefined) entry.take = requestedEntry.take
|
|
263
690
|
if (requestedEntry?.orderBy !== undefined) entry.orderBy = requestedEntry.orderBy
|
|
@@ -267,14 +694,23 @@ export async function buildAccessScopedInclude(
|
|
|
267
694
|
if (nestedToOneFilters && !isToOneAccessFilterTreeEmpty(nestedToOneFilters)) {
|
|
268
695
|
toOneAccessFilters.nested[relationName] = nestedToOneFilters
|
|
269
696
|
}
|
|
697
|
+
if (nestedCountDenials && !isCountAccessDenialTreeEmpty(nestedCountDenials)) {
|
|
698
|
+
countDenials.nested[relationName] = nestedCountDenials
|
|
699
|
+
}
|
|
270
700
|
|
|
271
701
|
result[relationName] = Object.keys(entry).length > 0 ? entry : true
|
|
272
702
|
}
|
|
273
703
|
|
|
274
|
-
return { include: result, toOneAccessFilters }
|
|
704
|
+
return { include: result, toOneAccessFilters, countDenials }
|
|
275
705
|
}
|
|
276
706
|
|
|
277
|
-
/**
|
|
707
|
+
/**
|
|
708
|
+
* One relation's resolved post-query visibility — see
|
|
709
|
+
* `resolveToOneAccessVisibility`. `kind: 'visible'` (an existence check
|
|
710
|
+
* against a set of ids) only ever arises for a to-one relation, since only a
|
|
711
|
+
* to-one `kind: 'scoped'` filter entry produces one; `kind: 'denied'` passes
|
|
712
|
+
* straight through unresolved for either arity.
|
|
713
|
+
*/
|
|
278
714
|
export type ToOneVisibility = { kind: 'denied' } | { kind: 'visible'; ids: ReadonlySet<string> }
|
|
279
715
|
|
|
280
716
|
/** The resolved counterpart to {@link ToOneAccessFilterTree}, produced by `resolveToOneAccessVisibility`. */
|
|
@@ -406,6 +842,20 @@ export async function resolveToOneAccessVisibility(
|
|
|
406
842
|
* caller can observe that an inaccessible related row exists (an `every`
|
|
407
843
|
* that "should" pass instead fails), but never that row's field values,
|
|
408
844
|
* which is the property this ticket exists to close.
|
|
845
|
+
*
|
|
846
|
+
* A quantifier's value of literal `null` (`is: null`/`isNot: null`, a to-one
|
|
847
|
+
* relation's existence check) is passed through untouched rather than folded:
|
|
848
|
+
* it names no fields to read-check or scope, and AND-folding an access filter
|
|
849
|
+
* into it would silently invert the caller's own predicate (see the inline
|
|
850
|
+
* comment at that branch).
|
|
851
|
+
*
|
|
852
|
+
* `resolveSyntheticRelation` (#1092/#1108) extends this to a key that
|
|
853
|
+
* resolves to a synthetic back-relation (#1082) rather than a declared
|
|
854
|
+
* field, recursing against its SOURCE list. Only `buildAccessScopedInclude`
|
|
855
|
+
* passes it, for the include-nested `where` position; the top-level `where`
|
|
856
|
+
* this function was originally built for (`context/index.ts`) omits it, so
|
|
857
|
+
* a synthetic key there is unaffected — matching #1092's own scope, which
|
|
858
|
+
* deliberately left the top-level checks unchanged.
|
|
409
859
|
*/
|
|
410
860
|
export async function buildAccessScopedWhere(
|
|
411
861
|
where: unknown,
|
|
@@ -417,12 +867,19 @@ export async function buildAccessScopedWhere(
|
|
|
417
867
|
session: Session | null
|
|
418
868
|
context: AccessContext
|
|
419
869
|
},
|
|
870
|
+
// #1092/#1108 — the include-nested position's own addition, exactly
|
|
871
|
+
// mirroring `validateQueryKeys`'s `resolveSyntheticRelation` (see that
|
|
872
|
+
// module's doc comment): every top-level `where` caller omits this, so
|
|
873
|
+
// top-level behavior is unchanged.
|
|
874
|
+
resolveSyntheticRelation?: ResolveSyntheticRelation,
|
|
420
875
|
): Promise<unknown> {
|
|
421
876
|
if (where === null || typeof where !== 'object') return where
|
|
422
877
|
|
|
423
878
|
if (Array.isArray(where)) {
|
|
424
879
|
return Promise.all(
|
|
425
|
-
where.map((entry) =>
|
|
880
|
+
where.map((entry) =>
|
|
881
|
+
buildAccessScopedWhere(entry, listConfig, listName, config, args, resolveSyntheticRelation),
|
|
882
|
+
),
|
|
426
883
|
)
|
|
427
884
|
}
|
|
428
885
|
|
|
@@ -430,24 +887,33 @@ export async function buildAccessScopedWhere(
|
|
|
430
887
|
|
|
431
888
|
for (const [key, value] of Object.entries(where as Record<string, unknown>)) {
|
|
432
889
|
if (LOGICAL_OPERATORS.has(key)) {
|
|
433
|
-
result[key] = await buildAccessScopedWhere(
|
|
890
|
+
result[key] = await buildAccessScopedWhere(
|
|
891
|
+
value,
|
|
892
|
+
listConfig,
|
|
893
|
+
listName,
|
|
894
|
+
config,
|
|
895
|
+
args,
|
|
896
|
+
resolveSyntheticRelation,
|
|
897
|
+
)
|
|
434
898
|
continue
|
|
435
899
|
}
|
|
436
900
|
|
|
437
901
|
const resolved = resolveQueryField(key, listConfig.fields)
|
|
438
|
-
|
|
439
|
-
|
|
440
|
-
|
|
441
|
-
|
|
442
|
-
|
|
443
|
-
|
|
444
|
-
|
|
445
|
-
|
|
446
|
-
|
|
447
|
-
|
|
902
|
+
// A synthetic back-relation (#1082) carries no `ref` of its own to
|
|
903
|
+
// follow — its "related" list for recursion is the SOURCE list it
|
|
904
|
+
// stands for, given directly by the resolver, not `getRelatedListConfig`.
|
|
905
|
+
const related = resolved?.isRelationship
|
|
906
|
+
? getRelatedListConfig(resolved.fieldConfig.ref, config)
|
|
907
|
+
: !resolved
|
|
908
|
+
? (() => {
|
|
909
|
+
const synthetic = resolveSyntheticRelation?.(key, listName)
|
|
910
|
+
return synthetic
|
|
911
|
+
? { listConfig: synthetic.listConfig, listName: synthetic.listName }
|
|
912
|
+
: null
|
|
913
|
+
})()
|
|
914
|
+
: null
|
|
448
915
|
|
|
449
|
-
|
|
450
|
-
if (!related) {
|
|
916
|
+
if (!related || value === null || typeof value !== 'object' || Array.isArray(value)) {
|
|
451
917
|
result[key] = value
|
|
452
918
|
continue
|
|
453
919
|
}
|
|
@@ -473,6 +939,16 @@ export async function buildAccessScopedWhere(
|
|
|
473
939
|
nestedEntry[quantifier] = quantifierValue
|
|
474
940
|
continue
|
|
475
941
|
}
|
|
942
|
+
if (quantifierValue === null) {
|
|
943
|
+
// `is: null` / `isNot: null` tests EXISTENCE of a to-one relation,
|
|
944
|
+
// not its fields — there is nothing to read-check or scope, and
|
|
945
|
+
// AND-folding the access filter in here would silently invert the
|
|
946
|
+
// caller's predicate: `is: null` ("has no related row") would
|
|
947
|
+
// become `is: <accessWhere>` ("has a related row matching the
|
|
948
|
+
// filter"), the opposite of what was asked. Passed through as-is.
|
|
949
|
+
nestedEntry[quantifier] = null
|
|
950
|
+
continue
|
|
951
|
+
}
|
|
476
952
|
await walkWhereReadAccess(quantifierValue, related.listConfig, related.listName, args)
|
|
477
953
|
const scopedNested = await buildAccessScopedWhere(
|
|
478
954
|
quantifierValue,
|
|
@@ -480,6 +956,7 @@ export async function buildAccessScopedWhere(
|
|
|
480
956
|
related.listName,
|
|
481
957
|
config,
|
|
482
958
|
args,
|
|
959
|
+
resolveSyntheticRelation,
|
|
483
960
|
)
|
|
484
961
|
nestedEntry[quantifier] = accessWhere
|
|
485
962
|
? andWhere(accessWhere, scopedNested as PrismaFilter | undefined)
|
|
@@ -499,6 +976,7 @@ export async function buildAccessScopedWhere(
|
|
|
499
976
|
related.listName,
|
|
500
977
|
config,
|
|
501
978
|
args,
|
|
979
|
+
resolveSyntheticRelation,
|
|
502
980
|
)
|
|
503
981
|
result[key] = accessWhere
|
|
504
982
|
? { is: andWhere(accessWhere, scopedNested as PrismaFilter | undefined) }
|