@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.
- package/dist/modules/security/api/enforcement/[id]/route.js +35 -1
- package/dist/modules/security/api/enforcement/[id]/route.js.map +2 -2
- package/dist/modules/security/api/enforcement/_shared.js +63 -1
- package/dist/modules/security/api/enforcement/_shared.js.map +3 -3
- package/dist/modules/security/api/enforcement/compliance/route.js +12 -3
- package/dist/modules/security/api/enforcement/compliance/route.js.map +2 -2
- package/dist/modules/security/api/enforcement/route.js +25 -2
- package/dist/modules/security/api/enforcement/route.js.map +2 -2
- package/dist/modules/security/api/mfa/prepare/route.js +1 -1
- package/dist/modules/security/api/mfa/prepare/route.js.map +2 -2
- package/dist/modules/security/api/mfa/recovery/route.js +1 -1
- package/dist/modules/security/api/mfa/recovery/route.js.map +2 -2
- package/dist/modules/security/api/mfa/verify/route.js +1 -1
- package/dist/modules/security/api/mfa/verify/route.js.map +2 -2
- package/dist/modules/security/api/users/[id]/mfa/reset/route.js +6 -1
- package/dist/modules/security/api/users/[id]/mfa/reset/route.js.map +2 -2
- package/dist/modules/security/api/users/[id]/mfa/status/route.js +13 -2
- package/dist/modules/security/api/users/[id]/mfa/status/route.js.map +2 -2
- package/dist/modules/security/api/users/_shared.js +56 -1
- package/dist/modules/security/api/users/_shared.js.map +2 -2
- package/dist/modules/security/api/users/mfa/compliance/route.js +17 -7
- package/dist/modules/security/api/users/mfa/compliance/route.js.map +2 -2
- package/dist/modules/security/commands/createEnforcementPolicy.js +6 -1
- package/dist/modules/security/commands/createEnforcementPolicy.js.map +2 -2
- package/dist/modules/security/commands/deleteEnforcementPolicy.js +6 -1
- package/dist/modules/security/commands/deleteEnforcementPolicy.js.map +2 -2
- package/dist/modules/security/commands/resetUserMfa.js +6 -1
- package/dist/modules/security/commands/resetUserMfa.js.map +2 -2
- package/dist/modules/security/commands/updateEnforcementPolicy.js +6 -1
- package/dist/modules/security/commands/updateEnforcementPolicy.js.map +2 -2
- package/dist/modules/security/services/MfaAdminService.js +22 -5
- package/dist/modules/security/services/MfaAdminService.js.map +2 -2
- package/dist/modules/security/services/MfaEnforcementService.js +28 -6
- package/dist/modules/security/services/MfaEnforcementService.js.map +2 -2
- package/dist/modules/security/services/MfaVerificationService.js +30 -10
- package/dist/modules/security/services/MfaVerificationService.js.map +2 -2
- package/dist/modules/security/services/SudoChallengeService.js +14 -3
- package/dist/modules/security/services/SudoChallengeService.js.map +2 -2
- package/package.json +5 -5
- package/src/modules/security/api/enforcement/[id]/route.ts +50 -1
- package/src/modules/security/api/enforcement/_shared.ts +83 -2
- package/src/modules/security/api/enforcement/compliance/route.ts +10 -1
- package/src/modules/security/api/enforcement/route.ts +30 -2
- package/src/modules/security/api/mfa/prepare/route.ts +1 -1
- package/src/modules/security/api/mfa/recovery/route.ts +1 -1
- package/src/modules/security/api/mfa/verify/route.ts +1 -1
- package/src/modules/security/api/users/[id]/mfa/reset/route.ts +6 -1
- package/src/modules/security/api/users/[id]/mfa/status/route.ts +13 -2
- package/src/modules/security/api/users/_shared.ts +69 -1
- package/src/modules/security/api/users/mfa/compliance/route.ts +16 -7
- package/src/modules/security/commands/createEnforcementPolicy.ts +6 -1
- package/src/modules/security/commands/deleteEnforcementPolicy.ts +6 -1
- package/src/modules/security/commands/resetUserMfa.ts +6 -1
- package/src/modules/security/commands/updateEnforcementPolicy.ts +6 -1
- package/src/modules/security/services/MfaAdminService.ts +29 -6
- package/src/modules/security/services/MfaEnforcementService.ts +42 -2
- package/src/modules/security/services/MfaVerificationService.ts +33 -10
- 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 {
|
|
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
|
|
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 {
|
|
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 {
|
|
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
|
|
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 {
|
|
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 {
|
|
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
|
-
|
|
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 {
|
|
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 {
|
|
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
|
|
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)) {
|