@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.
- package/.turbo/turbo-build.log +1 -1
- package/dist/helpers/integration/dbFixtures.js +1 -1
- package/dist/helpers/integration/dbFixtures.js.map +1 -1
- package/dist/modules/auth/api/roles/acl/route.js +2 -2
- package/dist/modules/auth/api/roles/acl/route.js.map +2 -2
- package/dist/modules/auth/api/roles/route.js +1 -1
- package/dist/modules/auth/api/roles/route.js.map +2 -2
- package/dist/modules/auth/api/users/acl/route.js +10 -2
- package/dist/modules/auth/api/users/acl/route.js.map +2 -2
- package/dist/modules/auth/commands/acl.js +2 -2
- package/dist/modules/auth/commands/acl.js.map +2 -2
- package/dist/modules/auth/commands/roles.js +1 -1
- package/dist/modules/auth/commands/roles.js.map +2 -2
- package/dist/modules/auth/commands/users.js +7 -3
- package/dist/modules/auth/commands/users.js.map +2 -2
- package/dist/modules/auth/data/entities.js.map +2 -2
- package/dist/modules/auth/lib/executionPrincipal.js +2 -2
- package/dist/modules/auth/lib/executionPrincipal.js.map +2 -2
- package/dist/modules/auth/lib/grantChecks.js +38 -11
- package/dist/modules/auth/lib/grantChecks.js.map +2 -2
- package/dist/modules/auth/lib/setup-app.js +2 -2
- package/dist/modules/auth/lib/setup-app.js.map +2 -2
- package/dist/modules/auth/migrations/Migration20260828120000_auth.js +45 -0
- package/dist/modules/auth/migrations/Migration20260828120000_auth.js.map +7 -0
- package/dist/modules/auth/services/rbacService.js +138 -59
- package/dist/modules/auth/services/rbacService.js.map +3 -3
- package/dist/modules/entities/cli.js +12 -1
- package/dist/modules/entities/cli.js.map +2 -2
- package/dist/modules/entities/lib/helpers.js +13 -0
- package/dist/modules/entities/lib/helpers.js.map +2 -2
- package/dist/modules/entities/lib/rotate-custom-entity-documents.js +157 -0
- package/dist/modules/entities/lib/rotate-custom-entity-documents.js.map +7 -0
- package/dist/modules/notifications/lib/notificationRecipients.js +2 -2
- package/dist/modules/notifications/lib/notificationRecipients.js.map +2 -2
- package/dist/modules/query_index/lib/engine.js +179 -11
- package/dist/modules/query_index/lib/engine.js.map +3 -3
- package/package.json +7 -7
- package/src/helpers/integration/dbFixtures.ts +1 -1
- package/src/modules/auth/api/roles/acl/route.ts +2 -2
- package/src/modules/auth/api/roles/route.ts +1 -1
- package/src/modules/auth/api/users/acl/route.ts +10 -2
- package/src/modules/auth/commands/acl.ts +2 -2
- package/src/modules/auth/commands/roles.ts +1 -1
- package/src/modules/auth/commands/users.ts +7 -3
- package/src/modules/auth/data/entities.ts +12 -0
- package/src/modules/auth/lib/executionPrincipal.ts +2 -2
- package/src/modules/auth/lib/grantChecks.ts +115 -11
- package/src/modules/auth/lib/setup-app.ts +2 -2
- package/src/modules/auth/migrations/Migration20260828120000_auth.ts +70 -0
- package/src/modules/auth/services/rbacService.ts +161 -76
- package/src/modules/entities/cli.ts +12 -1
- package/src/modules/entities/lib/helpers.ts +17 -0
- package/src/modules/entities/lib/rotate-custom-entity-documents.ts +200 -0
- package/src/modules/notifications/lib/notificationRecipients.ts +6 -0
- 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
|
-
|
|
385
|
-
|
|
386
|
-
|
|
387
|
-
|
|
388
|
-
|
|
389
|
-
|
|
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
|
-
|
|
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
|
|
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, {
|
|
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, {
|
|
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
|
-
|
|
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
|
-
{
|
|
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
|
|
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, {
|
|
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
|
|
713
|
-
|
|
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 (
|
|
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(
|
|
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
|
|
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
|
-
|
|
944
|
-
|
|
945
|
-
|
|
946
|
-
|
|
947
|
-
|
|
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
|
-
|
|
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
|
|
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(
|
|
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
|
-
|
|
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
|
+
}
|