@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
@@ -1,8 +1,13 @@
1
1
  import { NextResponse } from 'next/server'
2
- import { CrudHttpError } from '@open-mercato/shared/lib/crud/errors'
2
+ import type { EntityManager, FilterQuery } from '@mikro-orm/postgresql'
3
+ import { CrudHttpError, forbidden } from '@open-mercato/shared/lib/crud/errors'
3
4
  import type { CommandRuntimeContext } from '@open-mercato/shared/lib/commands'
4
5
  import { createRequestContainer } from '@open-mercato/shared/lib/di/container'
5
6
  import { getAuthFromRequest } from '@open-mercato/shared/lib/auth/server'
7
+ import { findOneWithDecryption } from '@open-mercato/shared/lib/encryption/find'
8
+ import { enforceTenantSelection, resolveIsSuperAdmin } from '@open-mercato/core/modules/auth/lib/tenantAccess'
9
+ import { User } from '@open-mercato/core/modules/auth/data/entities'
10
+ import type { RbacService } from '@open-mercato/core/modules/auth/services/rbacService'
6
11
  import { isSudoRequiredError } from '../../lib/sudo-middleware'
7
12
  import type { MfaAdminService, MfaAdminServiceError } from '../../services/MfaAdminService'
8
13
  import { localizeSecurityApiBody, securityApiError } from '../i18n'
@@ -41,6 +46,69 @@ export async function resolveSecurityUsersContext(
41
46
  }
42
47
  }
43
48
 
