@open-mercato/core 0.10.1-develop.7752.1.fd7c06569b → 0.10.1-develop.7756.1.8f5dec1b96

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.
Files changed (55) hide show
  1. package/.turbo/turbo-build.log +1 -1
  2. package/dist/helpers/integration/dbFixtures.js +1 -1
  3. package/dist/helpers/integration/dbFixtures.js.map +1 -1
  4. package/dist/modules/auth/api/roles/acl/route.js +2 -2
  5. package/dist/modules/auth/api/roles/acl/route.js.map +2 -2
  6. package/dist/modules/auth/api/roles/route.js +1 -1
  7. package/dist/modules/auth/api/roles/route.js.map +2 -2
  8. package/dist/modules/auth/api/users/acl/route.js +10 -2
  9. package/dist/modules/auth/api/users/acl/route.js.map +2 -2
  10. package/dist/modules/auth/commands/acl.js +2 -2
  11. package/dist/modules/auth/commands/acl.js.map +2 -2
  12. package/dist/modules/auth/commands/roles.js +1 -1
  13. package/dist/modules/auth/commands/roles.js.map +2 -2
  14. package/dist/modules/auth/commands/users.js +7 -3
  15. package/dist/modules/auth/commands/users.js.map +2 -2
  16. package/dist/modules/auth/data/entities.js.map +2 -2
  17. package/dist/modules/auth/lib/executionPrincipal.js +2 -2
  18. package/dist/modules/auth/lib/executionPrincipal.js.map +2 -2
  19. package/dist/modules/auth/lib/grantChecks.js +38 -11
  20. package/dist/modules/auth/lib/grantChecks.js.map +2 -2
  21. package/dist/modules/auth/lib/setup-app.js +2 -2
  22. package/dist/modules/auth/lib/setup-app.js.map +2 -2
  23. package/dist/modules/auth/migrations/Migration20260828120000_auth.js +45 -0
  24. package/dist/modules/auth/migrations/Migration20260828120000_auth.js.map +7 -0
  25. package/dist/modules/auth/services/rbacService.js +138 -59
  26. package/dist/modules/auth/services/rbacService.js.map +3 -3
  27. package/dist/modules/entities/cli.js +12 -1
  28. package/dist/modules/entities/cli.js.map +2 -2
  29. package/dist/modules/entities/lib/helpers.js +13 -0
  30. package/dist/modules/entities/lib/helpers.js.map +2 -2
  31. package/dist/modules/entities/lib/rotate-custom-entity-documents.js +157 -0
  32. package/dist/modules/entities/lib/rotate-custom-entity-documents.js.map +7 -0
  33. package/dist/modules/notifications/lib/notificationRecipients.js +2 -2
  34. package/dist/modules/notifications/lib/notificationRecipients.js.map +2 -2
  35. package/dist/modules/query_index/lib/engine.js +179 -11
  36. package/dist/modules/query_index/lib/engine.js.map +3 -3
  37. package/package.json +7 -7
  38. package/src/helpers/integration/dbFixtures.ts +1 -1
  39. package/src/modules/auth/api/roles/acl/route.ts +2 -2
  40. package/src/modules/auth/api/roles/route.ts +1 -1
  41. package/src/modules/auth/api/users/acl/route.ts +10 -2
  42. package/src/modules/auth/commands/acl.ts +2 -2
  43. package/src/modules/auth/commands/roles.ts +1 -1
  44. package/src/modules/auth/commands/users.ts +7 -3
  45. package/src/modules/auth/data/entities.ts +12 -0
  46. package/src/modules/auth/lib/executionPrincipal.ts +2 -2
  47. package/src/modules/auth/lib/grantChecks.ts +115 -11
  48. package/src/modules/auth/lib/setup-app.ts +2 -2
  49. package/src/modules/auth/migrations/Migration20260828120000_auth.ts +70 -0
  50. package/src/modules/auth/services/rbacService.ts +161 -76
  51. package/src/modules/entities/cli.ts +12 -1
  52. package/src/modules/entities/lib/helpers.ts +17 -0
  53. package/src/modules/entities/lib/rotate-custom-entity-documents.ts +200 -0
  54. package/src/modules/notifications/lib/notificationRecipients.ts +6 -0
  55. package/src/modules/query_index/lib/engine.ts +187 -9
@@ -1,7 +1,7 @@
1
1
  import type { EntityManager, FilterQuery } from '@mikro-orm/postgresql'
2
2
  import type { CacheStrategy } from '@open-mercato/cache'
3
3
  import { getCurrentCacheTenant, runWithCacheTenant } from '@open-mercato/cache'
4
- import { UserAcl, RoleAcl, User, UserRole } from '@open-mercato/core/modules/auth/data/entities'
4
+ import { UserAcl, RoleAcl, Role, User, UserRole } from '@open-mercato/core/modules/auth/data/entities'
5
5
  import { ApiKey } from '@open-mercato/core/modules/api_keys/data/entities'
6
6
  import { isSessionBoundApiKey, resolveApiKeyRoleIds } from '@open-mercato/core/modules/api_keys/services/sessionKeyRoles'
