@opensaas/stack-core 0.24.0 → 0.25.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 +223 -0
- package/dist/access/access-filter.d.ts +39 -0
- package/dist/access/access-filter.d.ts.map +1 -1
- package/dist/access/access-filter.js +121 -0
- package/dist/access/access-filter.js.map +1 -1
- package/dist/access/field-access.d.ts +1 -0
- package/dist/access/field-access.d.ts.map +1 -1
- package/dist/access/field-access.js +79 -4
- package/dist/access/field-access.js.map +1 -1
- package/dist/access/field-access.test.js +213 -0
- package/dist/access/field-access.test.js.map +1 -1
- package/dist/access/index.d.ts +1 -1
- package/dist/access/index.d.ts.map +1 -1
- package/dist/access/index.js +1 -1
- package/dist/access/index.js.map +1 -1
- package/dist/access/types.d.ts +39 -0
- package/dist/access/types.d.ts.map +1 -1
- package/dist/config/types.d.ts +318 -0
- 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 +153 -26
- package/dist/context/index.js.map +1 -1
- package/dist/context/nested-operations.d.ts +59 -3
- package/dist/context/nested-operations.d.ts.map +1 -1
- package/dist/context/nested-operations.js +552 -129
- package/dist/context/nested-operations.js.map +1 -1
- package/dist/context/transaction-boundary.d.ts +91 -0
- package/dist/context/transaction-boundary.d.ts.map +1 -0
- package/dist/context/transaction-boundary.js +329 -0
- package/dist/context/transaction-boundary.js.map +1 -0
- package/dist/context/write-pipeline.d.ts +15 -1
- package/dist/context/write-pipeline.d.ts.map +1 -1
- package/dist/context/write-pipeline.js +173 -10
- package/dist/context/write-pipeline.js.map +1 -1
- package/dist/fields/calendar-day.test.d.ts +2 -0
- package/dist/fields/calendar-day.test.d.ts.map +1 -0
- package/dist/fields/calendar-day.test.js +120 -0
- package/dist/fields/calendar-day.test.js.map +1 -0
- package/dist/fields/index.d.ts +18 -2
- package/dist/fields/index.d.ts.map +1 -1
- package/dist/fields/index.js +93 -17
- package/dist/fields/index.js.map +1 -1
- package/dist/hooks/index.d.ts +116 -0
- package/dist/hooks/index.d.ts.map +1 -1
- package/dist/hooks/index.js +154 -0
- package/dist/hooks/index.js.map +1 -1
- package/dist/validation/schema.test.js +222 -1
- package/dist/validation/schema.test.js.map +1 -1
- package/package.json +1 -1
- package/src/access/access-filter.ts +156 -0
- package/src/access/field-access.test.ts +255 -0
- package/src/access/field-access.ts +91 -5
- package/src/access/index.ts +1 -1
- package/src/access/types.ts +45 -0
- package/src/config/types.ts +364 -0
- package/src/context/index.ts +207 -37
- package/src/context/nested-operations.ts +969 -143
- package/src/context/transaction-boundary.ts +440 -0
- package/src/context/write-pipeline.ts +234 -13
- package/src/fields/calendar-day.test.ts +140 -0
- package/src/fields/index.ts +96 -16
- package/src/hooks/index.ts +265 -0
- package/src/validation/schema.test.ts +266 -1
- package/tests/access.test.ts +24 -16
- package/tests/context.test.ts +481 -0
- package/tests/field-types.test.ts +17 -3
- package/tests/nested-access-and-hooks.test.ts +1130 -54
- package/tests/nested-operation-registry.test.ts +28 -3
- package/tests/nested-write-hooks.test.ts +864 -0
- package/tests/transaction-boundary-hooks.test.ts +465 -0
- package/tsconfig.tsbuildinfo +1 -1
|
@@ -1,5 +1,25 @@
|
|
|
1
1
|
import { describe, it, expect } from 'vitest'
|
|
2
2
|
import { filterWritableFields } from './field-access.js'
|
|
3
|
+
import { ValidationError } from '../hooks/index.js'
|
|
4
|
+
|
|
5
|
+
// A non-sudo access context. The cast is localized to test setup (mirrors the
|
|
6
|
+
// existing sudo-context casts in this file): the runtime AccessContext carries
|
|
7
|
+
// Prisma plumbing the unit under test never touches.
|
|
8
|
+
function nonSudoContext() {
|
|
9
|
+
return {
|
|
10
|
+
session: null,
|
|
11
|
+
_isSudo: false,
|
|
12
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
13
|
+
} as any
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
function sudoContext() {
|
|
17
|
+
return {
|
|
18
|
+
session: null,
|
|
19
|
+
_isSudo: true,
|
|
20
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
21
|
+
} as any
|
|
22
|
+
}
|
|
3
23
|
|
|
4
24
|
describe('filterWritableFields', () => {
|
|
5
25
|
it('should filter out foreign key fields when their corresponding relationship field exists', async () => {
|
|
@@ -146,4 +166,239 @@ describe('filterWritableFields', () => {
|
|
|
146
166
|
// authorId is a foreign key for author relationship, so it should be filtered
|
|
147
167
|
expect(filtered).not.toHaveProperty('authorId')
|
|
148
168
|
})
|
|
169
|
+
|
|
170
|
+
// ── #564: undeclared data keys must fail CLOSED (throw) for non-sudo writes ──
|
|
171
|
+
|
|
172
|
+
it('throws on an undeclared data key for a non-sudo create', async () => {
|
|
173
|
+
const fieldConfigs = { title: { type: 'text' } }
|
|
174
|
+
const data = {
|
|
175
|
+
title: 'Test',
|
|
176
|
+
// Not a declared field — e.g. a Prisma back-relation the config never
|
|
177
|
+
// exposed (`from_Enrolment_student`). Must be rejected, not passed through.
|
|
178
|
+
from_Enrolment_student: { disconnect: [{ id: 'e1' }] },
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
await expect(
|
|
182
|
+
filterWritableFields(data, fieldConfigs, 'create', {
|
|
183
|
+
session: null,
|
|
184
|
+
context: nonSudoContext(),
|
|
185
|
+
inputData: data,
|
|
186
|
+
}),
|
|
187
|
+
).rejects.toThrow(ValidationError)
|
|
188
|
+
await expect(
|
|
189
|
+
filterWritableFields(data, fieldConfigs, 'create', {
|
|
190
|
+
session: null,
|
|
191
|
+
context: nonSudoContext(),
|
|
192
|
+
inputData: data,
|
|
193
|
+
}),
|
|
194
|
+
).rejects.toThrow(/from_Enrolment_student/)
|
|
195
|
+
})
|
|
196
|
+
|
|
197
|
+
it('throws on an undeclared data key for a non-sudo update', async () => {
|
|
198
|
+
const fieldConfigs = { title: { type: 'text' } }
|
|
199
|
+
const data = {
|
|
200
|
+
title: 'Updated',
|
|
201
|
+
bogusKey: 'value',
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
await expect(
|
|
205
|
+
filterWritableFields(data, fieldConfigs, 'update', {
|
|
206
|
+
session: null,
|
|
207
|
+
item: { id: 'post-1' },
|
|
208
|
+
context: nonSudoContext(),
|
|
209
|
+
inputData: data,
|
|
210
|
+
}),
|
|
211
|
+
).rejects.toThrow(/bogusKey/)
|
|
212
|
+
})
|
|
213
|
+
|
|
214
|
+
it('passes undeclared data keys through under sudo (the single trusted bypass)', async () => {
|
|
215
|
+
const fieldConfigs = { title: { type: 'text' } }
|
|
216
|
+
const data = {
|
|
217
|
+
title: 'Test',
|
|
218
|
+
from_Enrolment_student: { disconnect: [{ id: 'e1' }] },
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
const filtered = await filterWritableFields(data, fieldConfigs, 'create', {
|
|
222
|
+
session: null,
|
|
223
|
+
context: sudoContext(),
|
|
224
|
+
inputData: data,
|
|
225
|
+
})
|
|
226
|
+
|
|
227
|
+
expect(filtered).toHaveProperty('title', 'Test')
|
|
228
|
+
expect(filtered).toHaveProperty('from_Enrolment_student')
|
|
229
|
+
})
|
|
230
|
+
|
|
231
|
+
it('still skips system fields and relationship FK fields cleanly for a non-sudo write', async () => {
|
|
232
|
+
const fieldConfigs = {
|
|
233
|
+
title: { type: 'text' },
|
|
234
|
+
author: { type: 'relationship', many: false },
|
|
235
|
+
}
|
|
236
|
+
const data = {
|
|
237
|
+
id: 'post-1',
|
|
238
|
+
createdAt: new Date(),
|
|
239
|
+
updatedAt: new Date(),
|
|
240
|
+
title: 'Test',
|
|
241
|
+
authorId: 'user-1', // FK skipped, not rejected
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
const filtered = await filterWritableFields(data, fieldConfigs, 'create', {
|
|
245
|
+
session: null,
|
|
246
|
+
context: nonSudoContext(),
|
|
247
|
+
inputData: data,
|
|
248
|
+
})
|
|
249
|
+
|
|
250
|
+
expect(filtered).not.toHaveProperty('id')
|
|
251
|
+
expect(filtered).not.toHaveProperty('createdAt')
|
|
252
|
+
expect(filtered).not.toHaveProperty('updatedAt')
|
|
253
|
+
expect(filtered).not.toHaveProperty('authorId')
|
|
254
|
+
expect(filtered).toHaveProperty('title', 'Test')
|
|
255
|
+
})
|
|
256
|
+
|
|
257
|
+
it('passes through raw per-part columns from a multi-column field whose write access ALLOWS (non-sudo)', async () => {
|
|
258
|
+
// Multi-column fields inject raw columns (e.g. m_url/m_size) that are not
|
|
259
|
+
// declared in fieldConfigs; they must not trip the undeclared-key reject.
|
|
260
|
+
// With no field-level access (allow), they pass through.
|
|
261
|
+
const fieldConfigs = {
|
|
262
|
+
media: {
|
|
263
|
+
type: 'image',
|
|
264
|
+
getColumnNames: (fieldName: string) => [`${fieldName}_url`, `${fieldName}_size`],
|
|
265
|
+
},
|
|
266
|
+
}
|
|
267
|
+
const data = {
|
|
268
|
+
media_url: 'https://x/y.jpg',
|
|
269
|
+
media_size: 99,
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
const filtered = await filterWritableFields(data, fieldConfigs, 'create', {
|
|
273
|
+
session: null,
|
|
274
|
+
context: nonSudoContext(),
|
|
275
|
+
inputData: data,
|
|
276
|
+
})
|
|
277
|
+
|
|
278
|
+
expect(filtered).toHaveProperty('media_url', 'https://x/y.jpg')
|
|
279
|
+
expect(filtered).toHaveProperty('media_size', 99)
|
|
280
|
+
})
|
|
281
|
+
|
|
282
|
+
it('THROWS when raw split columns are supplied for a field whose write access is DENIED (non-sudo)', async () => {
|
|
283
|
+
// Security (#568): a non-sudo caller who supplies the raw per-part columns
|
|
284
|
+
// DIRECTLY must not bypass the owning field's write-access gate. The
|
|
285
|
+
// logical-key gate in the hooks layer never fires here (no `media` key), so
|
|
286
|
+
// this filter is the only enforcement point — it must throw.
|
|
287
|
+
const fieldConfigs = {
|
|
288
|
+
media: {
|
|
289
|
+
type: 'image',
|
|
290
|
+
access: { create: () => false, update: () => false },
|
|
291
|
+
getColumnNames: (fieldName: string) => [`${fieldName}_url`, `${fieldName}_size`],
|
|
292
|
+
},
|
|
293
|
+
}
|
|
294
|
+
const data = {
|
|
295
|
+
media_url: 'https://evil/x.jpg',
|
|
296
|
+
media_size: 1,
|
|
297
|
+
}
|
|
298
|
+
|
|
299
|
+
// Throws ValidationError, and the message names the owning field.
|
|
300
|
+
await expect(
|
|
301
|
+
filterWritableFields(data, fieldConfigs, 'create', {
|
|
302
|
+
session: null,
|
|
303
|
+
context: nonSudoContext(),
|
|
304
|
+
inputData: data,
|
|
305
|
+
}),
|
|
306
|
+
).rejects.toThrow(ValidationError)
|
|
307
|
+
await expect(
|
|
308
|
+
filterWritableFields(data, fieldConfigs, 'update', {
|
|
309
|
+
session: null,
|
|
310
|
+
item: { id: 'item-1' },
|
|
311
|
+
context: nonSudoContext(),
|
|
312
|
+
inputData: data,
|
|
313
|
+
}),
|
|
314
|
+
).rejects.toThrow(/media/)
|
|
315
|
+
})
|
|
316
|
+
|
|
317
|
+
it('passes raw split columns through under sudo regardless of denied owning-field access', async () => {
|
|
318
|
+
// sudo is the single trusted bypass; `checkFieldAccess` returns true under
|
|
319
|
+
// sudo, so even a would-be-denied multi-column field passes through.
|
|
320
|
+
const fieldConfigs = {
|
|
321
|
+
media: {
|
|
322
|
+
type: 'image',
|
|
323
|
+
access: { create: () => false, update: () => false },
|
|
324
|
+
getColumnNames: (fieldName: string) => [`${fieldName}_url`, `${fieldName}_size`],
|
|
325
|
+
},
|
|
326
|
+
}
|
|
327
|
+
const data = {
|
|
328
|
+
media_url: 'https://x/y.jpg',
|
|
329
|
+
media_size: 99,
|
|
330
|
+
}
|
|
331
|
+
|
|
332
|
+
const filtered = await filterWritableFields(data, fieldConfigs, 'create', {
|
|
333
|
+
session: null,
|
|
334
|
+
context: sudoContext(),
|
|
335
|
+
inputData: data,
|
|
336
|
+
})
|
|
337
|
+
|
|
338
|
+
expect(filtered).toHaveProperty('media_url', 'https://x/y.jpg')
|
|
339
|
+
expect(filtered).toHaveProperty('media_size', 99)
|
|
340
|
+
})
|
|
341
|
+
|
|
342
|
+
// ── #568: field-access-denied keys must THROW, not be silently stripped ──────
|
|
343
|
+
|
|
344
|
+
it('throws when a declared field is denied by field-level access (non-sudo)', async () => {
|
|
345
|
+
const fieldConfigs = {
|
|
346
|
+
title: { type: 'text' },
|
|
347
|
+
status: {
|
|
348
|
+
type: 'text',
|
|
349
|
+
access: { update: () => false, create: () => false },
|
|
350
|
+
},
|
|
351
|
+
}
|
|
352
|
+
const data = { title: 'Test', status: 'published' }
|
|
353
|
+
|
|
354
|
+
await expect(
|
|
355
|
+
filterWritableFields(data, fieldConfigs, 'update', {
|
|
356
|
+
session: null,
|
|
357
|
+
item: { id: 'post-1' },
|
|
358
|
+
context: nonSudoContext(),
|
|
359
|
+
inputData: data,
|
|
360
|
+
}),
|
|
361
|
+
).rejects.toThrow(/status/)
|
|
362
|
+
})
|
|
363
|
+
|
|
364
|
+
it('does NOT throw on a would-be-denied field under sudo', async () => {
|
|
365
|
+
const fieldConfigs = {
|
|
366
|
+
title: { type: 'text' },
|
|
367
|
+
status: {
|
|
368
|
+
type: 'text',
|
|
369
|
+
access: { update: () => false, create: () => false },
|
|
370
|
+
},
|
|
371
|
+
}
|
|
372
|
+
const data = { title: 'Test', status: 'published' }
|
|
373
|
+
|
|
374
|
+
const filtered = await filterWritableFields(data, fieldConfigs, 'update', {
|
|
375
|
+
session: null,
|
|
376
|
+
item: { id: 'post-1' },
|
|
377
|
+
context: sudoContext(),
|
|
378
|
+
inputData: data,
|
|
379
|
+
})
|
|
380
|
+
|
|
381
|
+
expect(filtered).toHaveProperty('title', 'Test')
|
|
382
|
+
expect(filtered).toHaveProperty('status', 'published')
|
|
383
|
+
})
|
|
384
|
+
|
|
385
|
+
it('passes a declared relationship field through to nested operations (non-sudo)', async () => {
|
|
386
|
+
const fieldConfigs = {
|
|
387
|
+
title: { type: 'text' },
|
|
388
|
+
author: { type: 'relationship', many: false },
|
|
389
|
+
}
|
|
390
|
+
const data = {
|
|
391
|
+
title: 'Test',
|
|
392
|
+
author: { connect: { id: 'user-1' } },
|
|
393
|
+
}
|
|
394
|
+
|
|
395
|
+
const filtered = await filterWritableFields(data, fieldConfigs, 'create', {
|
|
396
|
+
session: null,
|
|
397
|
+
context: nonSudoContext(),
|
|
398
|
+
inputData: data,
|
|
399
|
+
})
|
|
400
|
+
|
|
401
|
+
expect(filtered).toHaveProperty('author')
|
|
402
|
+
expect(filtered.author).toEqual({ connect: { id: 'user-1' } })
|
|
403
|
+
})
|
|
149
404
|
})
|
|
@@ -1,5 +1,9 @@
|
|
|
1
1
|
import type { Session, AccessContext } from './types.js'
|
|
2
2
|
import type { FieldAccess } from './types.js'
|
|
3
|
+
// `ValidationError` is referenced only inside function bodies (call-time), never
|
|
4
|
+
// at module-evaluation time, so the field-access ⇄ hooks import cycle is safe
|
|
5
|
+
// under ESM live bindings.
|
|
6
|
+
import { ValidationError } from '../hooks/index.js'
|
|
3
7
|
|
|
4
8
|
/**
|
|
5
9
|
* Shared field-level access evaluation.
|
|
@@ -100,7 +104,14 @@ function matchesFilter(item: Record<string, unknown>, filter: Record<string, unk
|
|
|
100
104
|
*/
|
|
101
105
|
export async function filterWritableFields<T extends Record<string, unknown>>(
|
|
102
106
|
data: T,
|
|
103
|
-
fieldConfigs: Record<
|
|
107
|
+
fieldConfigs: Record<
|
|
108
|
+
string,
|
|
109
|
+
{
|
|
110
|
+
access?: FieldAccess
|
|
111
|
+
type?: string
|
|
112
|
+
getColumnNames?: (fieldName: string) => string[]
|
|
113
|
+
}
|
|
114
|
+
>,
|
|
104
115
|
operation: 'create' | 'update',
|
|
105
116
|
args: {
|
|
106
117
|
session: Session | null
|
|
@@ -114,6 +125,23 @@ export async function filterWritableFields<T extends Record<string, unknown>>(
|
|
|
114
125
|
// Build a set of foreign key field names to exclude
|
|
115
126
|
// Foreign keys should not be in the data when using Prisma's relation syntax
|
|
116
127
|
const foreignKeyFields = new Set<string>()
|
|
128
|
+
// Map each raw per-part column name contributed by a multi-column field
|
|
129
|
+
// (e.g. storage image()/file() in Keystone-parity mode) back to its OWNING
|
|
130
|
+
// declared field. These columns are injected into the write payload by the
|
|
131
|
+
// field's `splitColumns` AFTER resolveInput and are intentionally NOT declared
|
|
132
|
+
// as their own entries in `fieldConfigs`, so without this map they would trip
|
|
133
|
+
// the #564 undeclared-key reject below.
|
|
134
|
+
//
|
|
135
|
+
// SECURITY (#568): a raw column must NOT be blanket-passed through. The hooks
|
|
136
|
+
// layer (`executeFieldResolveInputHooks`) only gates the owning field when the
|
|
137
|
+
// LOGICAL key (e.g. `media`) is present, because it iterates declared fields,
|
|
138
|
+
// not data keys. A non-sudo caller who supplies the raw columns DIRECTLY
|
|
139
|
+
// (`data: { media_url, media_size }`) never produces that logical key, so that
|
|
140
|
+
// gate never fires. We therefore gate each raw column HERE by its owning
|
|
141
|
+
// field's write access — denied (non-sudo) throws, allowed (or sudo) passes
|
|
142
|
+
// through — so the legitimate multi-column write path is preserved while the
|
|
143
|
+
// direct-raw-column bypass is closed.
|
|
144
|
+
const splitColumnOwners = new Map<string, { fieldName: string; access?: FieldAccess }>()
|
|
117
145
|
for (const [fieldName, fieldConfig] of Object.entries(fieldConfigs)) {
|
|
118
146
|
if (fieldConfig.type === 'relationship') {
|
|
119
147
|
// For non-many relationships, Prisma creates a foreign key field named `${fieldName}Id`
|
|
@@ -122,8 +150,15 @@ export async function filterWritableFields<T extends Record<string, unknown>>(
|
|
|
122
150
|
foreignKeyFields.add(`${fieldName}Id`)
|
|
123
151
|
}
|
|
124
152
|
}
|
|
153
|
+
if (typeof fieldConfig.getColumnNames === 'function') {
|
|
154
|
+
for (const column of fieldConfig.getColumnNames(fieldName)) {
|
|
155
|
+
splitColumnOwners.set(column, { fieldName, access: fieldConfig.access })
|
|
156
|
+
}
|
|
157
|
+
}
|
|
125
158
|
}
|
|
126
159
|
|
|
160
|
+
const isSudo = args.context._isSudo === true
|
|
161
|
+
|
|
127
162
|
for (const [fieldName, value] of Object.entries(data)) {
|
|
128
163
|
const fieldConfig = fieldConfigs[fieldName]
|
|
129
164
|
|
|
@@ -144,15 +179,66 @@ export async function filterWritableFields<T extends Record<string, unknown>>(
|
|
|
144
179
|
continue
|
|
145
180
|
}
|
|
146
181
|
|
|
147
|
-
//
|
|
148
|
-
|
|
182
|
+
// Raw per-part columns produced by a multi-column field's `splitColumns`.
|
|
183
|
+
// They are undeclared by design, so they must not trip the #564 reject — but
|
|
184
|
+
// they must NOT be blanket-passed through either: gate each one by its
|
|
185
|
+
// OWNING field's write access (see the SECURITY note where the map is built).
|
|
186
|
+
// This is the real gate for callers who supply the raw columns directly,
|
|
187
|
+
// because the logical-key gate in `executeFieldResolveInputHooks` never fires
|
|
188
|
+
// for them. Denied (non-sudo) throws — same fail-loud behaviour as a denied
|
|
189
|
+
// declared field (#568); allowed (or sudo, via `checkFieldAccess`) passes
|
|
190
|
+
// through, preserving the legitimate multi-column write path.
|
|
191
|
+
const splitColumnOwner = splitColumnOwners.get(fieldName)
|
|
192
|
+
if (splitColumnOwner) {
|
|
193
|
+
const canWrite = await checkFieldAccess(splitColumnOwner.access, operation, {
|
|
194
|
+
...args,
|
|
195
|
+
inputData: args.inputData,
|
|
196
|
+
})
|
|
197
|
+
if (!canWrite) {
|
|
198
|
+
throw new ValidationError([
|
|
199
|
+
`Cannot ${operation} "${splitColumnOwner.fieldName}" (via column "${fieldName}"): ` +
|
|
200
|
+
`field-level access denied.`,
|
|
201
|
+
])
|
|
202
|
+
}
|
|
203
|
+
filtered[fieldName] = value
|
|
204
|
+
continue
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
// #564 — undeclared data keys must fail CLOSED.
|
|
208
|
+
// A key with no entry in `fieldConfigs` is not a field the list config
|
|
209
|
+
// exposes. The generated Prisma model has MORE fields than the config
|
|
210
|
+
// declares (e.g. back-relations like `from_Enrolment_student`), so allowing
|
|
211
|
+
// an undeclared key to pass through lets a non-sudo caller drive ungated
|
|
212
|
+
// nested writes on undeclared back-relations. Mirror Keystone's
|
|
213
|
+
// GraphQL-schema behaviour and reject it. `sudo` is the single trusted
|
|
214
|
+
// bypass, so undeclared keys still pass through under sudo.
|
|
215
|
+
if (!fieldConfig) {
|
|
216
|
+
if (isSudo) {
|
|
217
|
+
filtered[fieldName] = value
|
|
218
|
+
continue
|
|
219
|
+
}
|
|
220
|
+
throw new ValidationError([
|
|
221
|
+
`Cannot ${operation} "${fieldName}": it is not a field of this list. ` +
|
|
222
|
+
`Undeclared data keys are rejected (use sudo to bypass).`,
|
|
223
|
+
])
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
// #568 — fields denied by field-level access must THROW, not be silently
|
|
227
|
+
// dropped. Keystone threw a GraphQL access error for the same situation;
|
|
228
|
+
// silently stripping the field lets a write "succeed" while doing less than
|
|
229
|
+
// asked (and skips any hook side effects gated on that field).
|
|
230
|
+
// `checkFieldAccess` already returns `true` under sudo, so sudo writes never
|
|
231
|
+
// reach the throw below — no parallel sudo path is needed here.
|
|
232
|
+
const canWrite = await checkFieldAccess(fieldConfig.access, operation, {
|
|
149
233
|
...args,
|
|
150
234
|
inputData: args.inputData,
|
|
151
235
|
})
|
|
152
236
|
|
|
153
|
-
if (canWrite) {
|
|
154
|
-
|
|
237
|
+
if (!canWrite) {
|
|
238
|
+
throw new ValidationError([`Cannot ${operation} "${fieldName}": field-level access denied.`])
|
|
155
239
|
}
|
|
240
|
+
|
|
241
|
+
filtered[fieldName] = value
|
|
156
242
|
}
|
|
157
243
|
|
|
158
244
|
return filtered as Partial<T>
|
package/src/access/index.ts
CHANGED
|
@@ -22,6 +22,6 @@ export {
|
|
|
22
22
|
// Canonical field-level access evaluation (shared by read and write paths).
|
|
23
23
|
export { checkFieldAccess, filterWritableFields } from './field-access.js'
|
|
24
24
|
// Phase 1 — Access Filter (pre-query row/relation scoping).
|
|
25
|
-
export { buildIncludeWithAccessControl } from './access-filter.js'
|
|
25
|
+
export { buildIncludeWithAccessControl, mergeIncludeWithAccessControl } from './access-filter.js'
|
|
26
26
|
// Phase 2 — Field Visibility (post-query field stripping + resolveOutput).
|
|
27
27
|
export { filterReadableFields } from './field-visibility.js'
|
package/src/access/types.ts
CHANGED
|
@@ -110,6 +110,48 @@ export interface AugmentedFindMany<TOriginal extends (...args: any[]) => any> {
|
|
|
110
110
|
(...args: Parameters<TOriginal>): ReturnType<TOriginal>
|
|
111
111
|
}
|
|
112
112
|
|
|
113
|
+
/**
|
|
114
|
+
* Extra query arguments accepted when a `query` Fragment is provided alongside
|
|
115
|
+
* `context.db.<list>.findFirst({ query: myFragment, ... })`.
|
|
116
|
+
*/
|
|
117
|
+
export type FindFirstQueryArgs = {
|
|
118
|
+
where?: Record<string, unknown>
|
|
119
|
+
orderBy?: Record<string, 'asc' | 'desc'> | Array<Record<string, 'asc' | 'desc'>>
|
|
120
|
+
skip?: number
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
/**
|
|
124
|
+
* Overloaded `findFirst` that accepts an optional `query` Fragment.
|
|
125
|
+
*
|
|
126
|
+
* `findFirst` is sugar over the access-controlled `findMany` (`take: 1`), so it
|
|
127
|
+
* applies the exact same query-access checks and access-controlled include
|
|
128
|
+
* building, then returns the first matching record or `null`.
|
|
129
|
+
*
|
|
130
|
+
* - **With `query`**: builds the Prisma `include` from the fragment, executes the
|
|
131
|
+
* query, applies access control, and returns a record shaped to `ResultOf<fragment>`
|
|
132
|
+
* or `null`.
|
|
133
|
+
* - **Without `query`**: behaves exactly like the original Prisma `findFirst`.
|
|
134
|
+
*
|
|
135
|
+
* @example
|
|
136
|
+
* ```ts
|
|
137
|
+
* const post = await context.db.post.findFirst({
|
|
138
|
+
* where: { published: true },
|
|
139
|
+
* orderBy: { createdAt: 'desc' },
|
|
140
|
+
* query: postFragment,
|
|
141
|
+
* })
|
|
142
|
+
* // post: ResultOf<typeof postFragment> | null
|
|
143
|
+
* ```
|
|
144
|
+
*/
|
|
145
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
146
|
+
export interface AugmentedFindFirst<TOriginal extends (...args: any[]) => any> {
|
|
147
|
+
// Overload 1: with query fragment — return type narrows to ResultOf<fragment> | null
|
|
148
|
+
<TItem, TFields extends FieldSelection<TItem>>(
|
|
149
|
+
args: FindFirstQueryArgs & { query: Fragment<TItem, TFields> },
|
|
150
|
+
): Promise<ResultOf<Fragment<TItem, TFields>> | null>
|
|
151
|
+
// Overload 2: original Prisma behaviour
|
|
152
|
+
(...args: Parameters<TOriginal>): ReturnType<TOriginal>
|
|
153
|
+
}
|
|
154
|
+
|
|
113
155
|
/**
|
|
114
156
|
* Overloaded `findUnique` that accepts an optional `query` Fragment.
|
|
115
157
|
*
|
|
@@ -149,6 +191,8 @@ export type AccessControlledDB<TPrisma extends PrismaClientLike> = {
|
|
|
149
191
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
150
192
|
findUnique: any
|
|
151
193
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
194
|
+
findFirst: any
|
|
195
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
152
196
|
findMany: any
|
|
153
197
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
154
198
|
create: any
|
|
@@ -161,6 +205,7 @@ export type AccessControlledDB<TPrisma extends PrismaClientLike> = {
|
|
|
161
205
|
}
|
|
162
206
|
? {
|
|
163
207
|
findUnique: AugmentedFindUnique<TPrisma[K]['findUnique']>
|
|
208
|
+
findFirst: AugmentedFindFirst<TPrisma[K]['findFirst']>
|
|
164
209
|
findMany: AugmentedFindMany<TPrisma[K]['findMany']>
|
|
165
210
|
create: TPrisma[K]['create']
|
|
166
211
|
update: TPrisma[K]['update']
|