@opensaas/stack-core 0.1.6 → 0.1.7
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 +6 -0
- package/dist/access/engine.d.ts +9 -3
- package/dist/access/engine.d.ts.map +1 -1
- package/dist/access/engine.js +6 -2
- package/dist/access/engine.js.map +1 -1
- package/dist/access/types.d.ts +1 -0
- package/dist/access/types.d.ts.map +1 -1
- package/dist/context/index.d.ts +11 -1
- package/dist/context/index.d.ts.map +1 -1
- package/dist/context/index.js +116 -86
- package/dist/context/index.js.map +1 -1
- package/package.json +1 -1
- package/src/access/engine.ts +10 -5
- package/src/access/types.ts +1 -0
- package/src/context/index.ts +137 -95
- package/tests/sudo.test.ts +406 -0
- package/tsconfig.tsbuildinfo +1 -1
package/src/access/engine.ts
CHANGED
|
@@ -116,9 +116,14 @@ export async function checkFieldAccess(
|
|
|
116
116
|
args: {
|
|
117
117
|
session: Session
|
|
118
118
|
item?: Record<string, unknown>
|
|
119
|
-
context: AccessContext
|
|
119
|
+
context: AccessContext & { _isSudo?: boolean }
|
|
120
120
|
},
|
|
121
121
|
): Promise<boolean> {
|
|
122
|
+
// Skip access check in sudo mode
|
|
123
|
+
if (args.context._isSudo) {
|
|
124
|
+
return true
|
|
125
|
+
}
|
|
126
|
+
|
|
122
127
|
if (!fieldAccess) {
|
|
123
128
|
return true // No field access means allow
|
|
124
129
|
}
|
|
@@ -257,7 +262,7 @@ export async function filterReadableFields<T extends Record<string, unknown>>(
|
|
|
257
262
|
fieldConfigs: Record<string, FieldConfig>,
|
|
258
263
|
args: {
|
|
259
264
|
session: Session
|
|
260
|
-
context: AccessContext
|
|
265
|
+
context: AccessContext & { _isSudo?: boolean }
|
|
261
266
|
},
|
|
262
267
|
config?: OpenSaasConfig,
|
|
263
268
|
depth: number = 0,
|
|
@@ -275,7 +280,7 @@ export async function filterReadableFields<T extends Record<string, unknown>>(
|
|
|
275
280
|
continue
|
|
276
281
|
}
|
|
277
282
|
|
|
278
|
-
// Check field access
|
|
283
|
+
// Check field access (checkFieldAccess already handles sudo mode)
|
|
279
284
|
const canRead = await checkFieldAccess(fieldConfig?.access, 'read', {
|
|
280
285
|
...args,
|
|
281
286
|
item,
|
|
@@ -365,7 +370,7 @@ export async function filterWritableFields<T extends Record<string, unknown>>(
|
|
|
365
370
|
args: {
|
|
366
371
|
session: Session
|
|
367
372
|
item?: Record<string, unknown>
|
|
368
|
-
context: AccessContext
|
|
373
|
+
context: AccessContext & { _isSudo?: boolean }
|
|
369
374
|
},
|
|
370
375
|
): Promise<Partial<T>> {
|
|
371
376
|
const filtered: Record<string, unknown> = {}
|
|
@@ -378,7 +383,7 @@ export async function filterWritableFields<T extends Record<string, unknown>>(
|
|
|
378
383
|
continue
|
|
379
384
|
}
|
|
380
385
|
|
|
381
|
-
// Check field access
|
|
386
|
+
// Check field access (checkFieldAccess already handles sudo mode)
|
|
382
387
|
const canWrite = await checkFieldAccess(fieldConfig?.access, operation, {
|
|
383
388
|
...args,
|
|
384
389
|
})
|
package/src/access/types.ts
CHANGED
package/src/context/index.ts
CHANGED
|
@@ -146,12 +146,23 @@ export function getContext<
|
|
|
146
146
|
prisma: TPrisma,
|
|
147
147
|
session: Session,
|
|
148
148
|
storage?: StorageUtils,
|
|
149
|
+
_isSudo: boolean = false,
|
|
149
150
|
): {
|
|
150
151
|
db: AccessControlledDB<TPrisma>
|
|
151
152
|
session: Session
|
|
152
153
|
prisma: TPrisma
|
|
153
154
|
storage: StorageUtils
|
|
154
155
|
serverAction: (props: ServerActionProps) => Promise<unknown>
|
|
156
|
+
_isSudo: boolean
|
|
157
|
+
sudo: () => {
|
|
158
|
+
db: AccessControlledDB<TPrisma>
|
|
159
|
+
session: Session
|
|
160
|
+
prisma: TPrisma
|
|
161
|
+
storage: StorageUtils
|
|
162
|
+
serverAction: (props: ServerActionProps) => Promise<unknown>
|
|
163
|
+
sudo: () => unknown
|
|
164
|
+
_isSudo: boolean
|
|
165
|
+
}
|
|
155
166
|
} {
|
|
156
167
|
// Initialize db object - will be populated with access-controlled operations
|
|
157
168
|
// Type is intentionally broad to allow dynamic model access
|
|
@@ -185,6 +196,7 @@ export function getContext<
|
|
|
185
196
|
)
|
|
186
197
|
},
|
|
187
198
|
},
|
|
199
|
+
_isSudo,
|
|
188
200
|
}
|
|
189
201
|
|
|
190
202
|
// Create access-controlled operations for each list
|
|
@@ -226,12 +238,20 @@ export function getContext<
|
|
|
226
238
|
return null
|
|
227
239
|
}
|
|
228
240
|
|
|
241
|
+
// Sudo function - creates a new context that bypasses access control
|
|
242
|
+
// but still executes all hooks and validation
|
|
243
|
+
function sudo() {
|
|
244
|
+
return getContext(config, prisma, session, context.storage, true)
|
|
245
|
+
}
|
|
246
|
+
|
|
229
247
|
return {
|
|
230
248
|
db: db as AccessControlledDB<TPrisma>,
|
|
231
249
|
session,
|
|
232
250
|
prisma,
|
|
233
251
|
storage: context.storage,
|
|
234
252
|
serverAction,
|
|
253
|
+
sudo,
|
|
254
|
+
_isSudo,
|
|
235
255
|
}
|
|
236
256
|
}
|
|
237
257
|
|
|
@@ -242,25 +262,29 @@ function createFindUnique<TPrisma extends PrismaClientLike>(
|
|
|
242
262
|
listName: string,
|
|
243
263
|
listConfig: ListConfig,
|
|
244
264
|
prisma: TPrisma,
|
|
245
|
-
context: AccessContext
|
|
265
|
+
context: AccessContext<TPrisma>,
|
|
246
266
|
config: OpenSaasConfig,
|
|
247
267
|
) {
|
|
248
268
|
return async (args: { where: { id: string }; include?: Record<string, unknown> }) => {
|
|
249
|
-
// Check query access
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
269
|
+
// Check query access (skip if sudo mode)
|
|
270
|
+
let where: Record<string, unknown> = args.where
|
|
271
|
+
if (!context._isSudo) {
|
|
272
|
+
const queryAccess = listConfig.access?.operation?.query
|
|
273
|
+
const accessResult = await checkAccess(queryAccess, {
|
|
274
|
+
session: context.session,
|
|
275
|
+
context,
|
|
276
|
+
})
|
|
255
277
|
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
278
|
+
if (accessResult === false) {
|
|
279
|
+
return null
|
|
280
|
+
}
|
|
259
281
|
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
282
|
+
// Merge access filter with where clause
|
|
283
|
+
const mergedWhere = mergeFilters(args.where, accessResult)
|
|
284
|
+
if (mergedWhere === null) {
|
|
285
|
+
return null
|
|
286
|
+
}
|
|
287
|
+
where = mergedWhere
|
|
264
288
|
}
|
|
265
289
|
|
|
266
290
|
// Build include with access control filters
|
|
@@ -290,12 +314,13 @@ function createFindUnique<TPrisma extends PrismaClientLike>(
|
|
|
290
314
|
}
|
|
291
315
|
|
|
292
316
|
// Filter readable fields and apply resolveOutput hooks (including nested relationships)
|
|
317
|
+
// Pass sudo flag through context to skip field-level access checks
|
|
293
318
|
const filtered = await filterReadableFields(
|
|
294
319
|
item,
|
|
295
320
|
listConfig.fields,
|
|
296
321
|
{
|
|
297
322
|
session: context.session,
|
|
298
|
-
context,
|
|
323
|
+
context: { ...context, _isSudo: context._isSudo },
|
|
299
324
|
},
|
|
300
325
|
config,
|
|
301
326
|
0,
|
|
@@ -323,7 +348,7 @@ function createFindMany<TPrisma extends PrismaClientLike>(
|
|
|
323
348
|
listName: string,
|
|
324
349
|
listConfig: ListConfig,
|
|
325
350
|
prisma: TPrisma,
|
|
326
|
-
context: AccessContext
|
|
351
|
+
context: AccessContext<TPrisma>,
|
|
327
352
|
config: OpenSaasConfig,
|
|
328
353
|
) {
|
|
329
354
|
return async (args?: {
|
|
@@ -332,21 +357,25 @@ function createFindMany<TPrisma extends PrismaClientLike>(
|
|
|
332
357
|
skip?: number
|
|
333
358
|
include?: Record<string, unknown>
|
|
334
359
|
}) => {
|
|
335
|
-
// Check query access
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
|
|
360
|
+
// Check query access (skip if sudo mode)
|
|
361
|
+
let where: Record<string, unknown> | undefined = args?.where
|
|
362
|
+
if (!context._isSudo) {
|
|
363
|
+
const queryAccess = listConfig.access?.operation?.query
|
|
364
|
+
const accessResult = await checkAccess(queryAccess, {
|
|
365
|
+
session: context.session,
|
|
366
|
+
context,
|
|
367
|
+
})
|
|
341
368
|
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
|
|
369
|
+
if (accessResult === false) {
|
|
370
|
+
return []
|
|
371
|
+
}
|
|
345
372
|
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
|
|
373
|
+
// Merge access filter with where clause
|
|
374
|
+
const mergedWhere = mergeFilters(args?.where, accessResult)
|
|
375
|
+
if (mergedWhere === null) {
|
|
376
|
+
return []
|
|
377
|
+
}
|
|
378
|
+
where = mergedWhere
|
|
350
379
|
}
|
|
351
380
|
|
|
352
381
|
// Build include with access control filters
|
|
@@ -374,6 +403,7 @@ function createFindMany<TPrisma extends PrismaClientLike>(
|
|
|
374
403
|
})
|
|
375
404
|
|
|
376
405
|
// Filter readable fields for each item and apply resolveOutput hooks (including nested relationships)
|
|
406
|
+
// Pass sudo flag through context to skip field-level access checks
|
|
377
407
|
const filtered = await Promise.all(
|
|
378
408
|
items.map((item: Record<string, unknown>) =>
|
|
379
409
|
filterReadableFields(
|
|
@@ -381,7 +411,7 @@ function createFindMany<TPrisma extends PrismaClientLike>(
|
|
|
381
411
|
listConfig.fields,
|
|
382
412
|
{
|
|
383
413
|
session: context.session,
|
|
384
|
-
context,
|
|
414
|
+
context: { ...context, _isSudo: context._isSudo },
|
|
385
415
|
},
|
|
386
416
|
config,
|
|
387
417
|
0,
|
|
@@ -415,19 +445,21 @@ function createCreate<TPrisma extends PrismaClientLike>(
|
|
|
415
445
|
listName: string,
|
|
416
446
|
listConfig: ListConfig,
|
|
417
447
|
prisma: TPrisma,
|
|
418
|
-
context: AccessContext
|
|
448
|
+
context: AccessContext<TPrisma>,
|
|
419
449
|
config: OpenSaasConfig,
|
|
420
450
|
) {
|
|
421
451
|
return async (args: { data: Record<string, unknown> }) => {
|
|
422
|
-
// 1. Check create access
|
|
423
|
-
|
|
424
|
-
|
|
425
|
-
|
|
426
|
-
|
|
427
|
-
|
|
452
|
+
// 1. Check create access (skip if sudo mode)
|
|
453
|
+
if (!context._isSudo) {
|
|
454
|
+
const createAccess = listConfig.access?.operation?.create
|
|
455
|
+
const accessResult = await checkAccess(createAccess, {
|
|
456
|
+
session: context.session,
|
|
457
|
+
context,
|
|
458
|
+
})
|
|
428
459
|
|
|
429
|
-
|
|
430
|
-
|
|
460
|
+
if (accessResult === false) {
|
|
461
|
+
return null
|
|
462
|
+
}
|
|
431
463
|
}
|
|
432
464
|
|
|
433
465
|
// 2. Execute list-level resolveInput hook
|
|
@@ -459,10 +491,10 @@ function createCreate<TPrisma extends PrismaClientLike>(
|
|
|
459
491
|
throw new ValidationError(validation.errors, validation.fieldErrors)
|
|
460
492
|
}
|
|
461
493
|
|
|
462
|
-
// 5. Filter writable fields (field-level access control)
|
|
494
|
+
// 5. Filter writable fields (field-level access control, skip if sudo mode)
|
|
463
495
|
const filteredData = await filterWritableFields(resolvedData, listConfig.fields, 'create', {
|
|
464
496
|
session: context.session,
|
|
465
|
-
context,
|
|
497
|
+
context: { ...context, _isSudo: context._isSudo },
|
|
466
498
|
})
|
|
467
499
|
|
|
468
500
|
// 5.5. Process nested relationship operations
|
|
@@ -509,12 +541,13 @@ function createCreate<TPrisma extends PrismaClientLike>(
|
|
|
509
541
|
)
|
|
510
542
|
|
|
511
543
|
// 11. Filter readable fields and apply resolveOutput hooks (including nested relationships)
|
|
544
|
+
// Pass sudo flag through context to skip field-level access checks
|
|
512
545
|
const filtered = await filterReadableFields(
|
|
513
546
|
item,
|
|
514
547
|
listConfig.fields,
|
|
515
548
|
{
|
|
516
549
|
session: context.session,
|
|
517
|
-
context,
|
|
550
|
+
context: { ...context, _isSudo: context._isSudo },
|
|
518
551
|
},
|
|
519
552
|
config,
|
|
520
553
|
0,
|
|
@@ -532,7 +565,7 @@ function createUpdate<TPrisma extends PrismaClientLike>(
|
|
|
532
565
|
listName: string,
|
|
533
566
|
listConfig: ListConfig,
|
|
534
567
|
prisma: TPrisma,
|
|
535
|
-
context: AccessContext
|
|
568
|
+
context: AccessContext<TPrisma>,
|
|
536
569
|
config: OpenSaasConfig,
|
|
537
570
|
) {
|
|
538
571
|
return async (args: { where: { id: string }; data: Record<string, unknown> }) => {
|
|
@@ -548,27 +581,29 @@ function createUpdate<TPrisma extends PrismaClientLike>(
|
|
|
548
581
|
return null
|
|
549
582
|
}
|
|
550
583
|
|
|
551
|
-
// 2. Check update access
|
|
552
|
-
|
|
553
|
-
|
|
554
|
-
|
|
555
|
-
|
|
556
|
-
|
|
557
|
-
|
|
558
|
-
|
|
559
|
-
if (accessResult === false) {
|
|
560
|
-
return null
|
|
561
|
-
}
|
|
562
|
-
|
|
563
|
-
// If access returns a filter, check if item matches
|
|
564
|
-
if (typeof accessResult === 'object') {
|
|
565
|
-
const matchesFilter = await model.findFirst({
|
|
566
|
-
where: mergeFilters(args.where, accessResult),
|
|
584
|
+
// 2. Check update access (skip if sudo mode)
|
|
585
|
+
if (!context._isSudo) {
|
|
586
|
+
const updateAccess = listConfig.access?.operation?.update
|
|
587
|
+
const accessResult = await checkAccess(updateAccess, {
|
|
588
|
+
session: context.session,
|
|
589
|
+
item,
|
|
590
|
+
context,
|
|
567
591
|
})
|
|
568
592
|
|
|
569
|
-
if (
|
|
593
|
+
if (accessResult === false) {
|
|
570
594
|
return null
|
|
571
595
|
}
|
|
596
|
+
|
|
597
|
+
// If access returns a filter, check if item matches
|
|
598
|
+
if (typeof accessResult === 'object') {
|
|
599
|
+
const matchesFilter = await model.findFirst({
|
|
600
|
+
where: mergeFilters(args.where, accessResult),
|
|
601
|
+
})
|
|
602
|
+
|
|
603
|
+
if (!matchesFilter) {
|
|
604
|
+
return null
|
|
605
|
+
}
|
|
606
|
+
}
|
|
572
607
|
}
|
|
573
608
|
|
|
574
609
|
// 3. Execute list-level resolveInput hook
|
|
@@ -603,11 +638,11 @@ function createUpdate<TPrisma extends PrismaClientLike>(
|
|
|
603
638
|
throw new ValidationError(validation.errors, validation.fieldErrors)
|
|
604
639
|
}
|
|
605
640
|
|
|
606
|
-
// 6. Filter writable fields (field-level access control)
|
|
641
|
+
// 6. Filter writable fields (field-level access control, skip if sudo mode)
|
|
607
642
|
const filteredData = await filterWritableFields(resolvedData, listConfig.fields, 'update', {
|
|
608
643
|
session: context.session,
|
|
609
644
|
item,
|
|
610
|
-
context,
|
|
645
|
+
context: { ...context, _isSudo: context._isSudo },
|
|
611
646
|
})
|
|
612
647
|
|
|
613
648
|
// 6.5. Process nested relationship operations
|
|
@@ -660,12 +695,13 @@ function createUpdate<TPrisma extends PrismaClientLike>(
|
|
|
660
695
|
)
|
|
661
696
|
|
|
662
697
|
// 12. Filter readable fields and apply resolveOutput hooks (including nested relationships)
|
|
698
|
+
// Pass sudo flag through context to skip field-level access checks
|
|
663
699
|
const filtered = await filterReadableFields(
|
|
664
700
|
updated,
|
|
665
701
|
listConfig.fields,
|
|
666
702
|
{
|
|
667
703
|
session: context.session,
|
|
668
|
-
context,
|
|
704
|
+
context: { ...context, _isSudo: context._isSudo },
|
|
669
705
|
},
|
|
670
706
|
config,
|
|
671
707
|
0,
|
|
@@ -683,7 +719,7 @@ function createDelete<TPrisma extends PrismaClientLike>(
|
|
|
683
719
|
listName: string,
|
|
684
720
|
listConfig: ListConfig,
|
|
685
721
|
prisma: TPrisma,
|
|
686
|
-
context: AccessContext
|
|
722
|
+
context: AccessContext<TPrisma>,
|
|
687
723
|
) {
|
|
688
724
|
return async (args: { where: { id: string } }) => {
|
|
689
725
|
// 1. Fetch the item to pass to access control and hooks
|
|
@@ -698,27 +734,29 @@ function createDelete<TPrisma extends PrismaClientLike>(
|
|
|
698
734
|
return null
|
|
699
735
|
}
|
|
700
736
|
|
|
701
|
-
// 2. Check delete access
|
|
702
|
-
|
|
703
|
-
|
|
704
|
-
|
|
705
|
-
|
|
706
|
-
|
|
707
|
-
|
|
708
|
-
|
|
709
|
-
if (accessResult === false) {
|
|
710
|
-
return null
|
|
711
|
-
}
|
|
712
|
-
|
|
713
|
-
// If access returns a filter, check if item matches
|
|
714
|
-
if (typeof accessResult === 'object') {
|
|
715
|
-
const matchesFilter = await model.findFirst({
|
|
716
|
-
where: mergeFilters(args.where, accessResult),
|
|
737
|
+
// 2. Check delete access (skip if sudo mode)
|
|
738
|
+
if (!context._isSudo) {
|
|
739
|
+
const deleteAccess = listConfig.access?.operation?.delete
|
|
740
|
+
const accessResult = await checkAccess(deleteAccess, {
|
|
741
|
+
session: context.session,
|
|
742
|
+
item,
|
|
743
|
+
context,
|
|
717
744
|
})
|
|
718
745
|
|
|
719
|
-
if (
|
|
746
|
+
if (accessResult === false) {
|
|
720
747
|
return null
|
|
721
748
|
}
|
|
749
|
+
|
|
750
|
+
// If access returns a filter, check if item matches
|
|
751
|
+
if (typeof accessResult === 'object') {
|
|
752
|
+
const matchesFilter = await model.findFirst({
|
|
753
|
+
where: mergeFilters(args.where, accessResult),
|
|
754
|
+
})
|
|
755
|
+
|
|
756
|
+
if (!matchesFilter) {
|
|
757
|
+
return null
|
|
758
|
+
}
|
|
759
|
+
}
|
|
722
760
|
}
|
|
723
761
|
|
|
724
762
|
// 3. Execute field-level beforeOperation hooks (side effects only)
|
|
@@ -764,24 +802,28 @@ function createCount<TPrisma extends PrismaClientLike>(
|
|
|
764
802
|
listName: string,
|
|
765
803
|
listConfig: ListConfig,
|
|
766
804
|
prisma: TPrisma,
|
|
767
|
-
context: AccessContext
|
|
805
|
+
context: AccessContext<TPrisma>,
|
|
768
806
|
) {
|
|
769
807
|
return async (args?: { where?: Record<string, unknown> }) => {
|
|
770
|
-
// Check query access
|
|
771
|
-
|
|
772
|
-
|
|
773
|
-
|
|
774
|
-
|
|
775
|
-
|
|
808
|
+
// Check query access (skip if sudo mode)
|
|
809
|
+
let where: Record<string, unknown> | undefined = args?.where
|
|
810
|
+
if (!context._isSudo) {
|
|
811
|
+
const queryAccess = listConfig.access?.operation?.query
|
|
812
|
+
const accessResult = await checkAccess(queryAccess, {
|
|
813
|
+
session: context.session,
|
|
814
|
+
context,
|
|
815
|
+
})
|
|
776
816
|
|
|
777
|
-
|
|
778
|
-
|
|
779
|
-
|
|
817
|
+
if (accessResult === false) {
|
|
818
|
+
return 0
|
|
819
|
+
}
|
|
780
820
|
|
|
781
|
-
|
|
782
|
-
|
|
783
|
-
|
|
784
|
-
|
|
821
|
+
// Merge access filter with where clause
|
|
822
|
+
const mergedWhere = mergeFilters(args?.where, accessResult)
|
|
823
|
+
if (mergedWhere === null) {
|
|
824
|
+
return 0
|
|
825
|
+
}
|
|
826
|
+
where = mergedWhere
|
|
785
827
|
}
|
|
786
828
|
|
|
787
829
|
// Execute count
|