@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
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@open-mercato/core",
|
|
3
|
-
"version": "0.10.1-develop.
|
|
3
|
+
"version": "0.10.1-develop.7756.1.8f5dec1b96",
|
|
4
4
|
"license": "MIT",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "./dist/index.js",
|
|
@@ -266,16 +266,16 @@
|
|
|
266
266
|
"zod": "^4.4.3"
|
|
267
267
|
},
|
|
268
268
|
"peerDependencies": {
|
|
269
|
-
"@open-mercato/ai-assistant": "0.10.1-develop.
|
|
270
|
-
"@open-mercato/shared": "0.10.1-develop.
|
|
271
|
-
"@open-mercato/ui": "0.10.1-develop.
|
|
269
|
+
"@open-mercato/ai-assistant": "0.10.1-develop.7756.1.8f5dec1b96",
|
|
270
|
+
"@open-mercato/shared": "0.10.1-develop.7756.1.8f5dec1b96",
|
|
271
|
+
"@open-mercato/ui": "0.10.1-develop.7756.1.8f5dec1b96",
|
|
272
272
|
"react": "^19.0.0",
|
|
273
273
|
"react-dom": "^19.0.0"
|
|
274
274
|
},
|
|
275
275
|
"devDependencies": {
|
|
276
|
-
"@open-mercato/ai-assistant": "0.10.1-develop.
|
|
277
|
-
"@open-mercato/shared": "0.10.1-develop.
|
|
278
|
-
"@open-mercato/ui": "0.10.1-develop.
|
|
276
|
+
"@open-mercato/ai-assistant": "0.10.1-develop.7756.1.8f5dec1b96",
|
|
277
|
+
"@open-mercato/shared": "0.10.1-develop.7756.1.8f5dec1b96",
|
|
278
|
+
"@open-mercato/ui": "0.10.1-develop.7756.1.8f5dec1b96",
|
|
279
279
|
"@testing-library/dom": "^10.4.1",
|
|
280
280
|
"@testing-library/jest-dom": "^7.0.1",
|
|
281
281
|
"@testing-library/react": "^16.3.3",
|
|
@@ -109,7 +109,7 @@ export async function setUserAclInDb(input: {
|
|
|
109
109
|
}): Promise<void> {
|
|
110
110
|
await withClient(async (client) => {
|
|
111
111
|
const existing = await client.query<{ id: string }>(
|
|
112
|
-
'select id from user_acls where user_id = $1 and tenant_id = $2 limit 1',
|
|
112
|
+
'select id from user_acls where user_id = $1 and tenant_id = $2 and deleted_at is null limit 1',
|
|
113
113
|
[input.userId, input.tenantId],
|
|
114
114
|
);
|
|
115
115
|
const featuresJson = JSON.stringify(input.features);
|
|
@@ -99,7 +99,7 @@ export async function GET(req: Request) {
|
|
|
99
99
|
}
|
|
100
100
|
|
|
101
101
|
const acl = tenantScope
|
|
102
|
-
? await em.findOne(RoleAcl, { role, tenantId: tenantScope })
|
|
102
|
+
? await em.findOne(RoleAcl, { role, tenantId: tenantScope, deletedAt: null })
|
|
103
103
|
: null
|
|
104
104
|
const response = acl
|
|
105
105
|
? {
|
|
@@ -178,7 +178,7 @@ export async function PUT(req: Request) {
|
|
|
178
178
|
}
|
|
179
179
|
}
|
|
180
180
|
|
|
181
|
-
const acl = await em.findOne(RoleAcl, { role, tenantId: targetTenantId })
|
|
181
|
+
const acl = await em.findOne(RoleAcl, { role, tenantId: targetTenantId, deletedAt: null })
|
|
182
182
|
// Optimistic lock: refuse a stale ACL overwrite so two admins editing the same
|
|
183
183
|
// role's features in parallel cannot silently clobber each other (#2055). The
|
|
184
184
|
// check is strictly additive — when the client sends no expected-version header
|
|
@@ -201,7 +201,7 @@ export async function GET(req: Request) {
|
|
|
201
201
|
}
|
|
202
202
|
let superAdminRoleIds: Set<string> | null = null
|
|
203
203
|
if (!isSuperAdmin && actorTenantId) {
|
|
204
|
-
const superAdminAcls = await findWithDecryption(em, RoleAcl, { tenantId: actorTenantId, isSuperAdmin: true }, {}, { tenantId: actorTenantId, organizationId: null })
|
|
204
|
+
const superAdminAcls = await findWithDecryption(em, RoleAcl, { tenantId: actorTenantId, isSuperAdmin: true, deletedAt: null }, {}, { tenantId: actorTenantId, organizationId: null })
|
|
205
205
|
if (superAdminAcls.length) {
|
|
206
206
|
superAdminRoleIds = new Set(
|
|
207
207
|
superAdminAcls
|
|
@@ -171,7 +171,11 @@ export async function GET(req: Request) {
|
|
|
171
171
|
}
|
|
172
172
|
}
|
|
173
173
|
const acl = tenantId
|
|
174
|
-
? await em.findOne(UserAcl, {
|
|
174
|
+
? await em.findOne(UserAcl, {
|
|
175
|
+
user: parsed.data.userId as unknown as User,
|
|
176
|
+
tenantId,
|
|
177
|
+
deletedAt: null,
|
|
178
|
+
} as FilterQuery<UserAcl>)
|
|
175
179
|
: null
|
|
176
180
|
const response = acl
|
|
177
181
|
? {
|
|
@@ -268,7 +272,11 @@ export async function PUT(req: Request) {
|
|
|
268
272
|
}
|
|
269
273
|
}
|
|
270
274
|
|
|
271
|
-
const acl = await em.findOne(UserAcl, {
|
|
275
|
+
const acl = await em.findOne(UserAcl, {
|
|
276
|
+
user: parsed.data.userId as unknown as User,
|
|
277
|
+
tenantId,
|
|
278
|
+
deletedAt: null,
|
|
279
|
+
} as FilterQuery<UserAcl>)
|
|
272
280
|
// Optimistic lock: refuse a stale per-user ACL overwrite so concurrent edits
|
|
273
281
|
// cannot silently clobber each other (#2055). Strictly additive — a no-op when
|
|
274
282
|
// the client sends no expected-version header; skipped when no ACL row exists.
|
|
@@ -390,7 +390,7 @@ const updateRoleAclCommand = createAclUpdateCommand<RoleAclUpdateInput>({
|
|
|
390
390
|
await lockRoleWriterAuthorizationState(em, [input.roleId])
|
|
391
391
|
},
|
|
392
392
|
loadAcl: (em, input) =>
|
|
393
|
-
em.findOne(RoleAcl, { role: input.roleId as unknown as Role, tenantId: input.tenantId }),
|
|
393
|
+
em.findOne(RoleAcl, { role: input.roleId as unknown as Role, tenantId: input.tenantId, deletedAt: null }),
|
|
394
394
|
persist: async ({ em, input, existing }) => {
|
|
395
395
|
const acl =
|
|
396
396
|
(existing as RoleAcl | null) ??
|
|
@@ -434,7 +434,7 @@ const updateUserAclCommand = createAclUpdateCommand<UserAclUpdateInput>({
|
|
|
434
434
|
await lockUserAclWriterAuthorizationState(em, [input.userId])
|
|
435
435
|
},
|
|
436
436
|
loadAcl: (em, input) =>
|
|
437
|
-
em.findOne(UserAcl, { user: input.userId as unknown as User, tenantId: input.tenantId }),
|
|
437
|
+
em.findOne(UserAcl, { user: input.userId as unknown as User, tenantId: input.tenantId, deletedAt: null }),
|
|
438
438
|
persist: async ({ em, input, existing }) => {
|
|
439
439
|
if (input.clear) {
|
|
440
440
|
if (!existing) return
|
|
@@ -948,7 +948,7 @@ function captureRoleSnapshots(
|
|
|
948
948
|
}
|
|
949
949
|
|
|
950
950
|
async function loadRoleAclSnapshots(em: EntityManager, roleId: string): Promise<RoleAclSnapshot[]> {
|
|
951
|
-
const entries = await findWithDecryption(em, RoleAcl, { role: roleId as unknown as Role }, {}, { tenantId: null, organizationId: null })
|
|
951
|
+
const entries = await findWithDecryption(em, RoleAcl, { role: roleId as unknown as Role, deletedAt: null }, {}, { tenantId: null, organizationId: null })
|
|
952
952
|
return entries.map((entry) => ({
|
|
953
953
|
id: entry.id ? String(entry.id) : null,
|
|
954
954
|
tenantId: String(entry.tenantId),
|
|
@@ -1687,7 +1687,7 @@ async function syncUserRoles(
|
|
|
1687
1687
|
const currentLinks = await findWithDecryption(
|
|
1688
1688
|
em,
|
|
1689
1689
|
UserRole,
|
|
1690
|
-
{ user }
|
|
1690
|
+
{ user, deletedAt: null, role: { deletedAt: null } } as FilterQuery<UserRole>,
|
|
1691
1691
|
{ lockMode: LockMode.PESSIMISTIC_WRITE, orderBy: { id: 'ASC' }, refresh: true },
|
|
1692
1692
|
{ tenantId: null, organizationId: null },
|
|
1693
1693
|
)
|
|
@@ -1717,7 +1717,11 @@ async function loadUserRoleNames(em: EntityManager, userId: string): Promise<str
|
|
|
1717
1717
|
const links = await findWithDecryption(
|
|
1718
1718
|
em,
|
|
1719
1719
|
UserRole,
|
|
1720
|
-
{
|
|
1720
|
+
{
|
|
1721
|
+
user: userId as unknown as User,
|
|
1722
|
+
deletedAt: null,
|
|
1723
|
+
role: { deletedAt: null },
|
|
1724
|
+
} as FilterQuery<UserRole>,
|
|
1721
1725
|
{ populate: ['role'] },
|
|
1722
1726
|
{ tenantId: null, organizationId: null },
|
|
1723
1727
|
)
|
|
@@ -1764,7 +1768,7 @@ function captureUserSnapshots(
|
|
|
1764
1768
|
}
|
|
1765
1769
|
|
|
1766
1770
|
async function loadUserAclSnapshots(em: EntityManager, userId: string): Promise<UserAclSnapshot[]> {
|
|
1767
|
-
const list = await findWithDecryption(em, UserAcl, { user: userId as unknown as User }, {}, { tenantId: null, organizationId: null })
|
|
1771
|
+
const list = await findWithDecryption(em, UserAcl, { user: userId as unknown as User, deletedAt: null }, {}, { tenantId: null, organizationId: null })
|
|
1768
1772
|
return list.map((acl) => ({
|
|
1769
1773
|
tenantId: String(acl.tenantId),
|
|
1770
1774
|
features: Array.isArray(acl.featuresJson) ? [...acl.featuresJson] : null,
|
|
@@ -271,6 +271,11 @@ export class PasswordReset {
|
|
|
271
271
|
|
|
272
272
|
// RBAC: Role-level ACL
|
|
273
273
|
@Entity({ tableName: 'role_acls' })
|
|
274
|
+
// Uniqueness is enforced by a partial unique index (`role_acls_active_unique_idx`)
|
|
275
|
+
// on `(role_id, tenant_id)` scoped to live rows (`WHERE deleted_at IS NULL`) and
|
|
276
|
+
// owned by raw SQL in Migration20260828120000_auth. A `@Unique` decorator can't
|
|
277
|
+
// express a partial index, so the entity intentionally omits it — the migration
|
|
278
|
+
// is the source of truth.
|
|
274
279
|
export class RoleAcl {
|
|
275
280
|
@PrimaryKey({ type: 'uuid', defaultRaw: 'gen_random_uuid()' })
|
|
276
281
|
id!: string
|
|
@@ -309,6 +314,13 @@ export class RoleAcl {
|
|
|
309
314
|
|
|
310
315
|
// RBAC: Per-user ACL override
|
|
311
316
|
@Entity({ tableName: 'user_acls' })
|
|
317
|
+
// Uniqueness is enforced by a partial unique index (`user_acls_active_unique_idx`)
|
|
318
|
+
// on `(user_id, tenant_id)` scoped to live rows (`WHERE deleted_at IS NULL`) and
|
|
319
|
+
// owned by raw SQL in Migration20260828120000_auth. A `@Unique` decorator can't
|
|
320
|
+
// express a partial index, so the entity intentionally omits it — the migration
|
|
321
|
+
// is the source of truth. This row REPLACES the user's role ACLs rather than
|
|
322
|
+
// merging with them (see RbacService.loadAcl), so a second live row for the same
|
|
323
|
+
// scope would silently decide the whole feature set.
|
|
312
324
|
export class UserAcl {
|
|
313
325
|
@PrimaryKey({ type: 'uuid', defaultRaw: 'gen_random_uuid()' })
|
|
314
326
|
id!: string
|
|
@@ -169,7 +169,7 @@ export async function provisionExecutionPrincipal(
|
|
|
169
169
|
const existingAcl = await findOneWithDecryption(
|
|
170
170
|
trx,
|
|
171
171
|
RoleAcl,
|
|
172
|
-
{ role, tenantId },
|
|
172
|
+
{ role, tenantId, deletedAt: null },
|
|
173
173
|
{},
|
|
174
174
|
{ tenantId, organizationId: null },
|
|
175
175
|
)
|
|
@@ -204,7 +204,7 @@ export async function provisionExecutionPrincipal(
|
|
|
204
204
|
const existingLink = await findOneWithDecryption(
|
|
205
205
|
trx,
|
|
206
206
|
UserRole,
|
|
207
|
-
{ user, role },
|
|
207
|
+
{ user, role, deletedAt: null },
|
|
208
208
|
{},
|
|
209
209
|
{ tenantId, organizationId: null },
|
|
210
210
|
)
|
|
@@ -189,7 +189,7 @@ export async function assertActorCanGrantRoles(input: RoleGrantCheckInput): Prom
|
|
|
189
189
|
const acl = await findOneWithDecryption(
|
|
190
190
|
input.em,
|
|
191
191
|
RoleAcl,
|
|
192
|
-
{ role, tenantId } as FilterQuery<RoleAcl>,
|
|
192
|
+
{ role, tenantId, deletedAt: null } as FilterQuery<RoleAcl>,
|
|
193
193
|
{},
|
|
194
194
|
{ tenantId, organizationId: null },
|
|
195
195
|
)
|
|
@@ -306,16 +306,65 @@ async function resolveActorIsSuperAdmin(input: GrantCheckContext & { actorIsSupe
|
|
|
306
306
|
return acl.isSuperAdmin
|
|
307
307
|
}
|
|
308
308
|
|
|
309
|
+
/**
|
|
310
|
+
* Answers "is this user effectively a super-admin" about a TARGET — the input to
|
|
311
|
+
* `assertActorCanModifySuperAdminUserTarget`, not to any authorization decision
|
|
312
|
+
* about the caller.
|
|
313
|
+
*
|
|
314
|
+
* It is bound to the target's OWN tenant (`users.tenant_id`) and to live grant
|
|
315
|
+
* rows, because that is the reading the two authorities already share:
|
|
316
|
+
* `RbacService.isGlobalSuperAdmin` and `lib/sessionIntegrity.ts`
|
|
317
|
+
* (`userAclGrantsSuperAdmin` / `roleAclGrantsSuperAdmin`). A protection
|
|
318
|
+
* predicate that answered differently would protect people who hold nothing —
|
|
319
|
+
* a grant stamped a foreign tenant, or one that was revoked — while every
|
|
320
|
+
* authorization path treats them as ordinary users. That over-protection does
|
|
321
|
+
* not expire: nothing short of a super-admin could edit such an account again,
|
|
322
|
+
* and revoking someone's super-admin would leave them permanently unmanageable
|
|
323
|
+
* by their own tenant's admins.
|
|
324
|
+
*
|
|
325
|
+
* Two asymmetries with `isGlobalSuperAdmin` are deliberate:
|
|
326
|
+
*
|
|
327
|
+
* - Organization-restricted role grants are NOT excluded. That exclusion only
|
|
328
|
+
* keeps them out of the GLOBAL answer; `loadAcl`'s scoped projection still
|
|
329
|
+
* returns `isSuperAdmin: true` for such a role inside the organizations it
|
|
330
|
+
* names, so its holder is a privileged target. A protection predicate may be
|
|
331
|
+
* a superset of the authorization answer; it must never be a subset.
|
|
332
|
+
* - The `User` load carries no `deletedAt` filter, matching the load in
|
|
333
|
+
* `assertActorCanAccessUserTarget` below. A soft-deleted super-admin stays
|
|
334
|
+
* protected; it is the ACL rows that must be live.
|
|
335
|
+
*
|
|
336
|
+
* "No user row" and "no tenant of their own" both answer false, as
|
|
337
|
+
* `isGlobalSuperAdmin` does. Neither is exploitable, because every call site
|
|
338
|
+
* pairs this guard with `assertActorCanAccessUserTarget`: that one delegates a
|
|
339
|
+
* missing target to the caller's own tenant-scoped load, and 404s a target
|
|
340
|
+
* whose `tenantId` is absent, so no mutation reaches either case.
|
|
341
|
+
*/
|
|
309
342
|
export async function isUserEffectivelySuperAdmin(em: EntityManager, userId: string): Promise<boolean> {
|
|
343
|
+
const target = await findOneWithDecryption(
|
|
344
|
+
em,
|
|
345
|
+
User,
|
|
346
|
+
{ id: userId } as FilterQuery<User>,
|
|
347
|
+
{},
|
|
348
|
+
{ tenantId: null, organizationId: null },
|
|
349
|
+
)
|
|
350
|
+
const tenantId = normalizeNullableString((target as { tenantId?: string | null } | null)?.tenantId)
|
|
351
|
+
if (!tenantId) return false
|
|
310
352
|
const directGrant = await em.findOne(
|
|
311
353
|
UserAcl,
|
|
312
|
-
{ user: userId as unknown, isSuperAdmin: true } as FilterQuery<UserAcl>,
|
|
354
|
+
{ user: userId as unknown, tenantId, isSuperAdmin: true, deletedAt: null } as FilterQuery<UserAcl>,
|
|
313
355
|
)
|
|
314
356
|
if (directGrant && (directGrant as { isSuperAdmin?: boolean }).isSuperAdmin === true) return true
|
|
315
357
|
const links = await findWithDecryption(
|
|
316
358
|
em,
|
|
317
359
|
UserRole,
|
|
318
|
-
|
|
360
|
+
// Roles of the user's own tenant, the same restriction `isGlobalSuperAdmin`
|
|
361
|
+
// applies. The encryption scope stays `{ null, null }`: this is about which
|
|
362
|
+
// rows count, not about which key decrypts them.
|
|
363
|
+
{
|
|
364
|
+
user: userId as unknown,
|
|
365
|
+
deletedAt: null,
|
|
366
|
+
role: { tenantId, deletedAt: null },
|
|
367
|
+
} as FilterQuery<UserRole>,
|
|
319
368
|
{ populate: ['role'] },
|
|
320
369
|
{ tenantId: null, organizationId: null },
|
|
321
370
|
)
|
|
@@ -330,22 +379,75 @@ export async function isUserEffectivelySuperAdmin(em: EntityManager, userId: str
|
|
|
330
379
|
if (!roleIds.length) return false
|
|
331
380
|
const roleGrant = await em.findOne(
|
|
332
381
|
RoleAcl,
|
|
333
|
-
{ role: { $in: roleIds } as unknown, isSuperAdmin: true } as FilterQuery<RoleAcl>,
|
|
382
|
+
{ role: { $in: roleIds } as unknown, tenantId, isSuperAdmin: true, deletedAt: null } as FilterQuery<RoleAcl>,
|
|
334
383
|
)
|
|
335
384
|
return !!roleGrant && (roleGrant as { isSuperAdmin?: boolean }).isSuperAdmin === true
|
|
336
385
|
}
|
|
337
386
|
|
|
387
|
+
/**
|
|
388
|
+
* The role twin of `isUserEffectivelySuperAdmin`, and it carries the same
|
|
389
|
+
* reading for the same reasons: the grant must be live, and it must be stamped
|
|
390
|
+
* with the role's OWN tenant. A `role_acls` row stamped elsewhere confers
|
|
391
|
+
* nothing (`RbacService.loadAcl` reads role ACLs `{ tenantId, deletedAt: null }`),
|
|
392
|
+
* so protecting a role on the strength of one would refuse an edit that every
|
|
393
|
+
* authorization path considers ordinary.
|
|
394
|
+
*
|
|
395
|
+
* A role that does not exist answers false, which its paired guard
|
|
396
|
+
* `assertActorCanAccessRoleTarget` then delegates to the caller in the same way
|
|
397
|
+
* the user side does.
|
|
398
|
+
*/
|
|
338
399
|
export async function isRoleEffectivelySuperAdmin(em: EntityManager, roleId: string): Promise<boolean> {
|
|
400
|
+
const role = await findOneWithDecryption(
|
|
401
|
+
em,
|
|
402
|
+
Role,
|
|
403
|
+
{ id: roleId } as FilterQuery<Role>,
|
|
404
|
+
{},
|
|
405
|
+
{ tenantId: null, organizationId: null },
|
|
406
|
+
)
|
|
407
|
+
const tenantId = normalizeNullableString((role as { tenantId?: string | null } | null)?.tenantId)
|
|
408
|
+
if (!tenantId) return false
|
|
339
409
|
const grant = await em.findOne(
|
|
340
410
|
RoleAcl,
|
|
341
|
-
{ role: roleId as unknown, isSuperAdmin: true } as FilterQuery<RoleAcl>,
|
|
411
|
+
{ role: roleId as unknown, tenantId, isSuperAdmin: true, deletedAt: null } as FilterQuery<RoleAcl>,
|
|
342
412
|
)
|
|
343
413
|
return !!grant && (grant as { isSuperAdmin?: boolean }).isSuperAdmin === true
|
|
344
414
|
}
|
|
345
415
|
|
|
416
|
+
/**
|
|
417
|
+
* The super-admin user ids a non-super-admin must not be shown while looking at
|
|
418
|
+
* `tenantId` — an exclusion list, not an authorization answer.
|
|
419
|
+
*
|
|
420
|
+
* `tenantId` names the tenant whose users are being listed. Because a
|
|
421
|
+
* super-admin grant is bound to the holder's own tenant (see
|
|
422
|
+
* `isUserEffectivelySuperAdmin` above and `RbacService.isGlobalSuperAdmin`),
|
|
423
|
+
* that is also the tenant a grant must be stamped with in order to count here,
|
|
424
|
+
* so the filter is applied to BOTH ACL tables. It used to reach only
|
|
425
|
+
* `user_acls`, which made the two halves disagree about what a tenant-scoped
|
|
426
|
+
* answer meant.
|
|
427
|
+
*
|
|
428
|
+
* The narrowing on `role_acls` is only safe TOGETHER with that tenant binding:
|
|
429
|
+
* while a foreign-stamped row still conferred super-admin, dropping it from this
|
|
430
|
+
* list would have unhidden a real one.
|
|
431
|
+
*
|
|
432
|
+
* The caller owes the other half of the scoping. This returns ids; it is the
|
|
433
|
+
* caller's own subject query that must already be restricted to `tenantId`.
|
|
434
|
+
* Both wired call sites are — `auth/api/users/route.ts` filters the user query
|
|
435
|
+
* by the same tenant, and documents' principals route scopes to `ctx.tenantId`
|
|
436
|
+
* — so a role member living in another tenant is merely excluded from a list
|
|
437
|
+
* they were never in. That is not licence to pass a tenant unrelated to the
|
|
438
|
+
* subject set.
|
|
439
|
+
*
|
|
440
|
+
* Deliberately NOT narrowed further: organization-restricted role grants count
|
|
441
|
+
* (their holders are super-admins inside those organizations), and the
|
|
442
|
+
* `user_roles` expansion stays tenant-less. Over-inclusion there is bounded by
|
|
443
|
+
* the caller's subject scoping, and for an exclusion list hiding one user too
|
|
444
|
+
* many is the safe direction.
|
|
445
|
+
*/
|
|
346
446
|
export async function listSuperAdminUserIds(em: EntityManager, tenantId: string | null): Promise<Set<string>> {
|
|
347
447
|
const ids = new Set<string>()
|
|
348
|
-
|
|
448
|
+
// A revoked override must not keep answering — the same rule the ACL reads in
|
|
449
|
+
// `RbacService` follow.
|
|
450
|
+
const userAclFilter: Record<string, unknown> = { isSuperAdmin: true, deletedAt: null }
|
|
349
451
|
if (tenantId) userAclFilter.tenantId = tenantId
|
|
350
452
|
const userAcls = await em.find(UserAcl, userAclFilter as FilterQuery<UserAcl>)
|
|
351
453
|
for (const acl of userAcls) {
|
|
@@ -355,10 +457,9 @@ export async function listSuperAdminUserIds(em: EntityManager, tenantId: string
|
|
|
355
457
|
: userRef
|
|
356
458
|
if (userId) ids.add(String(userId))
|
|
357
459
|
}
|
|
358
|
-
const
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
)
|
|
460
|
+
const roleAclFilter: Record<string, unknown> = { isSuperAdmin: true, deletedAt: null }
|
|
461
|
+
if (tenantId) roleAclFilter.tenantId = tenantId
|
|
462
|
+
const roleAcls = await em.find(RoleAcl, roleAclFilter as FilterQuery<RoleAcl>)
|
|
362
463
|
const roleIds = roleAcls
|
|
363
464
|
.map((acl) => {
|
|
364
465
|
const roleRef = (acl as { role?: { id?: unknown } | string | null }).role
|
|
@@ -371,7 +472,10 @@ export async function listSuperAdminUserIds(em: EntityManager, tenantId: string
|
|
|
371
472
|
const links = await findWithDecryption(
|
|
372
473
|
em,
|
|
373
474
|
UserRole,
|
|
374
|
-
{
|
|
475
|
+
{
|
|
476
|
+
deletedAt: null,
|
|
477
|
+
role: { id: { $in: roleIds }, deletedAt: null },
|
|
478
|
+
} as FilterQuery<UserRole>,
|
|
375
479
|
{},
|
|
376
480
|
{ tenantId: null, organizationId: null },
|
|
377
481
|
)
|
|
@@ -488,7 +488,7 @@ export async function setupInitialTenant(
|
|
|
488
488
|
roleIds: assignedRoles.map((role) => String(role.id)),
|
|
489
489
|
})
|
|
490
490
|
for (const role of assignedRoles) {
|
|
491
|
-
const existingLink = await findOneWithDecryption(tem, UserRole, { user, role }, {}, { tenantId: tenantId ?? null, organizationId: null })
|
|
491
|
+
const existingLink = await findOneWithDecryption(tem, UserRole, { user, role, deletedAt: null }, {}, { tenantId: tenantId ?? null, organizationId: null })
|
|
492
492
|
if (!existingLink) tem.persist(tem.create(UserRole, { user, role, createdAt: new Date() }))
|
|
493
493
|
}
|
|
494
494
|
await tem.flush()
|
|
@@ -713,7 +713,7 @@ async function ensureRoleAclFor(
|
|
|
713
713
|
const uniqueFeatures = Array.from(new Set(features))
|
|
714
714
|
await withAtomicFlush(em, [async () => {
|
|
715
715
|
await lockRoleAclWriterAuthorizationState(em, [String(role.id)])
|
|
716
|
-
const existing = await findOneWithDecryption(em, RoleAcl, { role, tenantId }, {}, { tenantId, organizationId: null })
|
|
716
|
+
const existing = await findOneWithDecryption(em, RoleAcl, { role, tenantId, deletedAt: null }, {}, { tenantId, organizationId: null })
|
|
717
717
|
if (!existing) {
|
|
718
718
|
const acl = em.create(RoleAcl, {
|
|
719
719
|
role,
|
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
import { Migration } from '@mikro-orm/migrations';
|
|
2
|
+
|
|
3
|
+
// `user_acls` and `role_acls` decide what a principal may do, and neither had
|
|
4
|
+
// any uniqueness beyond its primary key. `RbacService.loadAcl` resolves the
|
|
5
|
+
// per-user row with a bare `findOne` and RETURNS EARLY on a hit — the per-user
|
|
6
|
+
// ACL replaces the role ACLs rather than merging with them — so two rows for
|
|
7
|
+
// the same `(user_id, tenant_id)` were schema-legal and resolved with no
|
|
8
|
+
// `ORDER BY`: whichever row Postgres happened to return decided the user's
|
|
9
|
+
// entire feature set, and could change between requests.
|
|
10
|
+
//
|
|
11
|
+
// The write path makes that reachable rather than theoretical:
|
|
12
|
+
// `auth.user_acl.update` reads with `findOne` and creates when it misses, so
|
|
13
|
+
// two concurrent saves for the same user both miss and both insert.
|
|
14
|
+
//
|
|
15
|
+
// Partial unique indexes scoped to live rows (`where deleted_at is null`),
|
|
16
|
+
// matching the shape already used for the sidebar tables in
|
|
17
|
+
// Migration20260427143311. A `@Unique` decorator cannot express a partial
|
|
18
|
+
// index, so the entities carry a comment pointing here instead.
|
|
19
|
+
//
|
|
20
|
+
// Historical duplicates are collapsed first — most recently updated row wins,
|
|
21
|
+
// the rest are soft-deleted, never dropped — so the indexes can be created on
|
|
22
|
+
// a database that already carries some.
|
|
23
|
+
export class Migration20260828120000_auth extends Migration {
|
|
24
|
+
|
|
25
|
+
override up(): void | Promise<void> {
|
|
26
|
+
this.addSql(`
|
|
27
|
+
with ranked as (
|
|
28
|
+
select id,
|
|
29
|
+
row_number() over (
|
|
30
|
+
partition by user_id, tenant_id
|
|
31
|
+
order by coalesce(updated_at, created_at) desc, created_at desc, id desc
|
|
32
|
+
) as rn
|
|
33
|
+
from user_acls
|
|
34
|
+
where deleted_at is null
|
|
35
|
+
)
|
|
36
|
+
update user_acls
|
|
37
|
+
set deleted_at = now()
|
|
38
|
+
from ranked
|
|
39
|
+
where user_acls.id = ranked.id and ranked.rn > 1;
|
|
40
|
+
`);
|
|
41
|
+
this.addSql(`create unique index if not exists "user_acls_active_unique_idx" on "user_acls" ("user_id", "tenant_id") where "deleted_at" is null;`);
|
|
42
|
+
|
|
43
|
+
this.addSql(`
|
|
44
|
+
with ranked as (
|
|
45
|
+
select id,
|
|
46
|
+
row_number() over (
|
|
47
|
+
partition by role_id, tenant_id
|
|
48
|
+
order by coalesce(updated_at, created_at) desc, created_at desc, id desc
|
|
49
|
+
) as rn
|
|
50
|
+
from role_acls
|
|
51
|
+
where deleted_at is null
|
|
52
|
+
)
|
|
53
|
+
update role_acls
|
|
54
|
+
set deleted_at = now()
|
|
55
|
+
from ranked
|
|
56
|
+
where role_acls.id = ranked.id and ranked.rn > 1;
|
|
57
|
+
`);
|
|
58
|
+
this.addSql(`create unique index if not exists "role_acls_active_unique_idx" on "role_acls" ("role_id", "tenant_id") where "deleted_at" is null;`);
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
override down(): void | Promise<void> {
|
|
62
|
+
// Only the indexes are reversible. The collapse soft-deleted rows rather
|
|
63
|
+
// than dropping them, so the data is still there to un-delete by hand if a
|
|
64
|
+
// deployment ever needs it; un-deleting automatically would recreate the
|
|
65
|
+
// ambiguity this migration exists to remove.
|
|
66
|
+
this.addSql(`drop index if exists "user_acls_active_unique_idx";`);
|
|
67
|
+
this.addSql(`drop index if exists "role_acls_active_unique_idx";`);
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
}
|