@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
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@open-mercato/core",
3
- "version": "0.10.1-develop.7752.1.fd7c06569b",
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.7752.1.fd7c06569b",
270
- "@open-mercato/shared": "0.10.1-develop.7752.1.fd7c06569b",
271
- "@open-mercato/ui": "0.10.1-develop.7752.1.fd7c06569b",
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.7752.1.fd7c06569b",
277
- "@open-mercato/shared": "0.10.1-develop.7752.1.fd7c06569b",
278
- "@open-mercato/ui": "0.10.1-develop.7752.1.fd7c06569b",
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, { user: parsed.data.userId as any, tenantId })
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, { user: parsed.data.userId as any, tenantId })
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
- { user: userId as unknown as User },
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
- { user: userId as unknown } as FilterQuery<UserRole>,
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
- const userAclFilter: Record<string, unknown> = { isSuperAdmin: true }
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 roleAcls = await em.find(
359
- RoleAcl,
360
- { isSuperAdmin: true } as FilterQuery<RoleAcl>,
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
- { role: { $in: roleIds } as unknown } as FilterQuery<UserRole>,
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
+ }