@open-mercato/enterprise 0.6.4-develop.4371.1.8f3030407e → 0.6.4

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 (79) hide show
  1. package/.turbo/turbo-build.log +1 -1
  2. package/dist/modules/record_locks/widgets/injection/record-locking/widget.client.js +1 -1
  3. package/dist/modules/record_locks/widgets/injection/record-locking/widget.client.js.map +2 -2
  4. package/dist/modules/security/api/enforcement/[id]/route.js +35 -1
  5. package/dist/modules/security/api/enforcement/[id]/route.js.map +2 -2
  6. package/dist/modules/security/api/enforcement/_shared.js +63 -1
  7. package/dist/modules/security/api/enforcement/_shared.js.map +3 -3
  8. package/dist/modules/security/api/enforcement/compliance/route.js +12 -3
  9. package/dist/modules/security/api/enforcement/compliance/route.js.map +2 -2
  10. package/dist/modules/security/api/enforcement/route.js +25 -2
  11. package/dist/modules/security/api/enforcement/route.js.map +2 -2
  12. package/dist/modules/security/api/mfa/prepare/route.js +1 -1
  13. package/dist/modules/security/api/mfa/prepare/route.js.map +2 -2
  14. package/dist/modules/security/api/mfa/recovery/route.js +1 -1
  15. package/dist/modules/security/api/mfa/recovery/route.js.map +2 -2
  16. package/dist/modules/security/api/mfa/verify/route.js +1 -1
  17. package/dist/modules/security/api/mfa/verify/route.js.map +2 -2
  18. package/dist/modules/security/api/users/[id]/mfa/reset/route.js +6 -1
  19. package/dist/modules/security/api/users/[id]/mfa/reset/route.js.map +2 -2
  20. package/dist/modules/security/api/users/[id]/mfa/status/route.js +13 -2
  21. package/dist/modules/security/api/users/[id]/mfa/status/route.js.map +2 -2
  22. package/dist/modules/security/api/users/_shared.js +56 -1
  23. package/dist/modules/security/api/users/_shared.js.map +2 -2
  24. package/dist/modules/security/api/users/mfa/compliance/route.js +17 -7
  25. package/dist/modules/security/api/users/mfa/compliance/route.js.map +2 -2
  26. package/dist/modules/security/commands/createEnforcementPolicy.js +6 -1
  27. package/dist/modules/security/commands/createEnforcementPolicy.js.map +2 -2
  28. package/dist/modules/security/commands/deleteEnforcementPolicy.js +6 -1
  29. package/dist/modules/security/commands/deleteEnforcementPolicy.js.map +2 -2
  30. package/dist/modules/security/commands/resetUserMfa.js +6 -1
  31. package/dist/modules/security/commands/resetUserMfa.js.map +2 -2
  32. package/dist/modules/security/commands/updateEnforcementPolicy.js +6 -1
  33. package/dist/modules/security/commands/updateEnforcementPolicy.js.map +2 -2
  34. package/dist/modules/security/services/MfaAdminService.js +22 -5
  35. package/dist/modules/security/services/MfaAdminService.js.map +2 -2
  36. package/dist/modules/security/services/MfaEnforcementService.js +28 -6
  37. package/dist/modules/security/services/MfaEnforcementService.js.map +2 -2
  38. package/dist/modules/security/services/MfaVerificationService.js +30 -10
  39. package/dist/modules/security/services/MfaVerificationService.js.map +2 -2
  40. package/dist/modules/security/services/SudoChallengeService.js +14 -3
  41. package/dist/modules/security/services/SudoChallengeService.js.map +2 -2
  42. package/dist/modules/sso/api/callback/oidc/route.js +2 -2
  43. package/dist/modules/sso/api/callback/oidc/route.js.map +2 -2
  44. package/dist/modules/sso/i18n/de.json +2 -0
  45. package/dist/modules/sso/i18n/en.json +2 -0
  46. package/dist/modules/sso/i18n/es.json +2 -0
  47. package/dist/modules/sso/i18n/pl.json +2 -0
  48. package/dist/modules/sso/lib/errors.js +21 -0
  49. package/dist/modules/sso/lib/errors.js.map +7 -0
  50. package/dist/modules/sso/services/accountLinkingService.js +2 -1
  51. package/dist/modules/sso/services/accountLinkingService.js.map +2 -2
  52. package/package.json +7 -8
  53. package/src/modules/record_locks/widgets/injection/record-locking/widget.client.tsx +1 -1
  54. package/src/modules/security/api/enforcement/[id]/route.ts +50 -1
  55. package/src/modules/security/api/enforcement/_shared.ts +83 -2
  56. package/src/modules/security/api/enforcement/compliance/route.ts +10 -1
  57. package/src/modules/security/api/enforcement/route.ts +30 -2
  58. package/src/modules/security/api/mfa/prepare/route.ts +1 -1
  59. package/src/modules/security/api/mfa/recovery/route.ts +1 -1
  60. package/src/modules/security/api/mfa/verify/route.ts +1 -1
  61. package/src/modules/security/api/users/[id]/mfa/reset/route.ts +6 -1
  62. package/src/modules/security/api/users/[id]/mfa/status/route.ts +13 -2
  63. package/src/modules/security/api/users/_shared.ts +69 -1
  64. package/src/modules/security/api/users/mfa/compliance/route.ts +16 -7
  65. package/src/modules/security/commands/createEnforcementPolicy.ts +6 -1
  66. package/src/modules/security/commands/deleteEnforcementPolicy.ts +6 -1
  67. package/src/modules/security/commands/resetUserMfa.ts +6 -1
  68. package/src/modules/security/commands/updateEnforcementPolicy.ts +6 -1
  69. package/src/modules/security/services/MfaAdminService.ts +29 -6
  70. package/src/modules/security/services/MfaEnforcementService.ts +42 -2
  71. package/src/modules/security/services/MfaVerificationService.ts +33 -10
  72. package/src/modules/security/services/SudoChallengeService.ts +16 -11
  73. package/src/modules/sso/api/callback/oidc/route.ts +2 -2
  74. package/src/modules/sso/i18n/de.json +2 -0
  75. package/src/modules/sso/i18n/en.json +2 -0
  76. package/src/modules/sso/i18n/es.json +2 -0
  77. package/src/modules/sso/i18n/pl.json +2 -0
  78. package/src/modules/sso/lib/errors.ts +35 -0
  79. package/src/modules/sso/services/accountLinkingService.ts +2 -1
