@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.
@@ -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
  })
@@ -119,6 +119,7 @@ export type AccessContext<TPrisma extends PrismaClientLike = PrismaClientLike> =
119
119
  prisma: TPrisma
120
120
  db: AccessControlledDB<TPrisma>
121
121
  storage: StorageUtils
122
+ _isSudo: boolean
122
123
  [key: string]: unknown
123
124
  }
124
125
 
@@ -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
- const queryAccess = listConfig.access?.operation?.query
251
- const accessResult = await checkAccess(queryAccess, {
252
- session: context.session,
253
- context,
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
- if (accessResult === false) {
257
- return null
258
- }
278
+ if (accessResult === false) {
279
+ return null
280
+ }
259
281
 
260
- // Merge access filter with where clause
261
- const where = mergeFilters(args.where, accessResult)
262
- if (where === null) {
263
- return null
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
- const queryAccess = listConfig.access?.operation?.query
337
- const accessResult = await checkAccess(queryAccess, {
338
- session: context.session,
339
- context,
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
- if (accessResult === false) {
343
- return []
344
- }
369
+ if (accessResult === false) {
370
+ return []
371
+ }
345
372
 
346
- // Merge access filter with where clause
347
- const where = mergeFilters(args?.where, accessResult)
348
- if (where === null) {
349
- return []
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
- const createAccess = listConfig.access?.operation?.create
424
- const accessResult = await checkAccess(createAccess, {
425
- session: context.session,
426
- context,
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
- if (accessResult === false) {
430
- return null
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
- const updateAccess = listConfig.access?.operation?.update
553
- const accessResult = await checkAccess(updateAccess, {
554
- session: context.session,
555
- item,
556
- context,
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 (!matchesFilter) {
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
- const deleteAccess = listConfig.access?.operation?.delete
703
- const accessResult = await checkAccess(deleteAccess, {
704
- session: context.session,
705
- item,
706
- context,
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 (!matchesFilter) {
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
- const queryAccess = listConfig.access?.operation?.query
772
- const accessResult = await checkAccess(queryAccess, {
773
- session: context.session,
774
- context,
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
- if (accessResult === false) {
778
- return 0
779
- }
817
+ if (accessResult === false) {
818
+ return 0
819
+ }
780
820
 
781
- // Merge access filter with where clause
782
- const where = mergeFilters(args?.where, accessResult)
783
- if (where === null) {
784
- return 0
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