@opensaas/stack-core 0.39.2 → 0.40.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 +150 -0
- package/CLAUDE.md +51 -0
- package/dist/access/access-filter.d.ts +83 -10
- package/dist/access/access-filter.d.ts.map +1 -1
- package/dist/access/access-filter.js +139 -18
- package/dist/access/access-filter.js.map +1 -1
- package/dist/access/access-filter.test.js +224 -12
- package/dist/access/access-filter.test.js.map +1 -1
- package/dist/access/engine.d.ts +43 -1
- package/dist/access/engine.d.ts.map +1 -1
- package/dist/access/engine.js +58 -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 +28 -0
- package/dist/access/errors.d.ts.map +1 -1
- package/dist/access/errors.js +38 -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 +45 -3
- package/dist/access/field-visibility.js.map +1 -1
- package/dist/access/index.d.ts +5 -2
- package/dist/access/index.d.ts.map +1 -1
- package/dist/access/index.js +4 -2
- package/dist/access/index.js.map +1 -1
- package/dist/config/types.d.ts +76 -10
- package/dist/config/types.d.ts.map +1 -1
- package/dist/context/index.d.ts +19 -1
- package/dist/context/index.d.ts.map +1 -1
- package/dist/context/index.js +61 -17
- package/dist/context/index.js.map +1 -1
- package/dist/context/nested-operations.d.ts.map +1 -1
- package/dist/context/nested-operations.js +54 -13
- 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 +8 -5
- 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/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 +1 -1
- package/src/access/access-filter.test.ts +278 -12
- package/src/access/access-filter.ts +207 -19
- package/src/access/engine.test.ts +67 -0
- package/src/access/engine.ts +81 -1
- package/src/access/errors.ts +42 -2
- package/src/access/field-access.test.ts +78 -0
- package/src/access/field-access.ts +34 -2
- package/src/access/field-visibility.ts +75 -10
- package/src/access/index.ts +14 -0
- package/src/config/types.ts +76 -10
- package/src/context/index.ts +95 -15
- package/src/context/nested-operations.ts +61 -16
- package/src/context/write-pipeline.ts +8 -4
- package/src/fields/index.ts +55 -5
- package/src/fields/relationship-foreign-key.test.ts +29 -0
- 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 +259 -12
- package/tests/field-types.test.ts +113 -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 +65 -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
|
@@ -9,6 +9,7 @@ import {
|
|
|
9
9
|
resolveQueryField,
|
|
10
10
|
walkWhereReadAccess,
|
|
11
11
|
} from './query-validation.js'
|
|
12
|
+
import { getDbKey } from '../lib/case-utils.js'
|
|
12
13
|
|
|
13
14
|
/**
|
|
14
15
|
* Access Filter — phase 1 of the two-phase read (pre-query).
|
|
@@ -34,10 +35,36 @@ import {
|
|
|
34
35
|
* named a nested `include` there too. A relation nobody named never has its
|
|
35
36
|
* list's `query` access evaluated at all — there is no separate "build the
|
|
36
37
|
* whole tree, then reconcile against what was asked for" pass to walk it.
|
|
38
|
+
*
|
|
39
|
+
* **To-one relations are scoped after the query, not inside it (issue #974).**
|
|
40
|
+
* Prisma accepts a nested `where` on an `include` entry only for a to-**many**
|
|
41
|
+
* relation; the same shape on a to-**one** relation raises
|
|
42
|
+
* `PrismaClientValidationError`. So for a to-one relation whose related
|
|
43
|
+
* list's `query` access resolves to a filter, `buildAccessScopedInclude`
|
|
44
|
+
* does NOT attach that filter as `where` — it fetches the row unscoped and
|
|
45
|
+
* records the filter in the returned `toOneAccessFilters` tree instead.
|
|
46
|
+
* `resolveToOneAccessVisibility` (below) turns that tree into the set of
|
|
47
|
+
* related ids the session may actually see, via ONE batched `id IN (...)`
|
|
48
|
+
* existence check per (relation, nesting level) across every row in the
|
|
49
|
+
* read — never a per-row query, and never a hand-rolled evaluation of the
|
|
50
|
+
* access filter (it is handed to Prisma exactly as `checkAccess` produced
|
|
51
|
+
* it). `field-visibility.ts`'s `filterReadableFields` is where the result
|
|
52
|
+
* actually becomes `null` for a row the check excludes — see its module doc.
|
|
53
|
+
* A to-one relation whose related list's `query` access is `true` (no
|
|
54
|
+
* filter) or `false` (denied outright) needs no existence check at all: the
|
|
55
|
+
* former is left as `true` in `include`, unchanged from before; the latter
|
|
56
|
+
* is recorded as `{ kind: 'denied' }`, and `field-visibility.ts` forces the
|
|
57
|
+
* key to `null` without ever asking Prisma for it.
|
|
37
58
|
*/
|
|
38
59
|
|
|
39
60
|
/** The structured (object) form of a relation include entry — caller/fold-supplied or produced by this module. */
|
|
40
|
-
type IncludeEntryObject = {
|
|
61
|
+
type IncludeEntryObject = {
|
|
62
|
+
where?: PrismaFilter
|
|
63
|
+
include?: Record<string, unknown>
|
|
64
|
+
take?: number
|
|
65
|
+
orderBy?: PrismaFilter | PrismaFilter[]
|
|
66
|
+
skip?: number
|
|
67
|
+
}
|
|
41
68
|
|
|
42
69
|
/** A plain object — excludes `null` and arrays, which `typeof x === 'object'` alone would admit. */
|
|
43
70
|
function isPlainObject(value: unknown): value is Record<string, unknown> {
|
|
@@ -51,16 +78,22 @@ function isPlainObject(value: unknown): value is Record<string, unknown> {
|
|
|
51
78
|
* own type before trusting it, rather than casting the whole value wholesale.
|
|
52
79
|
*
|
|
53
80
|
* A numeric `take` on a to-many relation include (a caller-supplied row bound,
|
|
54
|
-
* issue #752)
|
|
55
|
-
*
|
|
81
|
+
* issue #752), and a caller-supplied `orderBy`/`skip` (#851), are carried
|
|
82
|
+
* through the same way: none of the three can ever widen the result past the
|
|
83
|
+
* access `where` — they only narrow or reorder rows the access filter already
|
|
84
|
+
* admits — so preserving them is access-neutral.
|
|
56
85
|
*/
|
|
57
86
|
function asEntryObject(value: unknown): IncludeEntryObject | null {
|
|
58
87
|
if (!isPlainObject(value)) return null
|
|
59
|
-
const { where, include, take } = value
|
|
88
|
+
const { where, include, take, orderBy, skip } = value
|
|
60
89
|
const entry: IncludeEntryObject = {}
|
|
61
90
|
if (isPlainObject(where)) entry.where = where
|
|
62
91
|
if (isPlainObject(include)) entry.include = include
|
|
63
92
|
if (typeof take === 'number') entry.take = take
|
|
93
|
+
if (isPlainObject(orderBy) || Array.isArray(orderBy)) {
|
|
94
|
+
entry.orderBy = orderBy as PrismaFilter | PrismaFilter[]
|
|
95
|
+
}
|
|
96
|
+
if (typeof skip === 'number') entry.skip = skip
|
|
64
97
|
return entry
|
|
65
98
|
}
|
|
66
99
|
|
|
@@ -81,6 +114,38 @@ function andWhere(
|
|
|
81
114
|
return accessWhere ?? callerWhere
|
|
82
115
|
}
|
|
83
116
|
|
|
117
|
+
/** One to-one relation's recorded access filter, or an outright denial — see the module doc's "To-one relations" section. */
|
|
118
|
+
export type ToOneAccessFilterEntry =
|
|
119
|
+
{ kind: 'scoped'; relatedListName: string; accessWhere: PrismaFilter } | { kind: 'denied' }
|
|
120
|
+
|
|
121
|
+
/**
|
|
122
|
+
* Which to-one relations, at which nesting level of an `include`, need a
|
|
123
|
+
* post-query existence check rather than a Prisma-side `where` — because
|
|
124
|
+
* their related list's `query` access resolved to a filter (`kind: 'scoped'`)
|
|
125
|
+
* or a denial (`kind: 'denied'`) and Prisma cannot express either as a nested
|
|
126
|
+
* `where` on a to-one include. `resolveToOneAccessVisibility` consumes this
|
|
127
|
+
* tree; `filterReadableFields` (`field-visibility.ts`) applies its result.
|
|
128
|
+
*/
|
|
129
|
+
export type ToOneAccessFilterTree = {
|
|
130
|
+
/** To-one relation keys at THIS level needing a post-query check. */
|
|
131
|
+
filters: Record<string, ToOneAccessFilterEntry>
|
|
132
|
+
/** Per-key trees for relations present in the include for other reasons, whose own nested include may contain further to-one filters. */
|
|
133
|
+
nested: Record<string, ToOneAccessFilterTree>
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
export function emptyToOneAccessFilterTree(): ToOneAccessFilterTree {
|
|
137
|
+
return { filters: {}, nested: {} }
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
function isToOneAccessFilterTreeEmpty(tree: ToOneAccessFilterTree): boolean {
|
|
141
|
+
return Object.keys(tree.filters).length === 0 && Object.keys(tree.nested).length === 0
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
/** Whether a relationship field is to-one (at most one related row) rather than to-many. */
|
|
145
|
+
function isToOneRelationship(fieldConfig: FieldConfig): boolean {
|
|
146
|
+
return !('many' in fieldConfig && fieldConfig.many === true)
|
|
147
|
+
}
|
|
148
|
+
|
|
84
149
|
/**
|
|
85
150
|
* Build the access-scoped `include` for exactly the relations a read
|
|
86
151
|
* requested, recursing only into branches `requestedInclude` itself names.
|
|
@@ -92,14 +157,21 @@ function andWhere(
|
|
|
92
157
|
* - A declared relationship whose related list's `query` access denies it
|
|
93
158
|
* (`=== false`) → dropped entirely, no matter what the request asked for
|
|
94
159
|
* nested beneath it (#566): the caller chooses *which* relations, access
|
|
95
|
-
* control chooses *whether* and *with what filter*.
|
|
96
|
-
*
|
|
97
|
-
*
|
|
98
|
-
*
|
|
99
|
-
*
|
|
100
|
-
*
|
|
101
|
-
*
|
|
102
|
-
*
|
|
160
|
+
* control chooses *whether* and *with what filter*. For a to-one relation
|
|
161
|
+
* this denial is also recorded in `toOneAccessFilters` (`kind: 'denied'`),
|
|
162
|
+
* so `filterReadableFields` can still surface an explicit `null` for it
|
|
163
|
+
* (issue #974) rather than an absent key.
|
|
164
|
+
* - Otherwise, for a to-**many** relation → the access `where` is
|
|
165
|
+
* AND-combined with any caller-supplied nested `where` (never replaced —
|
|
166
|
+
* the other half of #566), and a caller-supplied `take` rides through
|
|
167
|
+
* unchanged (#752). For a to-**one** relation → the access filter (if any)
|
|
168
|
+
* is recorded in `toOneAccessFilters` instead of attached as `where`,
|
|
169
|
+
* because Prisma only accepts a nested `where` on a to-many include
|
|
170
|
+
* (issue #974) — the entry itself never carries a `where` for a to-one key.
|
|
171
|
+
* - Either way — the "One hop" rule (ADR-0026) — nested relations are scoped
|
|
172
|
+
* ONLY if `requestedInclude` itself named a nested `include` here. A bare
|
|
173
|
+
* relation (or one with no nested `include`) fetches its own columns and
|
|
174
|
+
* stops: no recursive call, no access evaluation on anything beneath it.
|
|
103
175
|
*
|
|
104
176
|
* **Depth is a cost limit, not a cycle guard (ADR-0026).** A `requestedInclude`
|
|
105
177
|
* is always a finite literal — the caller's own object, or
|
|
@@ -120,13 +192,14 @@ export async function buildAccessScopedInclude(
|
|
|
120
192
|
config: OpenSaasConfig,
|
|
121
193
|
listKey: string,
|
|
122
194
|
depth: number = 0,
|
|
123
|
-
): Promise<Record<string, unknown
|
|
195
|
+
): Promise<{ include: Record<string, unknown>; toOneAccessFilters: ToOneAccessFilterTree }> {
|
|
124
196
|
const requestedKeys = Object.keys(requestedInclude)
|
|
125
197
|
if (depth >= READ_INCLUDE_MAX_DEPTH && requestedKeys.length > 0) {
|
|
126
198
|
throw new AccessScopeDepthExceededError(listKey, requestedKeys[0], depth)
|
|
127
199
|
}
|
|
128
200
|
|
|
129
201
|
const result: Record<string, unknown> = {}
|
|
202
|
+
const toOneAccessFilters = emptyToOneAccessFilterTree()
|
|
130
203
|
|
|
131
204
|
for (const [relationName, requestedValue] of Object.entries(requestedInclude)) {
|
|
132
205
|
const fieldConfig = fieldConfigs[relationName]
|
|
@@ -141,6 +214,8 @@ export async function buildAccessScopedInclude(
|
|
|
141
214
|
const relatedConfig = getRelatedListConfig(fieldConfig.ref as string, config)
|
|
142
215
|
if (!relatedConfig) continue
|
|
143
216
|
|
|
217
|
+
const isToOne = isToOneRelationship(fieldConfig)
|
|
218
|
+
|
|
144
219
|
const queryAccess = relatedConfig.listConfig.access?.operation?.query
|
|
145
220
|
const accessResult = await checkAccess(queryAccess, {
|
|
146
221
|
session: args.session,
|
|
@@ -148,16 +223,19 @@ export async function buildAccessScopedInclude(
|
|
|
148
223
|
})
|
|
149
224
|
|
|
150
225
|
if (accessResult === false) {
|
|
226
|
+
if (isToOne) {
|
|
227
|
+
toOneAccessFilters.filters[relationName] = { kind: 'denied' }
|
|
228
|
+
}
|
|
151
229
|
continue
|
|
152
230
|
}
|
|
153
231
|
|
|
154
232
|
const accessWhere = typeof accessResult === 'object' ? accessResult : undefined
|
|
155
233
|
const requestedEntry = asEntryObject(requestedValue)
|
|
156
|
-
const mergedWhere = andWhere(accessWhere, requestedEntry?.where)
|
|
157
234
|
|
|
158
235
|
let nestedInclude: Record<string, unknown> | undefined
|
|
236
|
+
let nestedToOneFilters: ToOneAccessFilterTree | undefined
|
|
159
237
|
if (requestedEntry?.include) {
|
|
160
|
-
|
|
238
|
+
const nested = await buildAccessScopedInclude(
|
|
161
239
|
requestedEntry.include,
|
|
162
240
|
relatedConfig.listConfig.fields,
|
|
163
241
|
args,
|
|
@@ -165,17 +243,127 @@ export async function buildAccessScopedInclude(
|
|
|
165
243
|
relatedConfig.listName,
|
|
166
244
|
depth + 1,
|
|
167
245
|
)
|
|
246
|
+
nestedInclude = nested.include
|
|
247
|
+
nestedToOneFilters = nested.toOneAccessFilters
|
|
168
248
|
}
|
|
169
249
|
|
|
170
|
-
const entry:
|
|
171
|
-
if (
|
|
250
|
+
const entry: IncludeEntryObject = {}
|
|
251
|
+
if (isToOne) {
|
|
252
|
+
if (accessWhere) {
|
|
253
|
+
toOneAccessFilters.filters[relationName] = {
|
|
254
|
+
kind: 'scoped',
|
|
255
|
+
relatedListName: relatedConfig.listName,
|
|
256
|
+
accessWhere,
|
|
257
|
+
}
|
|
258
|
+
}
|
|
259
|
+
} else {
|
|
260
|
+
const mergedWhere = andWhere(accessWhere, requestedEntry?.where)
|
|
261
|
+
if (mergedWhere) entry.where = mergedWhere
|
|
262
|
+
if (requestedEntry?.take !== undefined) entry.take = requestedEntry.take
|
|
263
|
+
if (requestedEntry?.orderBy !== undefined) entry.orderBy = requestedEntry.orderBy
|
|
264
|
+
if (requestedEntry?.skip !== undefined) entry.skip = requestedEntry.skip
|
|
265
|
+
}
|
|
172
266
|
if (nestedInclude && Object.keys(nestedInclude).length > 0) entry.include = nestedInclude
|
|
173
|
-
if (
|
|
267
|
+
if (nestedToOneFilters && !isToOneAccessFilterTreeEmpty(nestedToOneFilters)) {
|
|
268
|
+
toOneAccessFilters.nested[relationName] = nestedToOneFilters
|
|
269
|
+
}
|
|
174
270
|
|
|
175
271
|
result[relationName] = Object.keys(entry).length > 0 ? entry : true
|
|
176
272
|
}
|
|
177
273
|
|
|
178
|
-
return result
|
|
274
|
+
return { include: result, toOneAccessFilters }
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
/** One to-one relation's resolved post-query visibility — see `resolveToOneAccessVisibility`. */
|
|
278
|
+
export type ToOneVisibility = { kind: 'denied' } | { kind: 'visible'; ids: ReadonlySet<string> }
|
|
279
|
+
|
|
280
|
+
/** The resolved counterpart to {@link ToOneAccessFilterTree}, produced by `resolveToOneAccessVisibility`. */
|
|
281
|
+
export type ToOneAccessVisibilityTree = {
|
|
282
|
+
filters: Record<string, ToOneVisibility>
|
|
283
|
+
nested: Record<string, ToOneAccessVisibilityTree>
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
export function emptyToOneAccessVisibilityTree(): ToOneAccessVisibilityTree {
|
|
287
|
+
return { filters: {}, nested: {} }
|
|
288
|
+
}
|
|
289
|
+
|
|
290
|
+
/**
|
|
291
|
+
* Resolve a `ToOneAccessFilterTree` against the RAW rows Prisma already
|
|
292
|
+
* fetched (unscoped for the flagged to-one relations — see
|
|
293
|
+
* `buildAccessScopedInclude`) into the set of related ids the session may
|
|
294
|
+
* actually see, one batched `id IN (...)` existence check per (relation,
|
|
295
|
+
* nesting level) across every row in `items` — never once per row.
|
|
296
|
+
*
|
|
297
|
+
* For each `filters` entry at a level:
|
|
298
|
+
* - `kind: 'denied'` → carried straight through; no query, nothing to check.
|
|
299
|
+
* - `kind: 'scoped'` → every id present at this key across ALL of `items` is
|
|
300
|
+
* collected first (an empty set skips the query entirely — nothing to
|
|
301
|
+
* check), then ONE `findMany` through the RAW `prisma` client (not
|
|
302
|
+
* `context.db`, which would re-evaluate the same access-control function a
|
|
303
|
+
* second time) asks which of those ids also satisfy `accessWhere` — the
|
|
304
|
+
* exact `PrismaFilter` `checkAccess` already produced, handed to Prisma
|
|
305
|
+
* unmodified rather than interpreted by hand.
|
|
306
|
+
*
|
|
307
|
+
* Recurses into `nested` by flattening the related items reached through
|
|
308
|
+
* each key across every row in `items` (a to-many hop contributes every one
|
|
309
|
+
* of its rows; a to-one hop contributes its single row, if any) into the
|
|
310
|
+
* next level's own `items` array, so a to-one relation nested arbitrarily
|
|
311
|
+
* deep is still resolved with one batched query per node, not per parent row.
|
|
312
|
+
*/
|
|
313
|
+
export async function resolveToOneAccessVisibility(
|
|
314
|
+
items: readonly unknown[],
|
|
315
|
+
tree: ToOneAccessFilterTree,
|
|
316
|
+
args: {
|
|
317
|
+
session: Session | null
|
|
318
|
+
context: AccessContext
|
|
319
|
+
},
|
|
320
|
+
): Promise<ToOneAccessVisibilityTree> {
|
|
321
|
+
const resolved = emptyToOneAccessVisibilityTree()
|
|
322
|
+
|
|
323
|
+
for (const [key, entry] of Object.entries(tree.filters)) {
|
|
324
|
+
if (entry.kind === 'denied') {
|
|
325
|
+
resolved.filters[key] = { kind: 'denied' }
|
|
326
|
+
continue
|
|
327
|
+
}
|
|
328
|
+
|
|
329
|
+
const ids = new Set<string>()
|
|
330
|
+
for (const item of items) {
|
|
331
|
+
if (!item || typeof item !== 'object') continue
|
|
332
|
+
const value = (item as Record<string, unknown>)[key]
|
|
333
|
+
if (value && typeof value === 'object' && 'id' in value) {
|
|
334
|
+
ids.add(String((value as Record<string, unknown>).id))
|
|
335
|
+
}
|
|
336
|
+
}
|
|
337
|
+
|
|
338
|
+
if (ids.size === 0) {
|
|
339
|
+
resolved.filters[key] = { kind: 'visible', ids: new Set() }
|
|
340
|
+
continue
|
|
341
|
+
}
|
|
342
|
+
|
|
343
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- dynamic model access by list name, mirroring the rest of the read pipeline
|
|
344
|
+
const model = (args.context.prisma as any)[getDbKey(entry.relatedListName)]
|
|
345
|
+
const visibleRows = await model.findMany({
|
|
346
|
+
where: { AND: [entry.accessWhere, { id: { in: [...ids] } }] },
|
|
347
|
+
select: { id: true },
|
|
348
|
+
})
|
|
349
|
+
const visibleIds = new Set<string>(
|
|
350
|
+
Array.isArray(visibleRows) ? visibleRows.map((row: { id: unknown }) => String(row.id)) : [],
|
|
351
|
+
)
|
|
352
|
+
resolved.filters[key] = { kind: 'visible', ids: visibleIds }
|
|
353
|
+
}
|
|
354
|
+
|
|
355
|
+
for (const [key, nestedTree] of Object.entries(tree.nested)) {
|
|
356
|
+
const nestedItems: unknown[] = []
|
|
357
|
+
for (const item of items) {
|
|
358
|
+
if (!item || typeof item !== 'object') continue
|
|
359
|
+
const value = (item as Record<string, unknown>)[key]
|
|
360
|
+
if (Array.isArray(value)) nestedItems.push(...value)
|
|
361
|
+
else if (value && typeof value === 'object') nestedItems.push(value)
|
|
362
|
+
}
|
|
363
|
+
resolved.nested[key] = await resolveToOneAccessVisibility(nestedItems, nestedTree, args)
|
|
364
|
+
}
|
|
365
|
+
|
|
366
|
+
return resolved
|
|
179
367
|
}
|
|
180
368
|
|
|
181
369
|
/**
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
import { describe, it, expect } from 'vitest'
|
|
2
|
+
import { resolveSyntheticReverseRelation } from './engine.js'
|
|
3
|
+
import type { OpenSaasConfig, FieldConfig } from '../config/types.js'
|
|
4
|
+
|
|
5
|
+
// A relationship field pointing at another list.
|
|
6
|
+
function rel(ref: string, many = false): FieldConfig {
|
|
7
|
+
return { type: 'relationship', ref, many } as unknown as FieldConfig
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* #978: a list-only `ref` (`ref: 'ListName'`, no target field) generates a
|
|
12
|
+
* synthetic back-relation on its target — `from_<SourceList>_<sourceField>`
|
|
13
|
+
* (see `getSyntheticFieldName` in fields/index.ts). `resolveSyntheticReverseRelation`
|
|
14
|
+
* is the runtime counterpart that recognises the same name and resolves it
|
|
15
|
+
* back to the declared field that owns it, so a nested write addressed to it
|
|
16
|
+
* gets the same hook pipeline a declared relationship field gets.
|
|
17
|
+
*/
|
|
18
|
+
describe('resolveSyntheticReverseRelation', () => {
|
|
19
|
+
const config: OpenSaasConfig = {
|
|
20
|
+
db: { provider: 'sqlite', url: 'file:./dev.db' },
|
|
21
|
+
lists: {
|
|
22
|
+
Account: { fields: { name: { type: 'text' } as unknown as FieldConfig } },
|
|
23
|
+
ChargeRequest: {
|
|
24
|
+
fields: {
|
|
25
|
+
kind: { type: 'text' } as unknown as FieldConfig,
|
|
26
|
+
// List-only ref — no field named on Account, so a back-relation is
|
|
27
|
+
// synthesized on it as `from_ChargeRequest_account`.
|
|
28
|
+
account: rel('Account'),
|
|
29
|
+
},
|
|
30
|
+
},
|
|
31
|
+
// A bidirectional relationship — Post.author names User.posts directly,
|
|
32
|
+
// so no back-relation is synthesized anywhere for it.
|
|
33
|
+
User: { fields: { posts: rel('Post.author', true) } },
|
|
34
|
+
Post: { fields: { author: rel('User.posts') } },
|
|
35
|
+
},
|
|
36
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
37
|
+
} as any
|
|
38
|
+
|
|
39
|
+
it('resolves the synthetic back-relation name to its owning field', () => {
|
|
40
|
+
const result = resolveSyntheticReverseRelation('from_ChargeRequest_account', 'Account', config)
|
|
41
|
+
expect(result).not.toBeNull()
|
|
42
|
+
expect(result?.sourceListName).toBe('ChargeRequest')
|
|
43
|
+
expect(result?.sourceFieldName).toBe('account')
|
|
44
|
+
expect(result?.sourceFieldConfig.ref).toBe('Account')
|
|
45
|
+
})
|
|
46
|
+
|
|
47
|
+
it('returns null for a name that does not match the synthetic construction', () => {
|
|
48
|
+
expect(
|
|
49
|
+
resolveSyntheticReverseRelation('from_ChargeRequest_amount', 'Account', config),
|
|
50
|
+
).toBeNull()
|
|
51
|
+
expect(resolveSyntheticReverseRelation('chargeRequests', 'Account', config)).toBeNull()
|
|
52
|
+
})
|
|
53
|
+
|
|
54
|
+
it('returns null on the wrong parent list', () => {
|
|
55
|
+
expect(
|
|
56
|
+
resolveSyntheticReverseRelation('from_ChargeRequest_account', 'ChargeRequest', config),
|
|
57
|
+
).toBeNull()
|
|
58
|
+
})
|
|
59
|
+
|
|
60
|
+
it('returns null for a bidirectional ref — its other side is a real declared field, not a synthetic one', () => {
|
|
61
|
+
expect(resolveSyntheticReverseRelation('from_Post_author', 'User', config)).toBeNull()
|
|
62
|
+
})
|
|
63
|
+
|
|
64
|
+
it('returns null for a genuinely unknown key', () => {
|
|
65
|
+
expect(resolveSyntheticReverseRelation('totallyBogusKey', 'Account', config)).toBeNull()
|
|
66
|
+
})
|
|
67
|
+
})
|
package/src/access/engine.ts
CHANGED
|
@@ -1,5 +1,7 @@
|
|
|
1
1
|
import type { AccessControl, Session, AccessContext, PrismaFilter } from './types.js'
|
|
2
|
-
import type { OpenSaasConfig, ListConfig } from '../config/types.js'
|
|
2
|
+
import type { OpenSaasConfig, ListConfig, RelationshipField } from '../config/types.js'
|
|
3
|
+
import { getSyntheticFieldName } from '../fields/index.js'
|
|
4
|
+
import { InvalidCreateAccessResultError } from './errors.js'
|
|
3
5
|
|
|
4
6
|
/**
|
|
5
7
|
* Access engine — operation-level access control and shared helpers.
|
|
@@ -45,6 +47,53 @@ export function getRelatedListConfig(
|
|
|
45
47
|
return { listName, listConfig }
|
|
46
48
|
}
|
|
47
49
|
|
|
50
|
+
/** A synthetic reverse relation, resolved back to the declared field that owns it. */
|
|
51
|
+
export interface SyntheticReverseRelation {
|
|
52
|
+
sourceListName: string
|
|
53
|
+
sourceFieldName: string
|
|
54
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- RelationshipField must accept any TypeInfo
|
|
55
|
+
sourceFieldConfig: RelationshipField<any>
|
|
56
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- ListConfig must accept any TypeInfo
|
|
57
|
+
sourceListConfig: ListConfig<any>
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/**
|
|
61
|
+
* Resolve a candidate data key as the synthetic back-relation a list-only
|
|
62
|
+
* `ref` (`ref: 'ListName'`, no target field) generates on its target model —
|
|
63
|
+
* Prisma requires an opposite field there, but the config never declares one,
|
|
64
|
+
* so it never appears in `parentListName`'s own `fields`. Reuses
|
|
65
|
+
* `getSyntheticFieldName` (the same construction `getPrismaRelation` emits the
|
|
66
|
+
* schema with) rather than re-deriving the `from_<List>_<field>` format by
|
|
67
|
+
* string parsing, so the two cannot drift (#978).
|
|
68
|
+
*
|
|
69
|
+
* Returns the declared relationship field that owns the relation — the write
|
|
70
|
+
* pipeline treats a resolved synthetic key exactly like a nested write through
|
|
71
|
+
* that field, so it runs the same hooks/access/recovery machinery a declared
|
|
72
|
+
* relationship field gets. Returns `null` when `fieldName` isn't one of these
|
|
73
|
+
* on `parentListName` (a genuinely unknown key, or a bidirectional relation's
|
|
74
|
+
* ref, which never synthesizes a back-relation).
|
|
75
|
+
*/
|
|
76
|
+
export function resolveSyntheticReverseRelation(
|
|
77
|
+
fieldName: string,
|
|
78
|
+
parentListName: string,
|
|
79
|
+
config: OpenSaasConfig,
|
|
80
|
+
): SyntheticReverseRelation | null {
|
|
81
|
+
for (const [sourceListName, sourceListConfig] of Object.entries(config.lists)) {
|
|
82
|
+
for (const [sourceFieldName, sourceFieldConfig] of Object.entries(sourceListConfig.fields)) {
|
|
83
|
+
if (sourceFieldConfig.type !== 'relationship') continue
|
|
84
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- RelationshipField must accept any TypeInfo
|
|
85
|
+
const rel = sourceFieldConfig as RelationshipField<any>
|
|
86
|
+
// Only a list-only ref ('ListName', no '.fieldName') synthesizes a
|
|
87
|
+
// back-relation — a bidirectional ref's other side is a real field.
|
|
88
|
+
const refParts = rel.ref.split('.')
|
|
89
|
+
if (refParts.length !== 1 || refParts[0] !== parentListName) continue
|
|
90
|
+
if (getSyntheticFieldName(sourceListName, sourceFieldName) !== fieldName) continue
|
|
91
|
+
return { sourceListName, sourceFieldName, sourceFieldConfig: rel, sourceListConfig }
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
return null
|
|
95
|
+
}
|
|
96
|
+
|
|
48
97
|
export async function checkAccess<T = Record<string, unknown>>(
|
|
49
98
|
accessControl: AccessControl<T> | undefined,
|
|
50
99
|
args: {
|
|
@@ -63,6 +112,37 @@ export async function checkAccess<T = Record<string, unknown>>(
|
|
|
63
112
|
return result
|
|
64
113
|
}
|
|
65
114
|
|
|
115
|
+
/**
|
|
116
|
+
* Evaluate operation-level `create` access. The single evaluator shared by the
|
|
117
|
+
* write pipeline's top-level create and the nested-create path — both must
|
|
118
|
+
* reject the same way, or the two drift again (#1009).
|
|
119
|
+
*
|
|
120
|
+
* Unlike `checkAccess` (which `query`/`update`/`delete` call directly and which
|
|
121
|
+
* legitimately returns a filter for them to re-check against a row), create
|
|
122
|
+
* has no existing row and no way to test a filter against input data. A rule
|
|
123
|
+
* that returns anything other than a strict boolean — most notably a filter,
|
|
124
|
+
* which type-checks against the shared `AccessControl` signature and reads as
|
|
125
|
+
* though it scopes the create — throws `InvalidCreateAccessResultError`
|
|
126
|
+
* rather than being silently treated as an allow. See that error's doc,
|
|
127
|
+
* ADR-0022, and ADR-0030.
|
|
128
|
+
*/
|
|
129
|
+
export async function checkCreateAccess<T = Record<string, unknown>>(
|
|
130
|
+
listKey: string,
|
|
131
|
+
accessControl: AccessControl<T> | undefined,
|
|
132
|
+
args: {
|
|
133
|
+
session: Session | null
|
|
134
|
+
context: AccessContext
|
|
135
|
+
},
|
|
136
|
+
): Promise<boolean> {
|
|
137
|
+
const result = await checkAccess(accessControl, args)
|
|
138
|
+
|
|
139
|
+
if (isBoolean(result)) {
|
|
140
|
+
return result
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
throw new InvalidCreateAccessResultError(listKey, result)
|
|
144
|
+
}
|
|
145
|
+
|
|
66
146
|
export function mergeFilters(
|
|
67
147
|
userFilter: PrismaFilter | undefined,
|
|
68
148
|
accessFilter: boolean | PrismaFilter,
|
package/src/access/errors.ts
CHANGED
|
@@ -99,7 +99,7 @@ export class RelationFilterAccessDeniedError extends Error {
|
|
|
99
99
|
}
|
|
100
100
|
}
|
|
101
101
|
|
|
102
|
-
function
|
|
102
|
+
function describeAccessResult(result: unknown): string {
|
|
103
103
|
if (result === null) return 'null'
|
|
104
104
|
if (result === undefined) return 'undefined'
|
|
105
105
|
if (typeof result === 'object') return 'an object (e.g. a Prisma filter)'
|
|
@@ -135,7 +135,7 @@ export class InvalidFieldAccessResultError extends Error {
|
|
|
135
135
|
constructor(operation: 'read' | 'create' | 'update', result: unknown) {
|
|
136
136
|
super(
|
|
137
137
|
`Field-level access control for operation "${operation}" returned ` +
|
|
138
|
-
`${
|
|
138
|
+
`${describeAccessResult(result)}, not a boolean. Field access is a per-field ` +
|
|
139
139
|
`visibility decision — it must return true or false, and (unlike operation-level access) ` +
|
|
140
140
|
`cannot scope which rows are affected. If you meant to restrict access based on the row or ` +
|
|
141
141
|
`the write payload, evaluate the condition yourself and return a boolean, e.g. ` +
|
|
@@ -145,3 +145,43 @@ export class InvalidFieldAccessResultError extends Error {
|
|
|
145
145
|
this.operation = operation
|
|
146
146
|
}
|
|
147
147
|
}
|
|
148
|
+
|
|
149
|
+
/**
|
|
150
|
+
* Thrown when operation-level `create` access control returns anything other
|
|
151
|
+
* than a strict `boolean`. `OperationAccess['create']` is typed as
|
|
152
|
+
* `AccessControl`, which also accepts a `PrismaFilter` — the shape
|
|
153
|
+
* `query`/`update`/`delete` legitimately use to scope which rows an
|
|
154
|
+
* operation may touch (see ADR-0001). Create has none of that to scope: there
|
|
155
|
+
* is no existing row, and — unlike update/delete, which re-check a returned
|
|
156
|
+
* filter against the target via `findFirst` — no equivalent re-check is
|
|
157
|
+
* possible against data that doesn't exist in the database yet.
|
|
158
|
+
*
|
|
159
|
+
* A filter-returning `create` rule type-checks (the shared `AccessControl`
|
|
160
|
+
* type admits it) and reads as though it scopes the create. Before this it
|
|
161
|
+
* fell through a check that only tested `=== false` and was silently treated
|
|
162
|
+
* as a full allow. This is the same defect shape ADR-0030 closed for
|
|
163
|
+
* field-level access, resolved the same way per ADR-0022 (an engine that
|
|
164
|
+
* cannot compute a scope must deny, never pass through) — a loud, distinct
|
|
165
|
+
* failure rather than a wider `null`/`[]` Silent failure, because this is a
|
|
166
|
+
* config bug, not an access denial.
|
|
167
|
+
*
|
|
168
|
+
* Deliberately does not expose the offending result as a public field, for
|
|
169
|
+
* the same reason as `InvalidFieldAccessResultError`: there is no
|
|
170
|
+
* concretely-typed shape to give it, and the root CLAUDE.md forbids exposing
|
|
171
|
+
* `unknown`/`any` as part of a package's external API.
|
|
172
|
+
*/
|
|
173
|
+
export class InvalidCreateAccessResultError extends Error {
|
|
174
|
+
public listKey: string
|
|
175
|
+
|
|
176
|
+
constructor(listKey: string, result: unknown) {
|
|
177
|
+
super(
|
|
178
|
+
`Operation-level "create" access control for "${listKey}" returned ` +
|
|
179
|
+
`${describeAccessResult(result)}, not a boolean. Create cannot be row-scoped — there is no ` +
|
|
180
|
+
`existing row, and no input data is available to test a filter against here. Return a ` +
|
|
181
|
+
`boolean from create access, or move the ownership check into a \`resolveInput\` or ` +
|
|
182
|
+
`\`validate\` hook, where the input data is in scope.`,
|
|
183
|
+
)
|
|
184
|
+
this.name = 'InvalidCreateAccessResultError'
|
|
185
|
+
this.listKey = listKey
|
|
186
|
+
}
|
|
187
|
+
}
|
|
@@ -712,3 +712,81 @@ describe('filterWritableFields', () => {
|
|
|
712
712
|
expect(filtered.author).toEqual({ connect: { id: 'user-1' } })
|
|
713
713
|
})
|
|
714
714
|
})
|
|
715
|
+
|
|
716
|
+
// ── #978: under sudo, only a synthetic reverse-relation key is a recognised
|
|
717
|
+
// undeclared key — anything else is refused, even under sudo ──────────────
|
|
718
|
+
|
|
719
|
+
describe('filterWritableFields — #978 tightened sudo undeclared-key guard', () => {
|
|
720
|
+
// A minimal config: Account is the target of ChargeRequest's list-only ref
|
|
721
|
+
// (`ref: 'Account'`, no field named on Account), so the generator
|
|
722
|
+
// synthesizes `from_ChargeRequest_account` on Account.
|
|
723
|
+
const config = {
|
|
724
|
+
db: { provider: 'sqlite', url: 'file:./dev.db' },
|
|
725
|
+
lists: {
|
|
726
|
+
Account: { fields: { name: { type: 'text' } } },
|
|
727
|
+
ChargeRequest: {
|
|
728
|
+
fields: {
|
|
729
|
+
kind: { type: 'text' },
|
|
730
|
+
account: { type: 'relationship', ref: 'Account' },
|
|
731
|
+
},
|
|
732
|
+
},
|
|
733
|
+
},
|
|
734
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
735
|
+
} as any
|
|
736
|
+
|
|
737
|
+
it('passes a synthetic reverse-relation key through under sudo, when config/listName are supplied', async () => {
|
|
738
|
+
const fieldConfigs = { name: { type: 'text' } }
|
|
739
|
+
const data = {
|
|
740
|
+
name: 'Acme',
|
|
741
|
+
from_ChargeRequest_account: { create: { kind: 'DEPOSIT' } },
|
|
742
|
+
}
|
|
743
|
+
|
|
744
|
+
const filtered = await filterWritableFields(data, fieldConfigs, 'update', {
|
|
745
|
+
session: null,
|
|
746
|
+
item: { id: 'a1' },
|
|
747
|
+
context: sudoContext(),
|
|
748
|
+
inputData: data,
|
|
749
|
+
listName: 'Account',
|
|
750
|
+
config,
|
|
751
|
+
})
|
|
752
|
+
|
|
753
|
+
expect(filtered).toHaveProperty('name', 'Acme')
|
|
754
|
+
expect(filtered).toHaveProperty('from_ChargeRequest_account')
|
|
755
|
+
})
|
|
756
|
+
|
|
757
|
+
it('throws on a genuinely unknown key under sudo, when config/listName are supplied', async () => {
|
|
758
|
+
const fieldConfigs = { name: { type: 'text' } }
|
|
759
|
+
const data = {
|
|
760
|
+
name: 'Acme',
|
|
761
|
+
totallyBogusKey: 'value',
|
|
762
|
+
}
|
|
763
|
+
|
|
764
|
+
await expect(
|
|
765
|
+
filterWritableFields(data, fieldConfigs, 'update', {
|
|
766
|
+
session: null,
|
|
767
|
+
item: { id: 'a1' },
|
|
768
|
+
context: sudoContext(),
|
|
769
|
+
inputData: data,
|
|
770
|
+
listName: 'Account',
|
|
771
|
+
config,
|
|
772
|
+
}),
|
|
773
|
+
).rejects.toThrow(/totallyBogusKey/)
|
|
774
|
+
})
|
|
775
|
+
|
|
776
|
+
it('keeps the pre-#978 blanket sudo passthrough when config/listName are omitted', async () => {
|
|
777
|
+
// Pins the fallback direct unit tests above (e.g. "passes undeclared data
|
|
778
|
+
// keys through under sudo") rely on: with no config to resolve a synthetic
|
|
779
|
+
// key against, any undeclared key still passes through under sudo.
|
|
780
|
+
const fieldConfigs = { name: { type: 'text' } }
|
|
781
|
+
const data = { name: 'Acme', totallyBogusKey: 'value' }
|
|
782
|
+
|
|
783
|
+
const filtered = await filterWritableFields(data, fieldConfigs, 'update', {
|
|
784
|
+
session: null,
|
|
785
|
+
item: { id: 'a1' },
|
|
786
|
+
context: sudoContext(),
|
|
787
|
+
inputData: data,
|
|
788
|
+
})
|
|
789
|
+
|
|
790
|
+
expect(filtered).toHaveProperty('totallyBogusKey', 'value')
|
|
791
|
+
})
|
|
792
|
+
})
|