@@ -0,0 +1,21 @@
1
+ var _a, _b;
2
+ const EMAIL_NOT_VERIFIED_ERROR_MARKER = /* @__PURE__ */ Symbol.for("@open-mercato/sso/EmailNotVerifiedError");
3
+ class EmailNotVerifiedError extends (_b = Error, _a = EMAIL_NOT_VERIFIED_ERROR_MARKER, _b) {
4
+ constructor(message, options) {
5
+ super(message, options);
6
+ this[_a] = true;
7
+ this.name = "EmailNotVerifiedError";
8
+ }
9
+ }
10
+ function isEmailNotVerifiedError(err) {
11
+ return !!err && typeof err === "object" && err[EMAIL_NOT_VERIFIED_ERROR_MARKER] === true;
12
+ }
13
+ function resolveSsoCallbackErrorCode(err) {
14
+ return isEmailNotVerifiedError(err) ? "sso_email_not_verified" : "sso_failed";
15
+ }
16
+ export {
17
+ EmailNotVerifiedError,
18
+ isEmailNotVerifiedError,
19
+ resolveSsoCallbackErrorCode
20
+ };
21
+ //# sourceMappingURL=errors.js.map
@@ -0,0 +1,7 @@
1
+ {
2
+ "version": 3,
3
+ "sources": ["../../../../src/modules/sso/lib/errors.ts"],
4
+ "sourcesContent": ["// Use Symbol.for so the marker survives module duplication across bundle\n// boundaries: the OIDC callback route and the account-linking service can be\n// bundled into separate chunks where `instanceof` silently returns false\n// (same rationale as isCrudHttpError in @open-mercato/shared).\nconst EMAIL_NOT_VERIFIED_ERROR_MARKER = Symbol.for('@open-mercato/sso/EmailNotVerifiedError')\n\nexport class EmailNotVerifiedError extends Error {\n readonly [EMAIL_NOT_VERIFIED_ERROR_MARKER] = true\n\n constructor(message: string, options?: { cause?: unknown }) {\n super(message, options)\n this.name = 'EmailNotVerifiedError'\n }\n}\n\n/**\n * Type-safe check that works across module/bundle boundaries. Prefer this over\n * `instanceof EmailNotVerifiedError` because the SSO callback route may be\n * bundled separately from the service that throws the error.\n */\nexport function isEmailNotVerifiedError(err: unknown): err is EmailNotVerifiedError {\n return !!err && typeof err === 'object' && (err as Record<symbol, unknown>)[EMAIL_NOT_VERIFIED_ERROR_MARKER] === true\n}\n\nexport type SsoCallbackErrorCode = 'sso_email_not_verified' | 'sso_failed'\n\n/**\n * Maps an error thrown during the OIDC callback to the login-page UX error code.\n * Keyed off the error type rather than a substring of the human-readable message,\n * which previously drifted out of sync and left `sso_email_not_verified`\n * unreachable (#2741).\n */\nexport function resolveSsoCallbackErrorCode(err: unknown): SsoCallbackErrorCode {\n return isEmailNotVerifiedError(err) ? 'sso_email_not_verified' : 'sso_failed'\n}\n"],
5
+ "mappings": "AAAA;AAIA,MAAM,kCAAkC,uBAAO,IAAI,yCAAyC;AAErF,MAAM,+BAA8B,YAC/B,sCAD+B,IAAM;AAAA,EAG/C,YAAY,SAAiB,SAA+B;AAC1D,UAAM,SAAS,OAAO;AAHxB,SAAU,MAAmC;AAI3C,SAAK,OAAO;AAAA,EACd;AACF;AAOO,SAAS,wBAAwB,KAA4C;AAClF,SAAO,CAAC,CAAC,OAAO,OAAO,QAAQ,YAAa,IAAgC,+BAA+B,MAAM;AACnH;AAUO,SAAS,4BAA4B,KAAoC;AAC9E,SAAO,wBAAwB,GAAG,IAAI,2BAA2B;AACnE;",
6
+ "names": []
7
+ }
@@ -3,6 +3,7 @@ import { findOneWithDecryption } from "@open-mercato/shared/lib/encryption/find"
3
3
  import { computeEmailHash } from "@open-mercato/core/modules/auth/lib/emailHash";
4
4
  import { SsoIdentity, SsoRoleGrant, ScimToken } from "../data/entities.js";
5
5
  import { emitSsoEvent } from "../events.js";