49
+ function normalizeNullableString(value: unknown): string | null {
50
+ return typeof value === 'string' && value.trim().length > 0 ? value.trim() : null
51
+ }
52
+
53
+ function normalizeOrganizationList(values: unknown): string[] | null {
54
+ if (values === null || values === undefined) return null
55
+ if (!Array.isArray(values)) return null
56
+ const result: string[] = []
57
+ for (const value of values) {
58
+ if (typeof value !== 'string') continue
59
+ const trimmed = value.trim()
60
+ if (trimmed) result.push(trimmed)
61
+ }
62
+ return result
63
+ }
64
+
65
+ export async function assertActorCanAccessSecurityUserTarget(
66
+ ctx: SecurityUsersRequestContext,
67
+ targetUserId: string,
68
+ ): Promise<void> {
69
+ const isSuperAdmin = await resolveIsSuperAdmin({ auth: ctx.auth, container: ctx.container })
70
+ if (isSuperAdmin) return
71
+
72
+ const em = ctx.container.resolve<EntityManager>('em')
73
+ const target = await findOneWithDecryption(
74
+ em,
75
+ User,
76
+ { id: targetUserId, deletedAt: null } as FilterQuery<User>,
77
+ {},
78
+ { tenantId: null, organizationId: null },
79
+ )
80
+ if (!target) {
81
+ throw new CrudHttpError(404, { error: 'User not found' })
82
+ }
83
+
84
+ const actorTenantId = normalizeNullableString(ctx.auth.tenantId)
85
+ const targetTenantId = normalizeNullableString((target as { tenantId?: string | null }).tenantId)
86
+ if (!targetTenantId || targetTenantId !== actorTenantId) {
87
+ throw new CrudHttpError(404, { error: 'User not found' })
88
+ }
89
+
90
+ const rbacService = ctx.container.resolve<RbacService>('rbacService')
91
+ const acl = await rbacService.loadAcl(ctx.auth.sub, {
92
+ tenantId: actorTenantId,
93
+ organizationId: normalizeNullableString(ctx.auth.orgId),
94
+ })
95
+ const organizations = normalizeOrganizationList(acl?.organizations)
96
+ if (organizations !== null && !organizations.includes('__all__')) {
97
+ const targetOrganizationId = normalizeNullableString((target as { organizationId?: string | null }).organizationId)
98
+ if (!targetOrganizationId || !organizations.includes(targetOrganizationId)) {
99
+ throw forbidden('Not authorized to access this user.')
100
+ }
101
+ }
102
+ }
103
+
104
+ export async function assertActorOwnsTenantScope(
105
+ ctx: SecurityUsersRequestContext,
106
+ requestedTenantId: string | null | undefined,
107
+ ): Promise<string | null> {
108
+ const resolved = await enforceTenantSelection({ auth: ctx.auth, container: ctx.container }, requestedTenantId)
109
+ return resolved ?? ctx.auth.tenantId ?? null
110
+ }
111
+
44
112
  export async function mapSecurityUsersError(error: unknown): Promise<NextResponse> {
45
113
  if (error instanceof CrudHttpError) {
46
114
  return NextResponse.json(await localizeSecurityApiBody(error.body), { status: error.status })
@@ -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
+ assertActorOwnsTenantScope,
8
+ mapSecurityUsersError,
9
+ resolveSecurityUsersContext,
10
+ } from '../../_shared'
6
11
 
7
12
  const querySchema = z.object({
8
13
  tenantId: z.string().uuid().optional(),
@@ -37,13 +42,16 @@ export async function GET(req: Request) {
37
42
  return securityApiError(400, 'Invalid query parameters', { issues: parsedQuery.error.issues })
38
43
  }
39
44
 
40
- const tenantId = parsedQuery.data.tenantId ?? context.auth.tenantId ?? null
41
- if (!tenantId) {
42
- return securityApiError(400, 'Tenant context is required.')
43
- }
44
-
45
45
  try {
46
- const items = await context.mfaAdminService.bulkComplianceCheck(tenantId)
46
+ const tenantId = await assertActorOwnsTenantScope(context, parsedQuery.data.tenantId)
47
+ if (!tenantId) {
48
+ return securityApiError(400, 'Tenant context is required.')
49
+ }
50
+ const isSuperAdmin = await resolveIsSuperAdmin({ auth: context.auth, container: context.container })
51
+ const items = await context.mfaAdminService.bulkComplianceCheck(tenantId, {
52
+ tenantId: context.auth.tenantId ?? null,
53
+ isSuperAdmin,
54
+ })
47
55
  return NextResponse.json({ items })
48
56
  } catch (error) {
49
57
  return await mapSecurityUsersError(error)
@@ -62,6 +70,7 @@ export const openApi = buildSecurityOpenApi({
62
70
  errors: [
63
71
  { status: 400, description: 'Invalid query or missing tenant context', schema: securityErrorSchema },
64
72
  { status: 401, description: 'Unauthorized', schema: securityErrorSchema },
73
+ { status: 403, description: 'Not authorized for the requested tenant scope', schema: securityErrorSchema },
65
74
  ],
66
75
  },
67
76
  },
@@ -2,6 +2,7 @@ import { z } from 'zod'
2
2
  import { registerCommand } from '@open-mercato/shared/lib/commands'
3
3
  import { CrudHttpError } from '@open-mercato/shared/lib/crud/errors'
4
4
  import { resolveTranslations } from '@open-mercato/shared/lib/i18n/server'
5
+ import { resolveIsSuperAdmin } from '@open-mercato/core/modules/auth/lib/tenantAccess'
5
6
  import { enforcementPolicySchema } from '../data/validators'
6
7
  import type { MfaEnforcementService } from '../services/MfaEnforcementService'
7
8
 
@@ -34,8 +35,12 @@ registerCommand({
34
35
  }
35
36
 
36
37
  const enforcementService = ctx.container.resolve<MfaEnforcementService>('mfaEnforcementService')
38
+ const isSuperAdmin = await resolveIsSuperAdmin({ auth: ctx.auth, container: ctx.container })
37
39
  try {
38
- const policy = await enforcementService.createPolicy(parsed.data, ctx.auth.sub)
40
+ const policy = await enforcementService.createPolicy(parsed.data, ctx.auth.sub, {
41
+ tenantId: ctx.auth.tenantId ?? null,
42
+ isSuperAdmin,
43
+ })
39
44
  return { id: policy.id }
40
45
  } catch (error) {
41
46
  if (isEnforcementServiceError(error)) {
@@ -2,6 +2,7 @@ import { z } from 'zod'
2
2
  import { registerCommand } from '@open-mercato/shared/lib/commands'
3
3
  import { CrudHttpError } from '@open-mercato/shared/lib/crud/errors'
4
4
  import { resolveTranslations } from '@open-mercato/shared/lib/i18n/server'
5
+ import { resolveIsSuperAdmin } from '@open-mercato/core/modules/auth/lib/tenantAccess'
5
6
  import type { MfaEnforcementService } from '../services/MfaEnforcementService'
6
7
 
7
8
  export const commandId = 'security.enforcement.delete'
@@ -35,8 +36,12 @@ registerCommand({
35
36
  }
36
37
 
37
38
  const enforcementService = ctx.container.resolve<MfaEnforcementService>('mfaEnforcementService')
39
+ const isSuperAdmin = await resolveIsSuperAdmin({ auth: ctx.auth, container: ctx.container })
38
40
  try {
39
- await enforcementService.deletePolicy(parsed.data.id)
41
+ await enforcementService.deletePolicy(parsed.data.id, {
42
+ tenantId: ctx.auth.tenantId ?? null,
43
+ isSuperAdmin,
44
+ })
40
45
  return { ok: true as const }
41
46
  } catch (error) {
42
47
  if (isEnforcementServiceError(error)) {
@@ -2,6 +2,7 @@ import { z } from 'zod'
2
2
  import { registerCommand } from '@open-mercato/shared/lib/commands'
3
3
  import { CrudHttpError } from '@open-mercato/shared/lib/crud/errors'
4
4
  import { resolveTranslations } from '@open-mercato/shared/lib/i18n/server'
5
+ import { resolveIsSuperAdmin } from '@open-mercato/core/modules/auth/lib/tenantAccess'
5
6
  import type { MfaAdminService } from '../services/MfaAdminService'
6
7
 
7
8
  export const commandId = 'security.admin.mfa.reset'
@@ -36,8 +37,12 @@ registerCommand({
36
37
  }
37
38
 
38
39
  const mfaAdminService = ctx.container.resolve<MfaAdminService>('mfaAdminService')
40
+ const isSuperAdmin = await resolveIsSuperAdmin({ auth: ctx.auth, container: ctx.container })
39
41
  try {
40
- await mfaAdminService.resetUserMfa(ctx.auth.sub, parsed.data.userId, parsed.data.reason)
42
+ await mfaAdminService.resetUserMfa(ctx.auth.sub, parsed.data.userId, parsed.data.reason, {
43
+ tenantId: ctx.auth.tenantId ?? null,
44
+ isSuperAdmin,
45
+ })
41
46
  return { ok: true as const }
42
47
  } catch (error) {
43
48
  if (isMfaAdminServiceError(error)) {
@@ -2,6 +2,7 @@ import { z } from 'zod'
2
2
  import { registerCommand } from '@open-mercato/shared/lib/commands'
3
3
  import { CrudHttpError } from '@open-mercato/shared/lib/crud/errors'
4
4
  import { resolveTranslations } from '@open-mercato/shared/lib/i18n/server'
5
+ import { resolveIsSuperAdmin } from '@open-mercato/core/modules/auth/lib/tenantAccess'
5
6
  import { updateEnforcementPolicySchema } from '../data/validators'
6
7
  import type { MfaEnforcementService } from '../services/MfaEnforcementService'
7
8
 
@@ -37,8 +38,12 @@ registerCommand({
37
38
  }
38
39
 
39
40
  const enforcementService = ctx.container.resolve<MfaEnforcementService>('mfaEnforcementService')
41
+ const isSuperAdmin = await resolveIsSuperAdmin({ auth: ctx.auth, container: ctx.container })
40
42
  try {
41
- await enforcementService.updatePolicy(parsed.data.id, parsed.data.data, ctx.auth.sub)
43
+ await enforcementService.updatePolicy(parsed.data.id, parsed.data.data, ctx.auth.sub, {
44
+ tenantId: ctx.auth.tenantId ?? null,
45
+ isSuperAdmin,
46
+ })
42
47
  return { ok: true as const }
43
48
  } catch (error) {
44
49
  if (isEnforcementServiceError(error)) {
@@ -1,6 +1,6 @@
1
- import type { EntityManager } from '@mikro-orm/postgresql'
1
+ import type { EntityManager, FilterQuery } from '@mikro-orm/postgresql'
2
2
  import { User } from '@open-mercato/core/modules/auth/data/entities'
3
- import { findWithDecryption } from '@open-mercato/shared/lib/encryption/find'
3
+ import { findOneWithDecryption, findWithDecryption } from '@open-mercato/shared/lib/encryption/find'
4
4
  import { MfaRecoveryCode, UserMfaMethod } from '../data/entities'
5
5
  import { emitSecurityEvent } from '../events'
6
6
  import type { MfaEnforcementService } from './MfaEnforcementService'
@@ -27,6 +27,11 @@ type BulkComplianceStatus = {
27
27
  lastLoginAt?: Date
28
28
  }
29
29
 
30
+ type ActorContext = {
31
+ tenantId: string | null
32
+ isSuperAdmin: boolean
33
+ }
34
+
30
35
  export class MfaAdminServiceError extends Error {
31
36
  constructor(
32
37
  message: string,
@@ -43,7 +48,7 @@ export class MfaAdminService {
43
48
  private readonly mfaEnforcementService: MfaEnforcementService,
44
49
  ) {}
45
50
 
46
- async resetUserMfa(adminId: string, userId: string, reason: string): Promise<void> {
51
+ async resetUserMfa(adminId: string, userId: string, reason: string, actor?: ActorContext): Promise<void> {
47
52
  if (!adminId.trim()) {
48
53
  throw new MfaAdminServiceError('Admin ID is required', 400)
49
54
  }
@@ -60,6 +65,7 @@ export class MfaAdminService {
60
65
  if (!user) {
61
66
  throw new MfaAdminServiceError('User not found', 404)
62
67
  }
68
+ this.assertActorOwnsUser(user, actor)
63
69
 
64
70
  const activeMethods = await this.em.find(UserMfaMethod, {
65
71
  userId,
@@ -95,7 +101,7 @@ export class MfaAdminService {
95
101
  })
96
102
  }
97
103
 
98
- async getUserMfaStatus(userId: string): Promise<UserMfaStatus> {
104
+ async getUserMfaStatus(userId: string, actor?: ActorContext): Promise<UserMfaStatus> {
99
105
  if (!userId.trim()) {
100
106
  throw new MfaAdminServiceError('User ID is required', 400)
101
107
  }
@@ -104,6 +110,7 @@ export class MfaAdminService {
104
110
  if (!user) {
105
111
  throw new MfaAdminServiceError('User not found', 404)
106
112
  }
113
+ this.assertActorOwnsUser(user, actor)
107
114
 
108
115
  const methods = await this.em.find(
109
116
  UserMfaMethod,
@@ -136,10 +143,13 @@ export class MfaAdminService {
136
143
  }
137
144
  }
138
145
 
139
- async bulkComplianceCheck(tenantId: string): Promise<BulkComplianceStatus[]> {
146
+ async bulkComplianceCheck(tenantId: string, actor?: ActorContext): Promise<BulkComplianceStatus[]> {
140
147
  if (!tenantId.trim()) {
141
148
  throw new MfaAdminServiceError('Tenant ID is required', 400)
142
149
  }
150
+ if (actor && !actor.isSuperAdmin && tenantId !== actor.tenantId) {
151
+ throw new MfaAdminServiceError('Not authorized for the requested tenant scope.', 403)
152
+ }
143
153
 
144
154
  const users = await findWithDecryption(
145
155
  this.em,
@@ -186,8 +196,21 @@ export class MfaAdminService {
186
196
  })
187
197
  }
188
198
 
199
+ private assertActorOwnsUser(user: User, actor?: ActorContext): void {
200
+ if (!actor || actor.isSuperAdmin) return
201
+ if (!user.tenantId || user.tenantId !== actor.tenantId) {
202
+ throw new MfaAdminServiceError('User not found', 404)
203
+ }
204
+ }
205
+
189
206
  private async findUserById(userId: string): Promise<User | null> {
190
- return this.em.findOne(User, { id: userId, deletedAt: null })
207
+ return findOneWithDecryption(
208
+ this.em,
209
+ User,
210
+ { id: userId, deletedAt: null } as FilterQuery<User>,
211
+ {},
212
+ { tenantId: null, organizationId: null },
213
+ )
191
214
  }
192
215
  }
193
216
 
@@ -28,6 +28,11 @@ type EnforcementPolicyListFilters = {
28
28
  scope?: EnforcementScope
29
29
  }
30
30
 
31
+ export type EnforcementActorContext = {
32
+ tenantId: string | null
33
+ isSuperAdmin: boolean
34
+ }
35
+
31
36
  type UserCompliance = {
32
37
  compliant: boolean
33
38
  deadline?: Date
@@ -65,12 +70,21 @@ export class MfaEnforcementService {
65
70
  return { enforced: true, policy }
66
71
  }
67
72
 
68
- async listPolicies(filters?: EnforcementPolicyListFilters): Promise<MfaEnforcementPolicy[]> {
73
+ async getPolicyById(id: string): Promise<MfaEnforcementPolicy | null> {
74
+ return this.em.findOne(MfaEnforcementPolicy, { id, deletedAt: null })
75
+ }
76
+
77
+ async listPolicies(
78
+ filters?: EnforcementPolicyListFilters,
79
+ actor?: EnforcementActorContext,
80
+ ): Promise<MfaEnforcementPolicy[]> {
81
+ const tenantConstraint = actor && !actor.isSuperAdmin ? { tenantId: actor.tenantId } : {}
69
82
  return this.em.find(
70
83
  MfaEnforcementPolicy,
71
84
  {
72
85
  deletedAt: null,
73
86
  ...(filters?.scope ? { scope: filters.scope } : {}),
87
+ ...tenantConstraint,
74
88
  },
75
89
  {
76
90
  orderBy: { updatedAt: 'desc' },
@@ -81,8 +95,10 @@ export class MfaEnforcementService {
81
95
  async getComplianceReport(
82
96
  scope: EnforcementScope,
83
97
  scopeId?: string,
98
+ actor?: EnforcementActorContext,
84
99
  ): Promise<ComplianceReport> {
85
100
  const { tenantId, organizationId } = this.resolveScopeFilters(scope, scopeId)
101
+ this.assertActorOwnsScopeFilters(actor, scope, tenantId)
86
102
  const users = await this.em.find(User, {
87
103
  deletedAt: null,
88
104
  ...(tenantId ? { tenantId } : {}),
@@ -123,8 +139,10 @@ export class MfaEnforcementService {
123
139
  async createPolicy(
124
140
  data: EnforcementPolicyInput,
125
141
  adminId: string,
142
+ actor?: EnforcementActorContext,
126
143
  ): Promise<MfaEnforcementPolicy> {
127
144
  const normalized = this.normalizePolicyInput(data)
145
+ this.assertActorOwnsScopeFilters(actor, normalized.scope, normalized.tenantId)
128
146
  const existing = await this.findPolicyByScope(
129
147
  normalized.scope,
130
148
  normalized.tenantId ?? undefined,
@@ -176,6 +194,7 @@ export class MfaEnforcementService {
176
194
  id: string,
177
195
  data: UpdateEnforcementPolicyInput,
178
196
  adminId: string,
197
+ actor?: EnforcementActorContext,
179
198
  ): Promise<MfaEnforcementPolicy> {
180
199
  const policy = await this.em.findOne(MfaEnforcementPolicy, {
181
200
  id,
@@ -184,6 +203,7 @@ export class MfaEnforcementService {
184
203
  if (!policy) {
185
204
  throw new MfaEnforcementServiceError('Enforcement policy not found', 404)
186
205
  }
206
+ this.assertActorOwnsScopeFilters(actor, policy.scope, policy.tenantId ?? null)
187
207
 
188
208
  const mergedInput = this.normalizePolicyInput({
189
209
  scope: data.scope ?? policy.scope,
@@ -197,6 +217,8 @@ export class MfaEnforcementService {
197
217
  : data.enforcementDeadline,
198
218
  })
199
219
 
220
+ this.assertActorOwnsScopeFilters(actor, mergedInput.scope, mergedInput.tenantId)
221
+
200
222
  if (
201
223
  mergedInput.scope !== policy.scope ||
202
224
  mergedInput.tenantId !== (policy.tenantId ?? null) ||
@@ -231,7 +253,7 @@ export class MfaEnforcementService {
231
253
  return policy
232
254
  }
233
255
 
234
- async deletePolicy(id: string): Promise<void> {
256
+ async deletePolicy(id: string, actor?: EnforcementActorContext): Promise<void> {
235
257
  const policy = await this.em.findOne(MfaEnforcementPolicy, {
236
258
  id,
237
259
  deletedAt: null,
@@ -239,6 +261,7 @@ export class MfaEnforcementService {
239
261
  if (!policy) {
240
262
  throw new MfaEnforcementServiceError('Enforcement policy not found', 404)
241
263
  }
264
+ this.assertActorOwnsScopeFilters(actor, policy.scope, policy.tenantId ?? null)
242
265
 
243
266
  const now = new Date()
244
267
  policy.deletedAt = now
@@ -314,6 +337,23 @@ export class MfaEnforcementService {
314
337
  )
315
338
  }
316
339
 
340
+ private assertActorOwnsScopeFilters(
341
+ actor: EnforcementActorContext | undefined,
342
+ scope: EnforcementScope,
343
+ tenantId: string | null | undefined,
344
+ ): void {
345
+ if (!actor || actor.isSuperAdmin) return
346
+ if (scope === EnforcementScope.PLATFORM) {
347
+ throw new MfaEnforcementServiceError(
348
+ 'Platform scope requires platform administrator privileges.',
349
+ 403,
350
+ )
351
+ }
352
+ if (!tenantId || tenantId !== actor.tenantId) {
353
+ throw new MfaEnforcementServiceError('Not authorized for the requested scope.', 403)
354
+ }
355
+ }
356
+
317
357
  private resolveScopeFilters(
318
358
  scope: EnforcementScope,
319
359
  scopeId?: string,
@@ -87,6 +87,7 @@ export class MfaVerificationService {
87
87
  context?: MfaProviderRuntimeContext,
88
88
  ): Promise<{ clientData?: Record<string, unknown> }> {
89
89
  const challenge = await this.getValidChallenge(challengeId)
90
+ await this.assertMethodAllowedByPolicy(challenge.userId, methodType)
90
91
  const provider = this.mfaProviderRegistry.get(methodType)
91
92
  if (!provider) {
92
93
  throw new MfaVerificationServiceError(`MFA provider '${methodType}' is not registered`, 400)
@@ -119,12 +120,10 @@ export class MfaVerificationService {
119
120
  return false
120
121
  }
121
122
 
123
+ await this.assertMethodAllowedByPolicy(challenge.userId, methodType)
124
+
122
125
  if (challenge.methodType && challenge.methodType !== methodType) {
123
- challenge.attempts += 1
124
- if (challenge.attempts >= this.securityConfig.mfa.maxAttempts) {
125
- challenge.expiresAt = new Date()
126
- }
127
- await this.em.flush()
126
+ await this.registerFailedAttempt(challenge)
128
127
  return false
129
128
  }
130
129
 
@@ -160,11 +159,7 @@ export class MfaVerificationService {
160
159
  return true
161
160
  }
162
161
 
163
- challenge.attempts += 1
164
- if (challenge.attempts >= this.securityConfig.mfa.maxAttempts) {
165
- challenge.expiresAt = new Date()
166
- }
167
- await this.em.flush()
162
+ await this.registerFailedAttempt(challenge)
168
163
  return false
169
164
  }
170
165
 
@@ -186,6 +181,34 @@ export class MfaVerificationService {
186
181
  return challenge
187
182
  }
188
183
 
184
+ private async assertMethodAllowedByPolicy(userId: string, methodType: string): Promise<void> {
185
+ const policy = await this.mfaEnforcementService.getEffectivePolicyForUser(userId)
186
+ if (!policy?.isEnforced || !policy.allowedMethods?.length) {
187
+ return
188
+ }
189
+ if (!policy.allowedMethods.includes(methodType)) {
190
+ throw new MfaVerificationServiceError(`MFA method '${methodType}' is not allowed by the enforcement policy`, 403)
191
+ }
192
+ }
193
+
194
+ private async registerFailedAttempt(challenge: MfaChallenge): Promise<void> {
195
+ const maxAttempts = this.securityConfig.mfa.maxAttempts
196
+ const rows = await this.em.getConnection().execute<Array<{ attempts: number }>>(
197
+ 'UPDATE mfa_challenges SET attempts = attempts + 1 WHERE id = ? AND verified_at IS NULL AND attempts < ? RETURNING attempts',
198
+ [challenge.id, maxAttempts],
199
+ )
200
+ const updatedAttempts = rows.length > 0 ? Number(rows[0].attempts) : maxAttempts
201
+ challenge.attempts = updatedAttempts
202
+ if (updatedAttempts >= maxAttempts) {
203
+ const now = new Date()
204
+ await this.em.getConnection().execute(
205
+ 'UPDATE mfa_challenges SET expires_at = ? WHERE id = ?',
206
+ [now, challenge.id],
207
+ )
208
+ challenge.expiresAt = now
209
+ }
210
+ }
211
+
189
212
  private async getActiveMethods(userId: string): Promise<UserMfaMethod[]> {
190
213
  const methods = await this.em.find(
191
214
  UserMfaMethod,
@@ -1,4 +1,5 @@
1
1
  import { createHmac, randomBytes, timingSafeEqual } from 'node:crypto'
2
+ import { z } from 'zod'
2
3
  import type { EntityManager, FilterQuery } from '@mikro-orm/postgresql'
3
4
  import { User } from '@open-mercato/core/modules/auth/data/entities'
4
5
  import { findOneWithDecryption } from '@open-mercato/shared/lib/encryption/find'
@@ -38,14 +39,16 @@ export type SudoProtectionResolution = {
38
39
  config?: SudoChallengeConfig
39
40
  }
40
41
 
41
- type SignedSudoTokenPayload = {
42
- sid: string
43
- sub: string
44
- tid: string | null
45
- oid: string | null
46
- tgt: string
47
- exp: number
48
- }
42
+ const signedSudoTokenPayloadSchema = z.object({
43
+ sid: z.string(),
44
+ sub: z.string(),
45
+ tid: z.string().nullable(),
46
+ oid: z.string().nullable(),
47
+ tgt: z.string(),
48
+ exp: z.number(),
49
+ })
50
+
51
+ type SignedSudoTokenPayload = z.infer<typeof signedSudoTokenPayloadSchema>
49
52
 
50
53
  type UserScope = {
51
54
  id: string
@@ -665,9 +668,11 @@ export class SudoChallengeService {
665
668
  if (!timingSafeEqual(signatureBuffer, expectedBuffer)) return null
666
669
 
667
670
  try {
668
- const parsed = JSON.parse(Buffer.from(encodedPayload, 'base64url').toString('utf8')) as SignedSudoTokenPayload
669
- if (!parsed || typeof parsed !== 'object') return null
670
- return parsed
671
+ const parsed = signedSudoTokenPayloadSchema.safeParse(
672
+ JSON.parse(Buffer.from(encodedPayload, 'base64url').toString('utf8')),
673
+ )
674
+ if (!parsed.success) return null
675
+ return parsed.data
671
676
  } catch {
672
677
  return null
673
678
  }
@@ -4,6 +4,7 @@ import { toAbsoluteUrl } from '@open-mercato/shared/lib/url'
4
4
  import { createRequestContainer } from '@open-mercato/shared/lib/di/container'
5
5
  import { SsoService } from '../../../services/ssoService'
6
6
  import { emitSsoEvent } from '../../../events'
7
+ import { resolveSsoCallbackErrorCode } from '../../../lib/errors'
7
8
 
8
9
  export const metadata = {
9
10
  GET: { requireAuth: false },
@@ -82,8 +83,7 @@ async function handleCallback(req: Request): Promise<NextResponse> {
82
83
  void emitSsoEvent('sso.login.failed', {
83
84
  reason: err instanceof Error ? err.message : 'callback_failed',
84
85
  }).catch((e) => console.error('[SSO Event]', e))
85
- const message = err instanceof Error ? err.message : ''
86
- const errorCode = message.includes('email is not verified') ? 'sso_email_not_verified' : 'sso_failed'
86
+ const errorCode = resolveSsoCallbackErrorCode(err)
87
87
  return NextResponse.redirect(toAbsoluteUrl(req, `/login?error=${errorCode}`))
88
88
  }
89
89
  }
@@ -24,6 +24,7 @@
24
24
  "sso.admin.action.test": "Erkennung überprüfen",
25
25
  "sso.admin.activated": "SSO-Konfiguration aktiviert",
26
26
  "sso.admin.activity.empty": "Noch keine SSO-Anmeldeaktivität. Aktivitäten werden hier angezeigt, sobald Benutzer sich über SSO anmelden.",
27
+ "sso.admin.backToList": "Zurück zu SSO-Konfigurationen",
27
28
  "sso.admin.banner.activateNow": "Jetzt aktivieren",
28
29
  "sso.admin.banner.created": "Ihre SSO-Konfiguration wurde erstellt. Möchten Sie sie jetzt aktivieren?",
29
30
  "sso.admin.banner.notYet": "Noch nicht",
@@ -53,6 +54,7 @@
53
54
  "sso.admin.error.domainRemoveFailed": "Domain konnte nicht entfernt werden",
54
55
  "sso.admin.error.loadFailed": "SSO-Konfiguration konnte nicht geladen werden",
55
56
  "sso.admin.error.noDomainsForActivation": "Fügen Sie mindestens eine erlaubte E-Mail-Domain hinzu, bevor Sie aktivieren",
57
+ "sso.admin.error.notFound": "SSO-Konfiguration nicht gefunden.",
56
58
  "sso.admin.error.saveFailed": "SSO-Konfiguration konnte nicht gespeichert werden",
57
59
  "sso.admin.error.testFailed": "Verbindungstest fehlgeschlagen",
58
60
  "sso.admin.field.autoLinkByEmail": "Automatische Verknüpfung per E-Mail",
@@ -24,6 +24,7 @@
24
24
  "sso.admin.action.test": "Verify Discovery",
25
25
  "sso.admin.activated": "SSO configuration activated",
26
26
  "sso.admin.activity.empty": "No SSO login activity yet. Activity will appear here once users start logging in via SSO.",
27
+ "sso.admin.backToList": "Back to SSO configurations",
27
28
  "sso.admin.banner.activateNow": "Activate Now",
28
29
  "sso.admin.banner.created": "Your SSO configuration has been created. Would you like to activate it now?",
29
30
  "sso.admin.banner.notYet": "Not Yet",
@@ -53,6 +54,7 @@
53
54
  "sso.admin.error.domainRemoveFailed": "Failed to remove domain",
54
55
  "sso.admin.error.loadFailed": "Failed to load SSO configuration",
55
56
  "sso.admin.error.noDomainsForActivation": "Add at least one allowed email domain before activating",
57
+ "sso.admin.error.notFound": "SSO configuration not found.",
56
58
  "sso.admin.error.saveFailed": "Failed to save SSO configuration",
57
59
  "sso.admin.error.testFailed": "Connection test failed",
58
60
  "sso.admin.field.autoLinkByEmail": "Auto-link by Email",
@@ -24,6 +24,7 @@
24
24
  "sso.admin.action.test": "Verificar descubrimiento",
25
25
  "sso.admin.activated": "Configuración SSO activada",
26
26
  "sso.admin.activity.empty": "Aún no hay actividad de inicio de sesión SSO. La actividad aparecerá aquí cuando los usuarios comiencen a iniciar sesión a través de SSO.",
27
+ "sso.admin.backToList": "Volver a configuraciones SSO",
27
28
  "sso.admin.banner.activateNow": "Activar ahora",
28
29
  "sso.admin.banner.created": "Su configuración SSO ha sido creada. ¿Desea activarla ahora?",
29
30
  "sso.admin.banner.notYet": "Aún no",
@@ -53,6 +54,7 @@
53
54
  "sso.admin.error.domainRemoveFailed": "Error al eliminar dominio",
54
55
  "sso.admin.error.loadFailed": "Error al cargar la configuración SSO",
55
56
  "sso.admin.error.noDomainsForActivation": "Agregue al menos un dominio de correo electrónico permitido antes de activar",
57
+ "sso.admin.error.notFound": "Configuración SSO no encontrada.",
56
58
  "sso.admin.error.saveFailed": "Error al guardar la configuración SSO",
57
59
  "sso.admin.error.testFailed": "La prueba de conexión falló",
58
60
  "sso.admin.field.autoLinkByEmail": "Vinculación automática por correo electrónico",
@@ -24,6 +24,7 @@
24
24
  "sso.admin.action.test": "Zweryfikuj Discovery",
25
25
  "sso.admin.activated": "Konfiguracja SSO aktywowana",
26
26
  "sso.admin.activity.empty": "Brak aktywności logowania SSO. Aktywność pojawi się tutaj, gdy użytkownicy zaczną logować się przez SSO.",
27
+ "sso.admin.backToList": "Wróć do konfiguracji SSO",
27
28
  "sso.admin.banner.activateNow": "Aktywuj teraz",
28
29
  "sso.admin.banner.created": "Konfiguracja SSO została utworzona. Czy chcesz ją teraz aktywować?",
29
30
  "sso.admin.banner.notYet": "Jeszcze nie",
@@ -53,6 +54,7 @@
53
54
  "sso.admin.error.domainRemoveFailed": "Nie udało się usunąć domeny",
54
55
  "sso.admin.error.loadFailed": "Nie udało się załadować konfiguracji SSO",
55
56
  "sso.admin.error.noDomainsForActivation": "Dodaj co najmniej jedną dozwoloną domenę e-mail przed aktywacją",
57
+ "sso.admin.error.notFound": "Nie znaleziono konfiguracji SSO.",
56
58
  "sso.admin.error.saveFailed": "Nie udało się zapisać konfiguracji SSO",
57
59
  "sso.admin.error.testFailed": "Test połączenia nie powiódł się",
58
60
  "sso.admin.field.autoLinkByEmail": "Automatyczne łączenie po e-mailu",