7
7
  import { Organization } from '@open-mercato/core/modules/directory/data/entities'
@@ -14,6 +14,9 @@ import {
14
14
  } from '@open-mercato/shared/security/featurePolicy'
15
15
  import { filterGrantsByEnabledModules } from '@open-mercato/shared/security/enabledModulesRegistry'
16
16
  import { resolveRoleOrganizationScope, roleAclAllowsOrganization } from './roleOrganizationScope'
17
+ import { createLogger } from '@open-mercato/shared/lib/logger'
18
+
19
+ const logger = createLogger('auth.rbac')
17
20
 
18
21
  interface AclData {
19
22
  isSuperAdmin: boolean
@@ -381,39 +384,89 @@ export class RbacService {
381
384
  }
382
385
  }
383
386
 
384
- private async isGlobalSuperAdmin(userId: string): Promise<boolean> {
385
- if (this.globalSuperAdminCache.has(userId)) return this.globalSuperAdminCache.get(userId)!
386
- const em = this.em.fork()
387
- const userSuper = await em.findOne(UserAcl, { user: userId as any, isSuperAdmin: true })
388
- if (userSuper && (userSuper as any).isSuperAdmin) {
389
- this.globalSuperAdminCache.set(userId, true)
387
+ // A super-admin grant is deliberately GLOBAL: it survives every scope the
388
+ // caller asks about, which is what lets a platform super-admin work inside a
389
+ // customer tenant. What it must not do is survive the question of whose grant
390
+ // it is. Both lookups below are therefore evaluated against the user's OWN
391
+ // tenant (`users.tenant_id`), never against no tenant at all.
392
+ //
393
+ // Without that binding a single `user_acls` row — or a role link — stamped
394
+ // with ANY tenant confers platform super-admin everywhere, so a row written
395
+ // under the wrong tenant becomes a full privilege escalation with nothing in
396
+ // the schema or the UI to show for it. `user_acls` has no organization column
397
+ // and no uniqueness beyond its primary key, so such a row is cheap to create
398
+ // and invisible to the tenant/organization cross-wire checks.
399
+ //
400
+ // This is the tenant binding `resolveCanonicalStaffAuthContext` already
401
+ // applies when it computes `auth.isSuperAdmin` (see lib/sessionIntegrity.ts:
402
+ // `userAclGrantsSuperAdmin` / `roleAclGrantsSuperAdmin`, both bound to the
403
+ // user's own tenant). Before this change the two authorities disagreed:
404
+ // session integrity would say "not a super-admin" while `loadAcl` handed the
405
+ // same request `['*']`.
406
+ //
407
+ // The memo stays keyed by `userId` alone because the answer now depends only
408
+ // on the user's own tenant, which is a property of the user — not on the scope
409
+ // being asked about. `invalidateUserCache` therefore still clears it correctly.
410
+ private async isGlobalSuperAdmin(
411
+ em: EntityManager,
412
+ userId: string,
413
+ userTenantId: string | null,
414
+ useCache = true,
415
+ ): Promise<boolean> {
416
+ if (useCache && this.globalSuperAdminCache.has(userId)) return this.globalSuperAdminCache.get(userId)!
417
+ if (!userTenantId) {
418
+ logger.warn('Tenant-less principal cannot receive super-admin grants; assign an owning tenant before upgrading', { userId })
419
+ if (useCache) this.globalSuperAdminCache.set(userId, false)
420
+ return false
421
+ }
422
+ const userSuper = await em.findOne(UserAcl, {
423
+ user: userId as unknown as User,
424
+ tenantId: userTenantId,
425
+ isSuperAdmin: true,
426
+ deletedAt: null,
427
+ } as FilterQuery<UserAcl>)
428
+ if (userSuper?.isSuperAdmin) {
429
+ if (useCache) this.globalSuperAdminCache.set(userId, true)
390
430
  return true
391
431
  }
392
432
  const links = await findWithDecryption(
393
433
  em,
394
434
  UserRole,
395
- { user: userId as any },
435
+ // Roles that belong to the user's own tenant, as
436
+ // `resolveCanonicalStaffAuthContext` already requires. The encryption
437
+ // scope stays `{ null, null }` as before — this change is about which rows
438
+ // count, not about which key decrypts them.
439
+ {
440
+ user: userId as unknown as User,
441
+ deletedAt: null,
442
+ role: { tenantId: userTenantId, deletedAt: null },
443
+ } as FilterQuery<UserRole>,
396
444
  { populate: ['role'] },
397
445
  { tenantId: null, organizationId: null },
398
446
  )
399
447
  const linkList = Array.isArray(links) ? links : []
400
448
  if (!linkList.length) {
401
- this.globalSuperAdminCache.set(userId, false)
449
+ if (useCache) this.globalSuperAdminCache.set(userId, false)
402
450
  return false
403
451
  }
404
452
  const roleIds = Array.from(new Set(linkList.map((link) => {
405
- const role = link.role as any
453
+ const role = link.role
406
454
  return role?.id ? String(role.id) : null
407
455
  }).filter((id): id is string => typeof id === 'string' && id.length > 0)))
408
456
  if (!roleIds.length) {
409
- this.globalSuperAdminCache.set(userId, false)
457
+ if (useCache) this.globalSuperAdminCache.set(userId, false)
410
458
  return false
411
459
  }
412
- const roleSupers = await em.find(RoleAcl, { isSuperAdmin: true, role: { $in: roleIds as any } } as any)
460
+ const roleSupers = await em.find(RoleAcl, {
461
+ isSuperAdmin: true,
462
+ tenantId: userTenantId,
463
+ deletedAt: null,
464
+ role: { $in: roleIds as unknown as Role[] },
465
+ } as FilterQuery<RoleAcl>)
413
466
  const result = roleSupers.some((roleAcl) => (
414
467
  !!roleAcl.isSuperAdmin && !isRestrictedRoleAcl(roleAcl)
415
468
  ))
416
- this.globalSuperAdminCache.set(userId, result)
469
+ if (useCache) this.globalSuperAdminCache.set(userId, result)
417
470
  return result
418
471
  }
419
472
 
@@ -447,18 +500,6 @@ export class RbacService {
447
500
  const cached = userId.startsWith('api_key:') ? null : await this.getFromCache(cacheKey)
448
501
  if (cached) return cached
449
502
 
450
- // Direct user-level super-admin grants and unrestricted role-level
451
- // super-admin grants are global. Organization-restricted role grants are
452
- // deliberately excluded by isGlobalSuperAdmin and continue through the
453
- // scoped ACL projection below.
454
- if (!userId.startsWith('api_key:')) {
455
- if (await this.isGlobalSuperAdmin(userId)) {
456
- const result = { isSuperAdmin: true, features: ['*'], organizations: null }
457
- await this.setCache(cacheKey, result, userId, scope)
458
- return result
459
- }
460
- }
461
-
462
503
  if (userId.startsWith('api_key:')) {
463
504
  const apiKeyId = userId.slice('api_key:'.length)
464
505
  const em = this.em.fork()
@@ -489,7 +530,11 @@ export class RbacService {
489
530
  tenantId,
490
531
  evaluatedOrganizationId,
491
532
  )
492
- const racls = await em.find(RoleAcl, { tenantId, role: { $in: roleIds as any } } as any)
533
+ const racls = await em.find(RoleAcl, {
534
+ tenantId,
535
+ deletedAt: null,
536
+ role: { $in: roleIds as unknown as Role[], deletedAt: null },
537
+ } as FilterQuery<RoleAcl>)
493
538
  for (const acl of racls) {
494
539
  if (roleAclAllowsOrganization(acl, roleOrganizationScope)) {
495
540
  isSuper = isSuper || !!acl.isSuperAdmin
@@ -552,6 +597,23 @@ export class RbacService {
552
597
  await this.setCache(cacheKey, result, userId, scope)
553
598
  return result
554
599
  }
600
+
601
+ // Direct user-level super-admin grants and unrestricted role-level
602
+ // super-admin grants are global. Organization-restricted role grants are
603
+ // deliberately excluded by isGlobalSuperAdmin and continue through the
604
+ // scoped ACL projection below.
605
+ //
606
+ // The check runs AFTER the user is loaded because it is evaluated against
607
+ // the user's own tenant — see isGlobalSuperAdmin. It reuses that load and
608
+ // that EntityManager rather than issuing its own, so the reordering costs
609
+ // no extra query. API-key principals never reached it and still do not:
610
+ // their branch above has already returned.
611
+ if (await this.isGlobalSuperAdmin(em, userId, user.tenantId ?? null)) {
612
+ const result = { isSuperAdmin: true, features: ['*'], organizations: null }
613
+ await this.setCache(cacheKey, result, userId, scope)
614
+ return result
615
+ }
616
+
555
617
  const tenantId = scope.tenantId || user.tenantId || null
556
618
  const orgId = scope.organizationId || user.organizationId || null
557
619
 
@@ -561,8 +623,25 @@ export class RbacService {
561
623
  return result
562
624
  }
563
625
 
564
- // Per-user ACL first
565
- const uacl = await em.findOne(UserAcl, { user: userId as any, tenantId })
626
+ // Per-user ACL first. This row REPLACES the role ACLs rather than merging
627
+ // with them, so which row is returned decides the whole feature set. A
628
+ // partial unique index now makes a second live row impossible
629
+ // (`user_acls_active_unique_idx`, Migration20260828120000_auth), but the
630
+ // ordering is stated anyway: an index can be dropped by an operator, and an
631
+ // unordered `findOne` over a table that used to allow duplicates is exactly
632
+ // the read this pairs with. Soft-deleted rows are excluded — a revoked
633
+ // override must not keep answering.
634
+ const uacl = await em.findOne(
635
+ UserAcl,
636
+ { user: userId as unknown as User, tenantId, deletedAt: null } as FilterQuery<UserAcl>,
637
+ // `createdAt`/`id`, not `updatedAt`: `updated_at` is nullable and Postgres
638
+ // sorts NULLs FIRST under DESC, so ordering by it would prefer a row that
639
+ // was never updated. The migration's one-time collapse uses
640
+ // `coalesce(updated_at, created_at)` because there it is picking the row
641
+ // an operator would call current; here the only requirement is that the
642
+ // answer not vary between requests.
643
+ { orderBy: { createdAt: 'desc', id: 'desc' } } as const,
644
+ )
566
645
  if (uacl) {
567
646
  const result = {
568
647
  isSuperAdmin: !!uacl.isSuperAdmin,
@@ -577,12 +656,18 @@ export class RbacService {
577
656
  const links = await findWithDecryption(
578
657
  em,
579
658
  UserRole,
580
- { user: userId as any, role: { tenantId } } as any,
659
+ {
660
+ user: userId as unknown as User,
661
+ deletedAt: null,
662
+ role: { tenantId, deletedAt: null },
663
+ } as FilterQuery<UserRole>,
581
664
  { populate: ['role'] },
582
665
  { tenantId, organizationId: orgId },
583
666
  )
584
667
  const linkList = Array.isArray(links) ? links : []
585
- const roleIds = linkList.map((l) => (l.role as any)?.id).filter(Boolean)
668
+ const roleIds = linkList
669
+ .map((link) => link.role?.id ? String(link.role.id) : null)
670
+ .filter((roleId): roleId is string => roleId !== null)
586
671
  let isSuper = false
587
672
  const features: string[] = []
588
673
  let organizations: string[] | null = []
@@ -593,7 +678,11 @@ export class RbacService {
593
678
  tenantId,
594
679
  scope.organizationId,
595
680
  )
596
- const racls = await em.find(RoleAcl, { tenantId, role: { $in: roleIds as any } } as any, {})
681
+ const racls = await em.find(RoleAcl, {
682
+ tenantId,
683
+ deletedAt: null,
684
+ role: { $in: roleIds as unknown as Role[] },
685
+ } as FilterQuery<RoleAcl>, {})
597
686
  const roleAcls = Array.isArray(racls) ? racls : []
598
687
  for (const r of roleAcls) {
599
688
  if (roleAclAllowsOrganization(r, roleOrganizationScope)) {
@@ -662,7 +751,7 @@ export class RbacService {
662
751
  evaluatedOrganizationId,
663
752
  )
664
753
  const roleAcls = tenantId && roleIds.length
665
- ? await em.find(RoleAcl, { tenantId, role: { $in: roleIds as never } } as never)
754
+ ? await em.find(RoleAcl, { tenantId, deletedAt: null, role: { $in: roleIds as never, deletedAt: null } } as never)
666
755
  : []
667
756
  let isSuperAdmin = false
668
757
  const features: string[] = []
@@ -709,43 +798,22 @@ export class RbacService {
709
798
  return { isSuperAdmin: isSuperAdmin && organizations === null, features, organizations }
710
799
  }
711
800
 
712
- const directGlobalGrant = await em.findOne(
713
- UserAcl,
714
- { user: userId as never, isSuperAdmin: true } as never,
801
+ const user = await findOneWithDecryption(
802
+ em, User, { id: userId }, {}, { tenantId: null, organizationId: null },
715
803
  )
716
- if (directGlobalGrant?.isSuperAdmin) {
804
+ if (!user) return { isSuperAdmin: false, features: [], organizations: null }
805
+ if (await this.isGlobalSuperAdmin(em, userId, user.tenantId ?? null, false)) {
717
806
  return { isSuperAdmin: true, features: ['*'], organizations: null }
718
807
  }
719
- const globalLinks = await findWithDecryption(
720
- em,
721
- UserRole,
722
- { user: userId as never },
723
- { populate: ['role'] },
724
- { tenantId: null, organizationId: null },
725
- )
726
- const globalRoleIds = globalLinks
727
- .map((link) => typeof link.role === 'string' ? link.role : link.role?.id ? String(link.role.id) : null)
728
- .filter((roleId): roleId is string => typeof roleId === 'string' && roleId.length > 0)
729
- if (globalRoleIds.length) {
730
- const globalRoleGrants = await em.find(
731
- RoleAcl,
732
- {
733
- role: { $in: globalRoleIds as never },
734
- isSuperAdmin: true,
735
- } as never,
736
- )
737
- if (globalRoleGrants.some((grant) => grant.isSuperAdmin && !isRestrictedRoleAcl(grant))) {
738
- return { isSuperAdmin: true, features: ['*'], organizations: null }
739
- }
740
- }
741
-
742
- const user = await em.findOne(User, { id: userId })
743
- if (!user) return { isSuperAdmin: false, features: [], organizations: null }
744
808
  const tenantId = scope.tenantId || user.tenantId || null
745
809
  const organizationId = scope.organizationId || user.organizationId || null
746
810
  if (!tenantId) return { isSuperAdmin: false, features: [], organizations: null }
747
811
 
748
- const userAcl = await em.findOne(UserAcl, { user: userId as never, tenantId } as never)
812
+ const userAcl = await em.findOne(
813
+ UserAcl,
814
+ { user: userId as never, tenantId, deletedAt: null } as FilterQuery<UserAcl>,
815
+ { orderBy: { createdAt: 'desc', id: 'desc' } },
816
+ )
749
817
  if (userAcl) {
750
818
  return {
751
819
  isSuperAdmin: userAcl.isSuperAdmin === true,
@@ -757,7 +825,7 @@ export class RbacService {
757
825
  const links = await findWithDecryption(
758
826
  em,
759
827
  UserRole,
760
- { user: userId as never, role: { tenantId } } as never,
828
+ { user: userId as never, deletedAt: null, role: { tenantId, deletedAt: null } } as never,
761
829
  { populate: ['role'] },
762
830
  { tenantId, organizationId },
763
831
  )
@@ -765,7 +833,7 @@ export class RbacService {
765
833
  .map((link) => typeof link.role === 'string' ? link.role : link.role?.id ? String(link.role.id) : null)
766
834
  .filter((roleId): roleId is string => typeof roleId === 'string' && roleId.length > 0)
767
835
  const roleAcls = roleIds.length
768
- ? await em.find(RoleAcl, { tenantId, role: { $in: roleIds as never } } as never)
836
+ ? await em.find(RoleAcl, { tenantId, deletedAt: null, role: { $in: roleIds as never, deletedAt: null } } as never)
769
837
  : []
770
838
  const roleScope = await resolveRoleOrganizationScopeWithEntityManager(
771
839
  em,
@@ -862,7 +930,7 @@ export class RbacService {
862
930
  tenantId,
863
931
  opts?.organizationId,
864
932
  )
865
- const roleAcls = await em.find(RoleAcl, { tenantId, deletedAt: null } as any, {})
933
+ const roleAcls = await em.find(RoleAcl, { tenantId, deletedAt: null } as FilterQuery<RoleAcl>, {})
866
934
  const list = Array.isArray(roleAcls) ? roleAcls : []
867
935
 
868
936
  for (const acl of list) {
@@ -939,12 +1007,21 @@ export class RbacService {
939
1007
  const denyAll = () => []
940
1008
  if (!required.length) return { unrestricted: true, filterOrganizationIds: allowAll }
941
1009
  if (!input.tenantId) return { unrestricted: false, filterOrganizationIds: denyAll }
942
- if (
943
- !userId.startsWith('api_key:')
944
- && await this.isGlobalSuperAdmin(userId)
945
- && authorizeFeatures(required, { grantedFeatures: ['*'], unrestricted: true })
946
- ) {
947
- return { unrestricted: true, filterOrganizationIds: allowAll }
1010
+ if (!userId.startsWith('api_key:')) {
1011
+ let globalSuperAdmin = this.globalSuperAdminCache.get(userId)
1012
+ if (globalSuperAdmin === undefined) {
1013
+ const em = this.em.fork()
1014
+ const user = await findOneWithDecryption(
1015
+ em, User, { id: userId }, {}, { tenantId: null, organizationId: null },
1016
+ )
1017
+ globalSuperAdmin = !!user && await this.isGlobalSuperAdmin(em, userId, user.tenantId ?? null)
1018
+ }
1019
+ if (
1020
+ globalSuperAdmin
1021
+ && authorizeFeatures(required, { grantedFeatures: ['*'], unrestricted: true })
1022
+ ) {
1023
+ return { unrestricted: true, filterOrganizationIds: allowAll }
1024
+ }
948
1025
  }
949
1026
 
950
1027
  const grants = await this.loadFeatureOrganizationGrants(userId, input.tenantId)
@@ -1056,7 +1133,8 @@ export class RbacService {
1056
1133
  if (!roleIds.length) return { kind: 'none' }
1057
1134
  const roleAcls = await em.find(RoleAcl, {
1058
1135
  tenantId,
1059
- role: { $in: roleIds },
1136
+ deletedAt: null,
1137
+ role: { $in: roleIds, deletedAt: null },
1060
1138
  })
1061
1139
  const keyOrganizationId = typeof key.organizationId === 'string' && key.organizationId.trim().length > 0
1062
1140
  ? key.organizationId.trim()
@@ -1068,10 +1146,16 @@ export class RbacService {
1068
1146
  }
1069
1147
  }
1070
1148
 
1071
- const user = await em.findOne(User, { id: userId })
1149
+ const user = await findOneWithDecryption(
1150
+ em, User, { id: userId }, {}, { tenantId: null, organizationId: null },
1151
+ )
1072
1152
  if (!user) return { kind: 'none' }
1073
1153
 
1074
- const userAcl = await em.findOne(UserAcl, { user: userId, tenantId })
1154
+ const userAcl = await em.findOne(
1155
+ UserAcl,
1156
+ { user: userId, tenantId, deletedAt: null },
1157
+ { orderBy: { createdAt: 'desc', id: 'desc' } },
1158
+ )
1075
1159
  if (userAcl) {
1076
1160
  return {
1077
1161
  kind: 'user_acl',
@@ -1084,7 +1168,7 @@ export class RbacService {
1084
1168
  const links = await findWithDecryption(
1085
1169
  em,
1086
1170
  UserRole,
1087
- { user: userId, role: { tenantId } },
1171
+ { user: userId, deletedAt: null, role: { tenantId, deletedAt: null } },
1088
1172
  { populate: ['role'] },
1089
1173
  { tenantId, organizationId: null },
1090
1174
  )
@@ -1095,7 +1179,8 @@ export class RbacService {
1095
1179
 
1096
1180
  const roleAcls = await em.find(RoleAcl, {
1097
1181
  tenantId,
1098
- role: { $in: roleIds },
1182
+ deletedAt: null,
1183
+ role: { $in: roleIds, deletedAt: null },
1099
1184
  })
1100
1185
  return { kind: 'roles', roleGrants: roleAcls.map(toFeatureOrganizationRoleGrant) }
1101
1186
  }
@@ -32,6 +32,7 @@ import {
32
32
  } from '@open-mercato/shared/lib/encryption/tenantDataEncryptionService'
33
33
  import { resolveEntityIdFromMetadata } from '@open-mercato/shared/lib/encryption/entityIds'
34
34
  import { listEntityMetadata } from '@open-mercato/shared/lib/db/entityMetadata'
35
+ import { rotateCustomEntityDocuments } from './lib/rotate-custom-entity-documents'
35
36
  import { Organization } from '../directory/data/entities'
36
37
  import type { EntityManager } from '@mikro-orm/postgresql'
37
38
  import crypto from 'node:crypto'
@@ -501,8 +502,18 @@ const rotateEncryptionKey: ModuleCli = {
501
502
  )
502
503
  return false
503
504
  })
505
+ const customDocuments = await rotateCustomEntityDocuments(em, {
506
+ encryptionService,
507
+ oldKms,
508
+ tenantId: tenantIdArg,
509
+ organizationId: organizationIdArg,
510
+ dryRun,
511
+ })
512
+ if (customDocuments > 0) {
513
+ console.log(`${dryRun ? '[dry-run] ' : ''}Encrypted ${customDocuments} custom entity document(s).`)
514
+ }
504
515
  if (!maps.length) {
505
- console.log('No encryption maps found for the selected scope.')
516
+ if (!customDocuments) console.log('No encryption maps found for the selected scope.')
506
517
  return
507
518
  }
508
519
 
@@ -7,6 +7,23 @@ import {
7
7
  } from '@open-mercato/shared/modules/entities/validation'
8
8
  import { CustomFieldDef, CustomFieldValue } from '../data/entities'
9
9
  import { loadScopedCustomFieldDefs } from './scoped-field-defs'
10
+ import { encryptCustomEntityDocument } from '@open-mercato/shared/lib/encryption/customEntityDoc'
11
+ import { isTenantDataEncryptionEnabled } from '@open-mercato/shared/lib/encryption/toggles'
12
+
13
+ /** @internal */
14
+ export async function encryptCustomEntityRecordDocument(
15
+ em: EntityManager,
16
+ opts: { entityId: string; organizationId: string | null; tenantId: string | null; doc: Record<string, unknown>; encryptionService?: TenantDataEncryptionService | null },
17
+ ): Promise<Record<string, unknown>> {
18
+ if (!isTenantDataEncryptionEnabled() || Object.keys(opts.doc).every((key) => key === 'id')) return opts.doc
19
+ const definitions = await loadScopedCustomFieldDefs(em, opts)
20
+ const fields = Array.from(definitions.values(), (definition) => ({
21
+ key: definition.key,
22
+ kind: definition.kind,
23
+ encrypted: Boolean(definition.configJson?.encrypted),
24
+ }))
25
+ return encryptCustomEntityDocument(opts.doc, fields, opts.tenantId, resolveTenantEncryptionService(em, opts.encryptionService))
26
+ }
10
27
 
11
28
  type Primitive = string | number | boolean | null | undefined
12
29
  type PrimitiveOrArray = Primitive | Primitive[]
@@ -0,0 +1,200 @@
1
+ import type { EntityManager } from '@mikro-orm/postgresql'
2
+ import type { FilterQuery } from '@mikro-orm/core'
3
+ import {
4
+ decryptWithAesGcm,
5
+ TenantDataEncryptionError,
6
+ TenantDataEncryptionErrorCode,
7
+ } from '@open-mercato/shared/lib/encryption/aes'
8
+ import {
9
+ decryptCustomEntityDocument,
10
+ encryptCustomEntityDocument,
11
+ isCustomEntityDocumentCiphertext,
12
+ type CustomEntityDocumentField,
13
+ } from '@open-mercato/shared/lib/encryption/customEntityDoc'
14
+ import type { KmsService, TenantDek } from '@open-mercato/shared/lib/encryption/kms'
15
+ import type { TenantDataEncryptionService } from '@open-mercato/shared/lib/encryption/tenantDataEncryptionService'
16
+ import { CustomFieldDef } from '../data/entities'
17
+ import { loadScopedCustomFieldDefs } from './scoped-field-defs'
18
+
19
+ type RotationOptions = {
20
+ encryptionService: TenantDataEncryptionService
21
+ oldKms: KmsService | null
22
+ tenantId: string | null
23
+ organizationId: string | null
24
+ dryRun: boolean
25
+ }
26
+
27
+ type DocumentRow = {
28
+ id: string
29
+ entity_type: string
30
+ tenant_id: string | null
31
+ organization_id: string | null
32
+ doc: Record<string, unknown>
33
+ }
34
+
35
+ type DocumentFieldPolicy = {
36
+ fields: CustomEntityDocumentField[]
37
+ encryptedKeys: Set<string>
38
+ protectedKeys: Set<string>
39
+ }
40
+
41
+ const BATCH_SIZE = 100
42
+
43
+ function holdsCiphertext(value: unknown): boolean {
44
+ return Array.isArray(value) ? value.some(isCustomEntityDocumentCiphertext) : isCustomEntityDocumentCiphertext(value)
45
+ }
46
+
47
+ function selectHistoricalDefinitions(definitions: CustomFieldDef[]): Map<string, CustomFieldDef> {
48
+ const selected = new Map<string, CustomFieldDef>()
49
+ const scopeScore = (definition: CustomFieldDef) => (definition.tenantId ? 2 : 0) + (definition.organizationId ? 1 : 0)
50
+ const updatedAt = (definition: CustomFieldDef) => new Date(definition.updatedAt).getTime()
51
+ for (const definition of definitions) {
52
+ const previous = selected.get(definition.key)
53
+ if (!previous || scopeScore(definition) > scopeScore(previous)
54
+ || (scopeScore(definition) === scopeScore(previous) && updatedAt(definition) >= updatedAt(previous))) {
55
+ selected.set(definition.key, definition)
56
+ }
57
+ }
58
+ return selected
59
+ }
60
+
61
+ /** @internal */
62
+ export async function rotateCustomEntityDocuments(
63
+ em: EntityManager,
64
+ options: RotationOptions,
65
+ ): Promise<number> {
66
+ const definitionScope: FilterQuery<CustomFieldDef>[] = []
67
+ if (options.tenantId) definitionScope.push({ $or: [{ tenantId: options.tenantId }, { tenantId: null }] })
68
+ if (options.organizationId) definitionScope.push({ $or: [{ organizationId: options.organizationId }, { organizationId: null }] })
69
+ const definitions = await em.find(CustomFieldDef, {
70
+ ...(definitionScope.length ? { $and: definitionScope } : {}),
71
+ })
72
+ const entityIds = Array.from(new Set(definitions.map((definition) => definition.entityId)))
73
+ if (!entityIds.length) return 0
74
+
75
+ const connection = em.getConnection()
76
+ const fieldsByScope = new Map<string, DocumentFieldPolicy>()
77
+ const oldDeks = new Map<string, TenantDek | null>()
78
+ let cursor: string | null = null
79
+ let updated = 0
80
+ for (;;) {
81
+ const predicates = [`entity_type in (${entityIds.map(() => '?').join(', ')})`]
82
+ const parameters: unknown[] = [...entityIds]
83
+ if (options.tenantId) {
84
+ predicates.push('tenant_id = ?')
85
+ parameters.push(options.tenantId)
86
+ }
87
+ if (options.organizationId) {
88
+ predicates.push('organization_id = ?')
89
+ parameters.push(options.organizationId)
90
+ }
91
+ if (cursor) {
92
+ predicates.push('id > ?')
93
+ parameters.push(cursor)
94
+ }
95
+ const rows = await connection.execute<DocumentRow[]>(
96
+ `select id, entity_type, tenant_id, organization_id, doc from custom_entities_storage where ${predicates.join(' and ')} order by id asc limit ${BATCH_SIZE}`,
97
+ parameters,
98
+ )
99
+ if (!rows.length) break
100
+ for (const row of rows) {
101
+ const scopeKey = JSON.stringify([row.entity_type, row.tenant_id, row.organization_id])
102
+ let policy = fieldsByScope.get(scopeKey)
103
+ if (!policy) {
104
+ const scopedDefinitions = await loadScopedCustomFieldDefs(em, {
105
+ entityId: row.entity_type,
106
+ tenantId: row.tenant_id,
107
+ organizationId: row.organization_id,
108
+ })
109
+ const visibleDefinitions = definitions
110
+ .filter((definition) => definition.entityId === row.entity_type
111
+ && (definition.tenantId == null || definition.tenantId === row.tenant_id)
112
+ && (definition.organizationId == null || definition.organizationId === row.organization_id))
113
+ const keys = new Set(visibleDefinitions.map((definition) => definition.key))
114
+ const historicalDefinitions = selectHistoricalDefinitions(visibleDefinitions)
115
+ policy = {
116
+ fields: Array.from(keys, (key) => ({ key, kind: 'text', encrypted: true })),
117
+ protectedKeys: new Set(Array.from(keys)
118
+ .filter((key) => (scopedDefinitions.get(key) ?? historicalDefinitions.get(key))?.configJson?.encrypted === true)),
119
+ encryptedKeys: new Set(Array.from(scopedDefinitions.values())
120
+ .filter((definition) => definition.configJson?.encrypted === true)
121
+ .map((definition) => definition.key)),
122
+ }
123
+ fieldsByScope.set(scopeKey, policy)
124
+ }
125
+ const { fields, encryptedKeys, protectedKeys } = policy
126
+ const knownKeys = new Set(fields.map((field) => field.key))
127
+ const entries = Object.entries(row.doc).filter(([key, value]) => {
128
+ const normalizedKey = key.replace(/^cf[:_]/, '')
129
+ return value != null && knownKeys.has(normalizedKey) && (encryptedKeys.has(normalizedKey) || holdsCiphertext(value))
130
+ })
131
+ if (!entries.length) continue
132
+ const tenantId = row.tenant_id
133
+ if (!tenantId || !options.encryptionService.isEnabled()) {
134
+ throw new TenantDataEncryptionError(TenantDataEncryptionErrorCode.KMS_UNAVAILABLE, '[internal] Custom entity encryption requires an available tenant key')
135
+ }
136
+ const currentDek = await options.encryptionService.getDek(tenantId)
137
+ if (options.oldKms && !oldDeks.has(tenantId)) oldDeks.set(tenantId, await options.oldKms.getTenantDek(tenantId))
138
+ const oldDek = oldDeks.get(tenantId) ?? null
139
+ const candidates: Record<string, unknown> = {}
140
+ const arrayMasks = new Map<string, boolean[]>()
141
+ let changed = false
142
+ const prepare = (value: unknown, encryptPlaintext: boolean, requireCiphertextKey: boolean): { value: unknown; selected: boolean } => {
143
+ if (value == null) return { value, selected: false }
144
+ if (typeof value === 'string') {
145
+ if (currentDek && decryptWithAesGcm(value, currentDek.key) !== null) return { value, selected: true }
146
+ if (oldDek && decryptWithAesGcm(value, oldDek.key) !== null) {
147
+ changed = true
148
+ return { value, selected: true }
149
+ }
150
+ }
151
+ if (!isCustomEntityDocumentCiphertext(value)) {
152
+ if (options.oldKms || !encryptPlaintext) return { value: null, selected: false }
153
+ changed = true
154
+ return { value, selected: true }
155
+ }
156
+ if (!requireCiphertextKey) return { value: null, selected: false }
157
+ throw new TenantDataEncryptionError(TenantDataEncryptionErrorCode.WRONG_KEY, '[internal] Custom entity ciphertext does not decrypt under the supplied tenant keys')
158
+ }
159
+ for (const [key, value] of entries) {
160
+ const normalizedKey = key.replace(/^cf[:_]/, '')
161
+ const encryptPlaintext = encryptedKeys.has(normalizedKey)
162
+ const requireCiphertextKey = protectedKeys.has(normalizedKey)
163
+ if (Array.isArray(value)) {
164
+ const prepared = value.map((entry) => prepare(entry, encryptPlaintext, requireCiphertextKey))
165
+ candidates[key] = prepared.map((entry) => entry.value)
166
+ arrayMasks.set(key, prepared.map((entry) => entry.selected))
167
+ } else {
168
+ const prepared = prepare(value, encryptPlaintext, requireCiphertextKey)
169
+ if (prepared.selected) candidates[key] = prepared.value
170
+ }
171
+ }
172
+ if (!changed) continue
173
+ if (options.dryRun) {
174
+ updated += 1
175
+ continue
176
+ }
177
+ const plaintext = options.oldKms && oldDek
178
+ ? await decryptCustomEntityDocument(candidates, fields, tenantId, options.encryptionService, new Map([[tenantId, oldDek.key]]))
179
+ : candidates
180
+ const encrypted = await encryptCustomEntityDocument(plaintext, fields, tenantId, options.encryptionService)
181
+ const nextDoc = { ...row.doc }
182
+ for (const [key, value] of Object.entries(encrypted)) {
183
+ const mask = arrayMasks.get(key)
184
+ const previous = row.doc[key]
185
+ nextDoc[key] = mask && Array.isArray(value) && Array.isArray(previous)
186
+ ? value.map((entry, index) => mask[index] ? entry : previous[index])
187
+ : value
188
+ }
189
+ const written = await connection.execute<Array<{ id: string }>>(
190
+ 'update custom_entities_storage set doc = ?::jsonb, updated_at = now() where id = ? and entity_type = ? and tenant_id is not distinct from ? and organization_id is not distinct from ? and doc = ?::jsonb returning id',
191
+ [JSON.stringify(nextDoc), row.id, row.entity_type, tenantId, row.organization_id, JSON.stringify(row.doc)],
192
+ )
193
+ if (!written.length) throw new Error('[internal] Custom entity document changed during encryption; retry the operation')
194
+ updated += 1
195
+ }
196
+ cursor = rows[rows.length - 1]!.id
197
+ if (rows.length < BATCH_SIZE) break
198
+ }
199
+ return updated
200
+ }