6
+ import { EmailNotVerifiedError } from "../lib/errors.js";
6
7
  class AccountLinkingService {
7
8
  constructor(em) {
8
9
  this.em = em;
@@ -14,7 +15,7 @@ class AccountLinkingService {
14
15
  return existing;
15
16
  }
16
17
  if (idpPayload.emailVerified === false) {
17
- throw new Error("IdP explicitly reported email as unverified \u2014 cannot link or provision account");
18
+ throw new EmailNotVerifiedError("IdP explicitly reported email as unverified \u2014 cannot link or provision account");
18
19
  }
19
20
  const emailDomain = idpPayload.email.split("@")[1]?.toLowerCase();
20
21
  if (!emailDomain || !config.allowedDomains.some((d) => d.toLowerCase() === emailDomain)) {
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "version": 3,
3
3
  "sources": ["../../../../src/modules/sso/services/accountLinkingService.ts"],
4
- "sourcesContent": ["import { EntityManager, type FilterQuery, type RequiredEntityData } from '@mikro-orm/postgresql'\nimport { User, UserRole, Role } from '@open-mercato/core/modules/auth/data/entities'\nimport { findOneWithDecryption } from '@open-mercato/shared/lib/encryption/find'\nimport { computeEmailHash } from '@open-mercato/core/modules/auth/lib/emailHash'\nimport { SsoConfig, SsoIdentity, SsoRoleGrant, ScimToken } from '../data/entities'\nimport { emitSsoEvent } from '../events'\nimport type { SsoIdentityPayload } from '../lib/types'\n\nexport class AccountLinkingService {\n constructor(private em: EntityManager) {}\n\n async resolveUser(\n config: SsoConfig,\n idpPayload: SsoIdentityPayload,\n tenantId: string,\n ): Promise<{ user: User; identity: SsoIdentity }> {\n const existing = await this.findExistingLink(config.id, idpPayload.subject, tenantId, config.organizationId)\n if (existing) {\n await this.assignRolesFromSso(this.em, existing.user, config, tenantId, idpPayload.groups)\n return existing\n }\n\n if (idpPayload.emailVerified === false) {\n throw new Error('IdP explicitly reported email as unverified \u2014 cannot link or provision account')\n }\n\n const emailDomain = idpPayload.email.split('@')[1]?.toLowerCase()\n if (!emailDomain || !config.allowedDomains.some((d) => d.toLowerCase() === emailDomain)) {\n throw new Error('Email domain is not in the allowed domains for this SSO configuration')\n }\n\n const emailLinked = config.autoLinkByEmail\n ? await this.linkByEmail(config, idpPayload, tenantId)\n : null\n if (emailLinked) {\n await this.assignRolesFromSso(this.em, emailLinked.user, config, tenantId, idpPayload.groups)\n return emailLinked\n }\n\n if (config.jitEnabled) {\n const scimActive = await this.em.count(ScimToken, { ssoConfigId: config.id, isActive: true }) > 0\n if (scimActive) {\n throw new Error('JIT provisioning is disabled because SCIM directory sync is active')\n }\n return this.jitProvision(config, idpPayload, tenantId)\n }\n\n throw new Error('No matching user found and JIT provisioning is disabled')\n }\n\n private async findExistingLink(\n ssoConfigId: string,\n idpSubject: string,\n tenantId: string,\n organizationId: string,\n ): Promise<{ user: User; identity: SsoIdentity } | null> {\n const identity = await findOneWithDecryption(\n this.em,\n SsoIdentity,\n { ssoConfigId, idpSubject, deletedAt: null },\n {},\n { tenantId, organizationId },\n )\n if (!identity) return null\n\n const user = await findOneWithDecryption(\n this.em,\n User,\n { id: identity.userId, deletedAt: null },\n {},\n { tenantId, organizationId },\n )\n if (!user) {\n identity.deletedAt = new Date()\n await this.em.flush()\n return null\n }\n\n identity.lastLoginAt = new Date()\n await this.em.flush()\n\n return { user, identity }\n }\n\n private async linkByEmail(\n config: SsoConfig,\n idpPayload: SsoIdentityPayload,\n tenantId: string,\n ): Promise<{ user: User; identity: SsoIdentity } | null> {\n const emailHash = computeEmailHash(idpPayload.email)\n const user = await findOneWithDecryption(\n this.em,\n User,\n {\n organizationId: config.organizationId,\n deletedAt: null,\n $or: [\n { email: idpPayload.email },\n { emailHash },\n ],\n } as FilterQuery<User>,\n {},\n { tenantId, organizationId: config.organizationId },\n )\n if (!user) return null\n\n const now = new Date()\n const identity = this.em.create(SsoIdentity, {\n tenantId,\n organizationId: config.organizationId,\n ssoConfigId: config.id,\n userId: user.id,\n idpSubject: idpPayload.subject,\n idpEmail: idpPayload.email,\n idpName: idpPayload.name ?? null,\n idpGroups: idpPayload.groups ?? [],\n provisioningMethod: 'manual',\n firstLoginAt: now,\n lastLoginAt: now,\n createdAt: now,\n updatedAt: now,\n } as RequiredEntityData<SsoIdentity>)\n await this.em.persist(identity).flush()\n\n void emitSsoEvent('sso.identity.linked', {\n id: identity.id,\n tenantId,\n organizationId: config.organizationId,\n }).catch((e) => console.error('[SSO Event]', e))\n\n return { user, identity }\n }\n\n private async jitProvision(\n config: SsoConfig,\n idpPayload: SsoIdentityPayload,\n tenantId: string,\n ): Promise<{ user: User; identity: SsoIdentity }> {\n return this.em.transactional(async (txEm) => {\n const user = txEm.create(User, {\n tenantId,\n organizationId: config.organizationId,\n email: idpPayload.email,\n emailHash: computeEmailHash(idpPayload.email),\n name: idpPayload.name ?? null,\n passwordHash: null,\n isConfirmed: true,\n createdAt: new Date(),\n })\n await txEm.persist(user).flush()\n\n await this.assignRolesFromSso(txEm, user, config, tenantId, idpPayload.groups)\n\n const now = new Date()\n const identity = txEm.create(SsoIdentity, {\n tenantId,\n organizationId: config.organizationId,\n ssoConfigId: config.id,\n userId: user.id,\n idpSubject: idpPayload.subject,\n idpEmail: idpPayload.email,\n idpName: idpPayload.name ?? null,\n idpGroups: idpPayload.groups ?? [],\n provisioningMethod: 'jit',\n firstLoginAt: now,\n lastLoginAt: now,\n createdAt: now,\n updatedAt: now,\n } as RequiredEntityData<SsoIdentity>)\n await txEm.persist(identity).flush()\n\n void emitSsoEvent('sso.identity.created', {\n id: identity.id,\n tenantId,\n organizationId: config.organizationId,\n }).catch((e) => console.error('[SSO Event]', e))\n\n return { user, identity }\n })\n }\n\n private async assignRolesFromSso(\n em: EntityManager,\n user: User,\n config: SsoConfig,\n tenantId: string,\n idpGroups?: string[],\n ): Promise<void> {\n const hasMappings = config.appRoleMappings && Object.keys(config.appRoleMappings).length > 0\n if (!hasMappings) return\n\n await this.syncMappedRoles(em, user, config, tenantId, idpGroups)\n\n const hasAnySsoRole = await em.findOne(SsoRoleGrant, {\n userId: user.id,\n ssoConfigId: config.id,\n })\n if (!hasAnySsoRole) {\n throw new Error('No roles could be resolved from IdP groups \u2014 login denied. Configure role mappings or ensure the IdP sends matching group claims.')\n }\n }\n\n /**\n * Sync/replace SSO-sourced roles: on each login, SSO-managed roles are replaced\n * with what the IdP sends, while manually-assigned roles are preserved.\n */\n private async syncMappedRoles(\n em: EntityManager,\n user: User,\n config: SsoConfig,\n tenantId: string,\n idpGroups?: string[],\n ): Promise<void> {\n const resolvedTenantId = tenantId || user.tenantId || ''\n if (!resolvedTenantId) return\n\n const allRoles = await em.find(Role, { tenantId: resolvedTenantId, deletedAt: null } as FilterQuery<Role>)\n const roleByNormalizedName = new Map<string, Role>()\n for (const role of allRoles) {\n const normalized = normalizeToken(role.name)\n if (normalized) roleByNormalizedName.set(normalized, role)\n }\n\n // Resolve desired role IDs from IdP groups using merged mappings\n const desiredRoleNames = resolveRoleNamesFromIdpGroups(idpGroups, config.appRoleMappings)\n const desiredRoleIds = new Set<string>()\n for (const roleName of desiredRoleNames) {\n const role = roleByNormalizedName.get(roleName)\n if (role) desiredRoleIds.add(role.id)\n }\n\n // Query current SSO grants for this user+config\n const existingGrants = await em.find(SsoRoleGrant, {\n userId: user.id,\n ssoConfigId: config.id,\n })\n const existingGrantedRoleIds = new Set(existingGrants.map((g) => g.roleId))\n\n // Compute diff\n const toAdd = [...desiredRoleIds].filter((id) => !existingGrantedRoleIds.has(id))\n const toRemove = existingGrants.filter((g) => !desiredRoleIds.has(g.roleId))\n\n // Add new roles\n for (const roleId of toAdd) {\n const role = allRoles.find((r) => r.id === roleId)\n if (!role) continue\n await this.ensureUserRole(em, user, role)\n const grant = em.create(SsoRoleGrant, {\n tenantId: resolvedTenantId,\n organizationId: config.organizationId,\n userId: user.id,\n roleId,\n ssoConfigId: config.id,\n } as RequiredEntityData<SsoRoleGrant>)\n em.persist(grant)\n }\n\n // Remove stale SSO-sourced roles\n for (const grant of toRemove) {\n const userRole = await em.findOne(UserRole, {\n user: user.id,\n role: grant.roleId,\n deletedAt: null,\n } as FilterQuery<UserRole>)\n if (userRole) {\n em.remove(userRole)\n }\n em.remove(grant)\n }\n\n // Clean up orphaned soft-deleted UserRole rows (ghost rows from previous soft-delete logic)\n const allUserRoles = await em.find(UserRole, { user: user.id } as FilterQuery<UserRole>)\n for (const ur of allUserRoles) {\n if (ur.deletedAt) {\n em.remove(ur)\n }\n }\n\n if (toAdd.length > 0 || toRemove.length > 0 || allUserRoles.some((ur) => ur.deletedAt)) {\n await em.flush()\n }\n }\n\n private async ensureUserRole(em: EntityManager, user: User, role: Role): Promise<void> {\n const existingLink = await em.findOne(UserRole, {\n user: user.id,\n role: role.id,\n deletedAt: null,\n } as FilterQuery<UserRole>)\n if (existingLink) return\n\n const userRole = em.create(UserRole, { user, role, createdAt: new Date() })\n await em.persist(userRole).flush()\n }\n}\n\nfunction resolveRoleNamesFromIdpGroups(\n idpGroups?: string[],\n configMappings?: Record<string, string>,\n): string[] {\n if (!Array.isArray(idpGroups) || idpGroups.length === 0) return []\n\n const normalizedGroups = idpGroups\n .map((group) => normalizeToken(group))\n .filter((group): group is string => group !== null)\n if (normalizedGroups.length === 0) return []\n\n const mergedMappings = loadMergedMappings(configMappings)\n const roleNames = new Set<string>()\n\n for (const group of normalizedGroups) {\n const mapped = mergedMappings.get(group)\n if (mapped?.length) {\n for (const role of mapped) roleNames.add(role)\n continue\n }\n\n roleNames.add(group)\n const segmented = group.split(/[\\\\/:]/).map((part) => normalizeToken(part)).filter((part): part is string => part !== null)\n for (const candidate of segmented) {\n roleNames.add(candidate)\n }\n }\n\n return Array.from(roleNames)\n}\n\nfunction loadMergedMappings(configMappings?: Record<string, string>): Map<string, string[]> {\n const envMappings = loadGroupRoleMappingsFromEnv()\n\n // Per-config mappings take precedence over env var\n if (configMappings && Object.keys(configMappings).length > 0) {\n for (const [group, roleName] of Object.entries(configMappings)) {\n const normalizedGroup = normalizeToken(group)\n if (!normalizedGroup) continue\n const normalizedRole = normalizeToken(roleName)\n if (!normalizedRole) continue\n envMappings.set(normalizedGroup, [normalizedRole])\n }\n }\n\n return envMappings\n}\n\nfunction loadGroupRoleMappingsFromEnv(): Map<string, string[]> {\n const raw = process.env.SSO_GROUP_ROLE_MAP\n if (!raw) return new Map()\n\n try {\n const parsed = JSON.parse(raw) as Record<string, unknown>\n const out = new Map<string, string[]>()\n for (const [group, roleValue] of Object.entries(parsed)) {\n const normalizedGroup = normalizeToken(group)\n if (!normalizedGroup) continue\n const roles = normalizeRoleList(roleValue)\n if (roles.length > 0) out.set(normalizedGroup, roles)\n }\n return out\n } catch {\n return new Map()\n }\n}\n\nfunction normalizeRoleList(value: unknown): string[] {\n if (typeof value === 'string') {\n const token = normalizeToken(value)\n return token ? [token] : []\n }\n\n if (Array.isArray(value)) {\n const out = new Set<string>()\n for (const entry of value) {\n const token = normalizeToken(entry)\n if (token) out.add(token)\n }\n return Array.from(out)\n }\n\n return []\n}\n\nfunction normalizeToken(value: unknown): string | null {\n if (typeof value !== 'string') return null\n const normalized = value.trim().toLowerCase()\n return normalized.length > 0 ? normalized : null\n}\n"],
5
- "mappings": "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",
4
+ "sourcesContent": ["import { EntityManager, type FilterQuery, type RequiredEntityData } from '@mikro-orm/postgresql'\nimport { User, UserRole, Role } from '@open-mercato/core/modules/auth/data/entities'\nimport { findOneWithDecryption } from '@open-mercato/shared/lib/encryption/find'\nimport { computeEmailHash } from '@open-mercato/core/modules/auth/lib/emailHash'\nimport { SsoConfig, SsoIdentity, SsoRoleGrant, ScimToken } from '../data/entities'\nimport { emitSsoEvent } from '../events'\nimport { EmailNotVerifiedError } from '../lib/errors'\nimport type { SsoIdentityPayload } from '../lib/types'\n\nexport class AccountLinkingService {\n constructor(private em: EntityManager) {}\n\n async resolveUser(\n config: SsoConfig,\n idpPayload: SsoIdentityPayload,\n tenantId: string,\n ): Promise<{ user: User; identity: SsoIdentity }> {\n const existing = await this.findExistingLink(config.id, idpPayload.subject, tenantId, config.organizationId)\n if (existing) {\n await this.assignRolesFromSso(this.em, existing.user, config, tenantId, idpPayload.groups)\n return existing\n }\n\n if (idpPayload.emailVerified === false) {\n throw new EmailNotVerifiedError('IdP explicitly reported email as unverified \u2014 cannot link or provision account')\n }\n\n const emailDomain = idpPayload.email.split('@')[1]?.toLowerCase()\n if (!emailDomain || !config.allowedDomains.some((d) => d.toLowerCase() === emailDomain)) {\n throw new Error('Email domain is not in the allowed domains for this SSO configuration')\n }\n\n const emailLinked = config.autoLinkByEmail\n ? await this.linkByEmail(config, idpPayload, tenantId)\n : null\n if (emailLinked) {\n await this.assignRolesFromSso(this.em, emailLinked.user, config, tenantId, idpPayload.groups)\n return emailLinked\n }\n\n if (config.jitEnabled) {\n const scimActive = await this.em.count(ScimToken, { ssoConfigId: config.id, isActive: true }) > 0\n if (scimActive) {\n throw new Error('JIT provisioning is disabled because SCIM directory sync is active')\n }\n return this.jitProvision(config, idpPayload, tenantId)\n }\n\n throw new Error('No matching user found and JIT provisioning is disabled')\n }\n\n private async findExistingLink(\n ssoConfigId: string,\n idpSubject: string,\n tenantId: string,\n organizationId: string,\n ): Promise<{ user: User; identity: SsoIdentity } | null> {\n const identity = await findOneWithDecryption(\n this.em,\n SsoIdentity,\n { ssoConfigId, idpSubject, deletedAt: null },\n {},\n { tenantId, organizationId },\n )\n if (!identity) return null\n\n const user = await findOneWithDecryption(\n this.em,\n User,\n { id: identity.userId, deletedAt: null },\n {},\n { tenantId, organizationId },\n )\n if (!user) {\n identity.deletedAt = new Date()\n await this.em.flush()\n return null\n }\n\n identity.lastLoginAt = new Date()\n await this.em.flush()\n\n return { user, identity }\n }\n\n private async linkByEmail(\n config: SsoConfig,\n idpPayload: SsoIdentityPayload,\n tenantId: string,\n ): Promise<{ user: User; identity: SsoIdentity } | null> {\n const emailHash = computeEmailHash(idpPayload.email)\n const user = await findOneWithDecryption(\n this.em,\n User,\n {\n organizationId: config.organizationId,\n deletedAt: null,\n $or: [\n { email: idpPayload.email },\n { emailHash },\n ],\n } as FilterQuery<User>,\n {},\n { tenantId, organizationId: config.organizationId },\n )\n if (!user) return null\n\n const now = new Date()\n const identity = this.em.create(SsoIdentity, {\n tenantId,\n organizationId: config.organizationId,\n ssoConfigId: config.id,\n userId: user.id,\n idpSubject: idpPayload.subject,\n idpEmail: idpPayload.email,\n idpName: idpPayload.name ?? null,\n idpGroups: idpPayload.groups ?? [],\n provisioningMethod: 'manual',\n firstLoginAt: now,\n lastLoginAt: now,\n createdAt: now,\n updatedAt: now,\n } as RequiredEntityData<SsoIdentity>)\n await this.em.persist(identity).flush()\n\n void emitSsoEvent('sso.identity.linked', {\n id: identity.id,\n tenantId,\n organizationId: config.organizationId,\n }).catch((e) => console.error('[SSO Event]', e))\n\n return { user, identity }\n }\n\n private async jitProvision(\n config: SsoConfig,\n idpPayload: SsoIdentityPayload,\n tenantId: string,\n ): Promise<{ user: User; identity: SsoIdentity }> {\n return this.em.transactional(async (txEm) => {\n const user = txEm.create(User, {\n tenantId,\n organizationId: config.organizationId,\n email: idpPayload.email,\n emailHash: computeEmailHash(idpPayload.email),\n name: idpPayload.name ?? null,\n passwordHash: null,\n isConfirmed: true,\n createdAt: new Date(),\n })\n await txEm.persist(user).flush()\n\n await this.assignRolesFromSso(txEm, user, config, tenantId, idpPayload.groups)\n\n const now = new Date()\n const identity = txEm.create(SsoIdentity, {\n tenantId,\n organizationId: config.organizationId,\n ssoConfigId: config.id,\n userId: user.id,\n idpSubject: idpPayload.subject,\n idpEmail: idpPayload.email,\n idpName: idpPayload.name ?? null,\n idpGroups: idpPayload.groups ?? [],\n provisioningMethod: 'jit',\n firstLoginAt: now,\n lastLoginAt: now,\n createdAt: now,\n updatedAt: now,\n } as RequiredEntityData<SsoIdentity>)\n await txEm.persist(identity).flush()\n\n void emitSsoEvent('sso.identity.created', {\n id: identity.id,\n tenantId,\n organizationId: config.organizationId,\n }).catch((e) => console.error('[SSO Event]', e))\n\n return { user, identity }\n })\n }\n\n private async assignRolesFromSso(\n em: EntityManager,\n user: User,\n config: SsoConfig,\n tenantId: string,\n idpGroups?: string[],\n ): Promise<void> {\n const hasMappings = config.appRoleMappings && Object.keys(config.appRoleMappings).length > 0\n if (!hasMappings) return\n\n await this.syncMappedRoles(em, user, config, tenantId, idpGroups)\n\n const hasAnySsoRole = await em.findOne(SsoRoleGrant, {\n userId: user.id,\n ssoConfigId: config.id,\n })\n if (!hasAnySsoRole) {\n throw new Error('No roles could be resolved from IdP groups \u2014 login denied. Configure role mappings or ensure the IdP sends matching group claims.')\n }\n }\n\n /**\n * Sync/replace SSO-sourced roles: on each login, SSO-managed roles are replaced\n * with what the IdP sends, while manually-assigned roles are preserved.\n */\n private async syncMappedRoles(\n em: EntityManager,\n user: User,\n config: SsoConfig,\n tenantId: string,\n idpGroups?: string[],\n ): Promise<void> {\n const resolvedTenantId = tenantId || user.tenantId || ''\n if (!resolvedTenantId) return\n\n const allRoles = await em.find(Role, { tenantId: resolvedTenantId, deletedAt: null } as FilterQuery<Role>)\n const roleByNormalizedName = new Map<string, Role>()\n for (const role of allRoles) {\n const normalized = normalizeToken(role.name)\n if (normalized) roleByNormalizedName.set(normalized, role)\n }\n\n // Resolve desired role IDs from IdP groups using merged mappings\n const desiredRoleNames = resolveRoleNamesFromIdpGroups(idpGroups, config.appRoleMappings)\n const desiredRoleIds = new Set<string>()\n for (const roleName of desiredRoleNames) {\n const role = roleByNormalizedName.get(roleName)\n if (role) desiredRoleIds.add(role.id)\n }\n\n // Query current SSO grants for this user+config\n const existingGrants = await em.find(SsoRoleGrant, {\n userId: user.id,\n ssoConfigId: config.id,\n })\n const existingGrantedRoleIds = new Set(existingGrants.map((g) => g.roleId))\n\n // Compute diff\n const toAdd = [...desiredRoleIds].filter((id) => !existingGrantedRoleIds.has(id))\n const toRemove = existingGrants.filter((g) => !desiredRoleIds.has(g.roleId))\n\n // Add new roles\n for (const roleId of toAdd) {\n const role = allRoles.find((r) => r.id === roleId)\n if (!role) continue\n await this.ensureUserRole(em, user, role)\n const grant = em.create(SsoRoleGrant, {\n tenantId: resolvedTenantId,\n organizationId: config.organizationId,\n userId: user.id,\n roleId,\n ssoConfigId: config.id,\n } as RequiredEntityData<SsoRoleGrant>)\n em.persist(grant)\n }\n\n // Remove stale SSO-sourced roles\n for (const grant of toRemove) {\n const userRole = await em.findOne(UserRole, {\n user: user.id,\n role: grant.roleId,\n deletedAt: null,\n } as FilterQuery<UserRole>)\n if (userRole) {\n em.remove(userRole)\n }\n em.remove(grant)\n }\n\n // Clean up orphaned soft-deleted UserRole rows (ghost rows from previous soft-delete logic)\n const allUserRoles = await em.find(UserRole, { user: user.id } as FilterQuery<UserRole>)\n for (const ur of allUserRoles) {\n if (ur.deletedAt) {\n em.remove(ur)\n }\n }\n\n if (toAdd.length > 0 || toRemove.length > 0 || allUserRoles.some((ur) => ur.deletedAt)) {\n await em.flush()\n }\n }\n\n private async ensureUserRole(em: EntityManager, user: User, role: Role): Promise<void> {\n const existingLink = await em.findOne(UserRole, {\n user: user.id,\n role: role.id,\n deletedAt: null,\n } as FilterQuery<UserRole>)\n if (existingLink) return\n\n const userRole = em.create(UserRole, { user, role, createdAt: new Date() })\n await em.persist(userRole).flush()\n }\n}\n\nfunction resolveRoleNamesFromIdpGroups(\n idpGroups?: string[],\n configMappings?: Record<string, string>,\n): string[] {\n if (!Array.isArray(idpGroups) || idpGroups.length === 0) return []\n\n const normalizedGroups = idpGroups\n .map((group) => normalizeToken(group))\n .filter((group): group is string => group !== null)\n if (normalizedGroups.length === 0) return []\n\n const mergedMappings = loadMergedMappings(configMappings)\n const roleNames = new Set<string>()\n\n for (const group of normalizedGroups) {\n const mapped = mergedMappings.get(group)\n if (mapped?.length) {\n for (const role of mapped) roleNames.add(role)\n continue\n }\n\n roleNames.add(group)\n const segmented = group.split(/[\\\\/:]/).map((part) => normalizeToken(part)).filter((part): part is string => part !== null)\n for (const candidate of segmented) {\n roleNames.add(candidate)\n }\n }\n\n return Array.from(roleNames)\n}\n\nfunction loadMergedMappings(configMappings?: Record<string, string>): Map<string, string[]> {\n const envMappings = loadGroupRoleMappingsFromEnv()\n\n // Per-config mappings take precedence over env var\n if (configMappings && Object.keys(configMappings).length > 0) {\n for (const [group, roleName] of Object.entries(configMappings)) {\n const normalizedGroup = normalizeToken(group)\n if (!normalizedGroup) continue\n const normalizedRole = normalizeToken(roleName)\n if (!normalizedRole) continue\n envMappings.set(normalizedGroup, [normalizedRole])\n }\n }\n\n return envMappings\n}\n\nfunction loadGroupRoleMappingsFromEnv(): Map<string, string[]> {\n const raw = process.env.SSO_GROUP_ROLE_MAP\n if (!raw) return new Map()\n\n try {\n const parsed = JSON.parse(raw) as Record<string, unknown>\n const out = new Map<string, string[]>()\n for (const [group, roleValue] of Object.entries(parsed)) {\n const normalizedGroup = normalizeToken(group)\n if (!normalizedGroup) continue\n const roles = normalizeRoleList(roleValue)\n if (roles.length > 0) out.set(normalizedGroup, roles)\n }\n return out\n } catch {\n return new Map()\n }\n}\n\nfunction normalizeRoleList(value: unknown): string[] {\n if (typeof value === 'string') {\n const token = normalizeToken(value)\n return token ? [token] : []\n }\n\n if (Array.isArray(value)) {\n const out = new Set<string>()\n for (const entry of value) {\n const token = normalizeToken(entry)\n if (token) out.add(token)\n }\n return Array.from(out)\n }\n\n return []\n}\n\nfunction normalizeToken(value: unknown): string | null {\n if (typeof value !== 'string') return null\n const normalized = value.trim().toLowerCase()\n return normalized.length > 0 ? normalized : null\n}\n"],
5
+ "mappings": "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",
6
6
  "names": []
7
7
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@open-mercato/enterprise",
3
- "version": "0.6.4-develop.4371.1.8f3030407e",
3
+ "version": "0.6.4",
4
4
  "type": "module",
5
5
  "main": "./dist/index.js",
6
6
  "scripts": {
@@ -64,8 +64,8 @@
64
64
  }
65
65
  },
66
66
  "dependencies": {
67
- "@open-mercato/core": "0.6.4-develop.4371.1.8f3030407e",
68
- "@open-mercato/ui": "0.6.4-develop.4371.1.8f3030407e",
67
+ "@open-mercato/core": "0.6.4",
68
+ "@open-mercato/ui": "0.6.4",
69
69
  "@simplewebauthn/browser": "^13.3.0",
70
70
  "@simplewebauthn/server": "^13.3.1",
71
71
  "@simplewebauthn/types": "^12.0.0",
@@ -75,14 +75,14 @@
75
75
  "qrcode": "^1.5.4"
76
76
  },
77
77
  "peerDependencies": {
78
- "@open-mercato/shared": "0.6.4-develop.4371.1.8f3030407e",
78
+ "@open-mercato/shared": "0.6.4",
79
79
  "react": "^19.0.0",
80
80
  "react-dom": "^19.0.0"
81
81
  },
82
82
  "devDependencies": {
83
- "@open-mercato/shared": "0.6.4-develop.4371.1.8f3030407e",
83
+ "@open-mercato/shared": "0.6.4",
84
84
  "@types/jest": "^30.0.0",
85
- "@types/react": "^19.2.16",
85
+ "@types/react": "^19.2.17",
86
86
  "@types/react-dom": "^19.2.3",
87
87
  "jest": "^30.4.2",
88
88
  "react": "19.2.7",
@@ -96,6 +96,5 @@
96
96
  "type": "git",
97
97
  "url": "https://github.com/open-mercato/open-mercato",
98
98
  "directory": "packages/enterprise"
99
- },
100
- "stableVersion": "0.6.3"
99
+ }
101
100
  }
