@open-mercato/enterprise 0.6.5-develop.4882.1.901c3aa813 → 0.6.5-develop.5033.1.c970204a3f

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 (58) hide show
  1. package/dist/modules/security/api/enforcement/[id]/route.js +35 -1
  2. package/dist/modules/security/api/enforcement/[id]/route.js.map +2 -2
  3. package/dist/modules/security/api/enforcement/_shared.js +63 -1
  4. package/dist/modules/security/api/enforcement/_shared.js.map +3 -3
  5. package/dist/modules/security/api/enforcement/compliance/route.js +12 -3
  6. package/dist/modules/security/api/enforcement/compliance/route.js.map +2 -2
  7. package/dist/modules/security/api/enforcement/route.js +25 -2
  8. package/dist/modules/security/api/enforcement/route.js.map +2 -2
  9. package/dist/modules/security/api/mfa/prepare/route.js +1 -1
  10. package/dist/modules/security/api/mfa/prepare/route.js.map +2 -2
  11. package/dist/modules/security/api/mfa/recovery/route.js +1 -1
  12. package/dist/modules/security/api/mfa/recovery/route.js.map +2 -2
  13. package/dist/modules/security/api/mfa/verify/route.js +1 -1
  14. package/dist/modules/security/api/mfa/verify/route.js.map +2 -2
  15. package/dist/modules/security/api/users/[id]/mfa/reset/route.js +6 -1
  16. package/dist/modules/security/api/users/[id]/mfa/reset/route.js.map +2 -2
  17. package/dist/modules/security/api/users/[id]/mfa/status/route.js +13 -2
  18. package/dist/modules/security/api/users/[id]/mfa/status/route.js.map +2 -2
  19. package/dist/modules/security/api/users/_shared.js +56 -1
  20. package/dist/modules/security/api/users/_shared.js.map +2 -2
  21. package/dist/modules/security/api/users/mfa/compliance/route.js +17 -7
  22. package/dist/modules/security/api/users/mfa/compliance/route.js.map +2 -2
  23. package/dist/modules/security/commands/createEnforcementPolicy.js +6 -1
  24. package/dist/modules/security/commands/createEnforcementPolicy.js.map +2 -2
  25. package/dist/modules/security/commands/deleteEnforcementPolicy.js +6 -1
  26. package/dist/modules/security/commands/deleteEnforcementPolicy.js.map +2 -2
  27. package/dist/modules/security/commands/resetUserMfa.js +6 -1
  28. package/dist/modules/security/commands/resetUserMfa.js.map +2 -2
  29. package/dist/modules/security/commands/updateEnforcementPolicy.js +6 -1
  30. package/dist/modules/security/commands/updateEnforcementPolicy.js.map +2 -2
  31. package/dist/modules/security/services/MfaAdminService.js +22 -5
  32. package/dist/modules/security/services/MfaAdminService.js.map +2 -2
  33. package/dist/modules/security/services/MfaEnforcementService.js +28 -6
  34. package/dist/modules/security/services/MfaEnforcementService.js.map +2 -2
  35. package/dist/modules/security/services/MfaVerificationService.js +30 -10
  36. package/dist/modules/security/services/MfaVerificationService.js.map +2 -2
  37. package/dist/modules/security/services/SudoChallengeService.js +14 -3
  38. package/dist/modules/security/services/SudoChallengeService.js.map +2 -2
  39. package/package.json +5 -5
  40. package/src/modules/security/api/enforcement/[id]/route.ts +50 -1
  41. package/src/modules/security/api/enforcement/_shared.ts +83 -2
  42. package/src/modules/security/api/enforcement/compliance/route.ts +10 -1
  43. package/src/modules/security/api/enforcement/route.ts +30 -2
  44. package/src/modules/security/api/mfa/prepare/route.ts +1 -1
  45. package/src/modules/security/api/mfa/recovery/route.ts +1 -1
  46. package/src/modules/security/api/mfa/verify/route.ts +1 -1
  47. package/src/modules/security/api/users/[id]/mfa/reset/route.ts +6 -1
  48. package/src/modules/security/api/users/[id]/mfa/status/route.ts +13 -2
  49. package/src/modules/security/api/users/_shared.ts +69 -1
  50. package/src/modules/security/api/users/mfa/compliance/route.ts +16 -7
  51. package/src/modules/security/commands/createEnforcementPolicy.ts +6 -1
  52. package/src/modules/security/commands/deleteEnforcementPolicy.ts +6 -1
  53. package/src/modules/security/commands/resetUserMfa.ts +6 -1
  54. package/src/modules/security/commands/updateEnforcementPolicy.ts +6 -1
  55. package/src/modules/security/services/MfaAdminService.ts +29 -6
  56. package/src/modules/security/services/MfaEnforcementService.ts +42 -2
  57. package/src/modules/security/services/MfaVerificationService.ts +33 -10
  58. package/src/modules/security/services/SudoChallengeService.ts +16 -11
@@ -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
  },
@@ -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)) {