@@ -1226,7 +1226,7 @@ export default function RecordLockingWidget({
1226
1226
  }
1227
1227
  setIsConflictDialogOpen(false)
1228
1228
  }}>
1229
- <DialogContent>
1229
+ <DialogContent data-testid="record-lock-conflict-dialog">
1230
1230
  <DialogHeader>
1231
1231
  <DialogTitle>
1232
1232
  {isRecordDeleted
@@ -2,14 +2,48 @@ import { NextResponse } from 'next/server'
2
2
  import { z } from 'zod'
3
3
  import type { CommandBus } from '@open-mercato/shared/lib/commands'
4
4
  import { updateEnforcementPolicySchema } from '../../../data/validators'
5
+ import { EnforcementScope } from '../../../data/entities'
6
+ import type { MfaEnforcementPolicy } from '../../../data/entities'
7
+ import type { UpdateEnforcementPolicyInput } from '../../../data/validators'
5
8
  import { buildSecurityOpenApi, securityErrorSchema } from '../../openapi'
6
9
  import { securityApiError } from '../../i18n'
7
- import { mapEnforcementError, resolveEnforcementContext } from '../_shared'
10
+ import {
11
+ assertActorOwnsEnforcementScope,
12
+ mapEnforcementError,
13
+ resolveEnforcementContext,
14
+ type EnforcementRequestContext,
15
+ } from '../_shared'
8
16
 
9
17
  const paramsSchema = z.object({
10
18
  id: z.string().uuid(),
11
19
  })
12
20
 
21
+ function scopeIdFromPolicy(policy: {
22
+ scope: EnforcementScope
23
+ tenantId?: string | null
24
+ organizationId?: string | null
25
+ }): string | null {
26
+ if (policy.scope === EnforcementScope.TENANT) {
27
+ return policy.tenantId ?? null
28
+ }
29
+ if (policy.scope === EnforcementScope.ORGANISATION) {
30
+ if (!policy.tenantId || !policy.organizationId) return null
31
+ return `${policy.tenantId}:${policy.organizationId}`
32
+ }
33
+ return null
34
+ }
35
+
36
+ async function assertActorOwnsRequestedPolicyScope(
37
+ context: EnforcementRequestContext,
38
+ current: MfaEnforcementPolicy,
39
+ data: UpdateEnforcementPolicyInput,
40
+ ): Promise<void> {
41
+ const scope = data.scope ?? current.scope
42
+ const tenantId = data.tenantId ?? current.tenantId ?? null
43
+ const organizationId = data.organizationId ?? current.organizationId ?? null
44
+ await assertActorOwnsEnforcementScope(context, scope, scopeIdFromPolicy({ scope, tenantId, organizationId }))
45
+ }
46
+
13
47
  const okResponseSchema = z.object({
14
48
  ok: z.literal(true),
15
49
  })
@@ -41,6 +75,13 @@ export async function PUT(req: Request, context: { params: Promise<{ id: string
41
75
  }
42
76
 
43
77
  try {
78
+ const policy = await requestContext.enforcementService.getPolicyById(params.data.id)
79
+ if (!policy) {
80
+ return securityApiError(404, 'Enforcement policy not found')
81
+ }
82
+ await assertActorOwnsEnforcementScope(requestContext, policy.scope, scopeIdFromPolicy(policy))
83
+ await assertActorOwnsRequestedPolicyScope(requestContext, policy, parsedBody.data)
84
+
44
85
  const commandBus = requestContext.container.resolve<CommandBus>('commandBus')
45
86
  const { result } = await commandBus.execute('security.enforcement.update', {
46
87
  input: {
@@ -65,6 +106,12 @@ export async function DELETE(req: Request, context: { params: Promise<{ id: stri
65
106
  }
66
107
 
67
108
  try {
109
+ const policy = await requestContext.enforcementService.getPolicyById(params.data.id)
110
+ if (!policy) {
111
+ return securityApiError(404, 'Enforcement policy not found')
112
+ }
113
+ await assertActorOwnsEnforcementScope(requestContext, policy.scope, scopeIdFromPolicy(policy))
114
+
68
115
  const commandBus = requestContext.container.resolve<CommandBus>('commandBus')
69
116
  const { result } = await commandBus.execute('security.enforcement.delete', {
70
117
  input: { id: params.data.id },
@@ -92,6 +139,7 @@ export const openApi = buildSecurityOpenApi({
92
139
  errors: [
93
140
  { status: 400, description: 'Invalid input', schema: securityErrorSchema },
94
141
  { status: 401, description: 'Unauthorized', schema: securityErrorSchema },
142
+ { status: 403, description: 'Not authorized for the requested scope', schema: securityErrorSchema },
95
143
  { status: 404, description: 'Policy not found', schema: securityErrorSchema },
96
144
  { status: 409, description: 'Conflict', schema: securityErrorSchema },
97
145
  ],
@@ -105,6 +153,7 @@ export const openApi = buildSecurityOpenApi({
105
153
  errors: [
106
154
  { status: 400, description: 'Invalid policy id', schema: securityErrorSchema },
107
155
  { status: 401, description: 'Unauthorized', schema: securityErrorSchema },
156
+ { status: 403, description: 'Not authorized for the requested scope', schema: securityErrorSchema },
108
157
  { status: 404, description: 'Policy not found', schema: securityErrorSchema },
109
158
  ],
110
159
  },
@@ -2,16 +2,23 @@ import { NextResponse } from 'next/server'
2
2
  import type { EntityManager } from '@mikro-orm/postgresql'
3
3
  import type { CommandRuntimeContext } from '@open-mercato/shared/lib/commands'
4
4
  import { Organization, Tenant } from '@open-mercato/core/modules/directory/data/entities'
5
- import { CrudHttpError } from '@open-mercato/shared/lib/crud/errors'
5
+ import { CrudHttpError, forbidden } from '@open-mercato/shared/lib/crud/errors'
6
6
  import { getAuthFromRequest } from '@open-mercato/shared/lib/auth/server'
7
7
  import { createRequestContainer } from '@open-mercato/shared/lib/di/container'
8
- import type { MfaEnforcementPolicy } from '../../data/entities'
8
+ import { enforceTenantSelection, resolveIsSuperAdmin } from '@open-mercato/core/modules/auth/lib/tenantAccess'
9
+ import type { RbacService } from '@open-mercato/core/modules/auth/services/rbacService'
10
+ import { EnforcementScope, type MfaEnforcementPolicy } from '../../data/entities'
9
11
  import type { MfaEnforcementServiceError, MfaEnforcementService } from '../../services/MfaEnforcementService'
10
12
  import { localizeSecurityApiBody, securityApiError } from '../i18n'
11
13
 
12
14
  type RequestContainer = Awaited<ReturnType<typeof createRequestContainer>>
13
15
  type Auth = NonNullable<Awaited<ReturnType<typeof getAuthFromRequest>>>
14
16
 
17
+ export type EnforcementActorContext = {
18
+ tenantId: string | null
19
+ isSuperAdmin: boolean
20
+ }
21
+
15
22
  export type EnforcementRequestContext = {
16
23
  auth: Auth
17
24
  container: RequestContainer
@@ -41,6 +48,80 @@ export async function resolveEnforcementContext(req: Request): Promise<Enforceme
41
48
  }
42
49
  }
43
50
 
51
+ function normalizeNullableString(value: unknown): string | null {
52
+ return typeof value === 'string' && value.trim().length > 0 ? value.trim() : null
53
+ }
54
+
55
+ function normalizeOrganizationList(values: unknown): string[] | null {
56
+ if (values === null || values === undefined) return null
57
+ if (!Array.isArray(values)) return null
58
+ const result: string[] = []
59
+ for (const value of values) {
60
+ if (typeof value !== 'string') continue
61
+ const trimmed = value.trim()
62
+ if (trimmed) result.push(trimmed)
63
+ }
64
+ return result
65
+ }
66
+
67
+ export async function resolveActorContext(ctx: EnforcementRequestContext): Promise<EnforcementActorContext> {
68
+ const isSuperAdmin = await resolveIsSuperAdmin({ auth: ctx.auth, container: ctx.container })
69
+ return {
70
+ tenantId: normalizeNullableString(ctx.auth.tenantId),
71
+ isSuperAdmin,
72
+ }
73
+ }
74
+
75
+ async function assertActorOwnsOrganization(
76
+ ctx: EnforcementRequestContext,
77
+ organizationId: string,
78
+ ): Promise<void> {
79
+ const rbacService = ctx.container.resolve<RbacService>('rbacService')
80
+ const acl = await rbacService.loadAcl(ctx.auth.sub, {
81
+ tenantId: normalizeNullableString(ctx.auth.tenantId),
82
+ organizationId: normalizeNullableString(ctx.auth.orgId),
83
+ })
84
+ const organizations = normalizeOrganizationList(acl?.organizations)
85
+ if (organizations === null || organizations.includes('__all__')) return
86
+ if (!organizations.includes(organizationId)) {
87
+ throw forbidden('Not authorized to target this organization.')
88
+ }
89
+ }
90
+
91
+ export async function assertActorOwnsEnforcementScope(
92
+ ctx: EnforcementRequestContext,
93
+ scope: EnforcementScope,
94
+ scopeId: string | null | undefined,
95
+ ): Promise<void> {
96
+ if (scope === EnforcementScope.PLATFORM) {
97
+ const isSuperAdmin = await resolveIsSuperAdmin({ auth: ctx.auth, container: ctx.container })
98
+ if (!isSuperAdmin) {
99
+ throw forbidden('Platform scope requires platform administrator privileges.')
100
+ }
101
+ return
102
+ }
103
+
104
+ if (scope === EnforcementScope.TENANT) {
105
+ await enforceTenantSelection({ auth: ctx.auth, container: ctx.container }, scopeId)
106
+ return
107
+ }
108
+
109
+ const normalizedScopeId = normalizeNullableString(scopeId)
110
+ if (!normalizedScopeId) {
111
+ throw new CrudHttpError(400, { error: "organisation scopeId must use '<tenantId>:<organizationId>' format" })
112
+ }
113
+ const [tenantId, organizationId] = normalizedScopeId.split(':')
114
+ if (!tenantId || !organizationId) {
115
+ throw new CrudHttpError(400, { error: "organisation scopeId must use '<tenantId>:<organizationId>' format" })
116
+ }
117
+
118
+ await enforceTenantSelection({ auth: ctx.auth, container: ctx.container }, tenantId)
119
+
120
+ const isSuperAdmin = await resolveIsSuperAdmin({ auth: ctx.auth, container: ctx.container })
121
+ if (isSuperAdmin) return
122
+ await assertActorOwnsOrganization(ctx, organizationId)
123
+ }
124
+
44
125
  export async function mapEnforcementError(error: unknown): Promise<NextResponse> {
45
126
  if (error instanceof CrudHttpError) {
46
127
  return NextResponse.json(await localizeSecurityApiBody(error.body), { status: error.status })
@@ -3,7 +3,12 @@ import { z } from 'zod'
3
3
  import { EnforcementScope } from '../../../data/entities'
4
4
  import { buildSecurityOpenApi, securityErrorSchema } from '../../openapi'
5
5
  import { securityApiError } from '../../i18n'
6
- import { mapEnforcementError, resolveEnforcementContext } from '../_shared'
6
+ import {
7
+ assertActorOwnsEnforcementScope,
8
+ mapEnforcementError,
9
+ resolveActorContext,
10
+ resolveEnforcementContext,
11
+ } from '../_shared'
7
12
 
8
13
  const complianceQuerySchema = z.object({
9
14
  scope: z.nativeEnum(EnforcementScope).default(EnforcementScope.PLATFORM),
@@ -37,9 +42,12 @@ export async function GET(req: Request) {
37
42
  }
38
43
 
39
44
  try {
45
+ await assertActorOwnsEnforcementScope(context, parsedQuery.data.scope, parsedQuery.data.scopeId)
46
+ const actor = await resolveActorContext(context)
40
47
  const report = await context.enforcementService.getComplianceReport(
41
48
  parsedQuery.data.scope,
42
49
  parsedQuery.data.scopeId,
50
+ actor,
43
51
  )
44
52
  return NextResponse.json({
45
53
  scope: parsedQuery.data.scope,
@@ -63,6 +71,7 @@ export const openApi = buildSecurityOpenApi({
63
71
  errors: [
64
72
  { status: 400, description: 'Invalid query parameters', schema: securityErrorSchema },
65
73
  { status: 401, description: 'Unauthorized', schema: securityErrorSchema },
74
+ { status: 403, description: 'Not authorized for the requested scope', schema: securityErrorSchema },
66
75
  ],
67
76
  },
68
77
  },
@@ -5,7 +5,13 @@ import { enforcementPolicySchema } from '../../data/validators'
5
5
  import { EnforcementScope } from '../../data/entities'
6
6
  import { buildSecurityOpenApi, securityErrorSchema } from '../openapi'
7
7
  import { securityApiError } from '../i18n'
8
- import { attachPolicyScopeNames, mapEnforcementError, resolveEnforcementContext } from './_shared'
8
+ import {
9
+ assertActorOwnsEnforcementScope,
10
+ attachPolicyScopeNames,
11
+ mapEnforcementError,
12
+ resolveActorContext,
13
+ resolveEnforcementContext,
14
+ } from './_shared'
9
15
 
10
16
  const enforcementPolicyResponseSchema = z.object({
11
17
  id: z.string().uuid(),
@@ -52,7 +58,8 @@ export async function GET(req: Request) {
52
58
  return securityApiError(400, 'Invalid query parameters', { issues: parsedQuery.error.issues })
53
59
  }
54
60
 
55
- const policies = await context.enforcementService.listPolicies(parsedQuery.data)
61
+ const actor = await resolveActorContext(context)
62
+ const policies = await context.enforcementService.listPolicies(parsedQuery.data, actor)
56
63
  return NextResponse.json({
57
64
  items: await attachPolicyScopeNames(context.container, policies),
58
65
  })
@@ -78,6 +85,11 @@ export async function POST(req: Request) {
78
85
  }
79
86
 
80
87
  try {
88
+ await assertActorOwnsEnforcementScope(
89
+ context,
90
+ parsedBody.data.scope,
91
+ scopeIdFromPolicyInput(parsedBody.data),
92
+ )
81
93
  const commandBus = context.container.resolve<CommandBus>('commandBus')
82
94
  const { result } = await commandBus.execute('security.enforcement.create', {
83
95
  input: parsedBody.data,
@@ -89,6 +101,21 @@ export async function POST(req: Request) {
89
101
  }
90
102
  }
91
103
 
104
+ function scopeIdFromPolicyInput(data: {
105
+ scope: EnforcementScope
106
+ tenantId?: string | null
107
+ organizationId?: string | null
108
+ }): string | null {
109
+ if (data.scope === EnforcementScope.TENANT) {
110
+ return data.tenantId ?? null
111
+ }
112
+ if (data.scope === EnforcementScope.ORGANISATION) {
113
+ if (!data.tenantId || !data.organizationId) return null
114
+ return `${data.tenantId}:${data.organizationId}`
115
+ }
116
+ return null
117
+ }
118
+
92
119
  export const openApi = buildSecurityOpenApi({
93
120
  summary: 'Enforcement policy routes',
94
121
  methods: {
@@ -115,6 +142,7 @@ export const openApi = buildSecurityOpenApi({
115
142
  errors: [
116
143
  { status: 400, description: 'Invalid payload', schema: securityErrorSchema },
117
144
  { status: 401, description: 'Unauthorized', schema: securityErrorSchema },
145
+ { status: 403, description: 'Not authorized for the requested scope', schema: securityErrorSchema },
118
146
  { status: 409, description: 'Conflict', schema: securityErrorSchema },
119
147
  ],
120
148
  },
@@ -15,7 +15,7 @@ const responseSchema = z.object({
15
15
  })
16
16
 
17
17
  export const metadata = {
18
- POST: { requireAuth: true },
18
+ POST: { requireAuth: true, rateLimit: { points: 20, duration: 60, keyPrefix: 'security_mfa_prepare' } },
19
19
  }
20
20
 
21
21
  export async function POST(req: Request) {
@@ -15,7 +15,7 @@ const responseSchema = z.object({
15
15
  })
16
16
 
17
17
  export const metadata = {
18
- POST: { requireAuth: true },
18
+ POST: { requireAuth: true, rateLimit: { points: 10, duration: 60, keyPrefix: 'security_mfa_recovery' } },
19
19
  }
20
20
 
21
21
  export async function POST(req: Request) {
@@ -17,7 +17,7 @@ const responseSchema = z.object({
17
17
  })
18
18
 
19
19
  export const metadata = {
20
- POST: { requireAuth: true },
20
+ POST: { requireAuth: true, rateLimit: { points: 10, duration: 60, keyPrefix: 'security_mfa_verify' } },
21
21
  }
22
22
 
23
23
  export async function POST(req: Request) {
@@ -3,7 +3,11 @@ import { z } from 'zod'
3
3
  import type { CommandBus } from '@open-mercato/shared/lib/commands'
4
4
  import { buildSecurityOpenApi, securityErrorSchema } from '../../../../openapi'
5
5
  import { securityApiError } from '../../../../i18n'
6
- import { mapSecurityUsersError, resolveSecurityUsersContext } from '../../../_shared'
6
+ import {
7
+ assertActorCanAccessSecurityUserTarget,
8
+ mapSecurityUsersError,
9
+ resolveSecurityUsersContext,
10
+ } from '../../../_shared'
7
11
  import { requireSudo } from '../../../../../lib/sudo-middleware'
8
12
 
9
13
  const paramsSchema = z.object({
@@ -44,6 +48,7 @@ export async function POST(req: Request, routeContext: { params: Promise<{ id: s
44
48
  }
45
49
 
46
50
  try {
51
+ await assertActorCanAccessSecurityUserTarget(context, parsedParams.data.id)
47
52
  await requireSudo(req, 'security.admin.mfa.reset')
48
53
  const commandBus = context.container.resolve<CommandBus>('commandBus')
49
54
  const { result } = await commandBus.execute('security.admin.mfa.reset', {
@@ -2,7 +2,12 @@ import { NextResponse } from 'next/server'
2
2
  import { z } from 'zod'
3
3
  import { buildSecurityOpenApi, securityErrorSchema } from '../../../../openapi'
4
4
  import { securityApiError } from '../../../../i18n'
5
- import { mapSecurityUsersError, resolveSecurityUsersContext } from '../../../_shared'
5
+ import { resolveIsSuperAdmin } from '@open-mercato/core/modules/auth/lib/tenantAccess'
6
+ import {
7
+ assertActorCanAccessSecurityUserTarget,
8
+ mapSecurityUsersError,
9
+ resolveSecurityUsersContext,
10
+ } from '../../../_shared'
6
11
 
7
12
  const paramsSchema = z.object({
8
13
  id: z.string().uuid(),
@@ -35,7 +40,12 @@ export async function GET(req: Request, routeContext: { params: Promise<{ id: st
35
40
  }
36
41
 
37
42
  try {
38
- const status = await context.mfaAdminService.getUserMfaStatus(parsedParams.data.id)
43
+ await assertActorCanAccessSecurityUserTarget(context, parsedParams.data.id)
44
+ const isSuperAdmin = await resolveIsSuperAdmin({ auth: context.auth, container: context.container })
45
+ const status = await context.mfaAdminService.getUserMfaStatus(parsedParams.data.id, {
46
+ tenantId: context.auth.tenantId ?? null,
47
+ isSuperAdmin,
48
+ })
39
49
  return NextResponse.json({
40
50
  ...status,
41
51
  methods: status.methods.map((method) => ({
@@ -60,6 +70,7 @@ export const openApi = buildSecurityOpenApi({
60
70
  errors: [
61
71
  { status: 400, description: 'Invalid user id', schema: securityErrorSchema },
62
72
  { status: 401, description: 'Unauthorized', schema: securityErrorSchema },
73
+ { status: 403, description: 'Not authorized to access this user', schema: securityErrorSchema },
63
74
  { status: 404, description: 'User not found', schema: securityErrorSchema },
64
75
  ],
65
76
  },