@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.
- package/.turbo/turbo-build.log +1 -1
- package/dist/modules/record_locks/widgets/injection/record-locking/widget.client.js +1 -1
- package/dist/modules/record_locks/widgets/injection/record-locking/widget.client.js.map +2 -2
- 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/dist/modules/sso/api/callback/oidc/route.js +2 -2
- package/dist/modules/sso/api/callback/oidc/route.js.map +2 -2
- package/dist/modules/sso/i18n/de.json +2 -0
- package/dist/modules/sso/i18n/en.json +2 -0
- package/dist/modules/sso/i18n/es.json +2 -0
- package/dist/modules/sso/i18n/pl.json +2 -0
- package/dist/modules/sso/lib/errors.js +21 -0
- package/dist/modules/sso/lib/errors.js.map +7 -0
- package/dist/modules/sso/services/accountLinkingService.js +2 -1
- package/dist/modules/sso/services/accountLinkingService.js.map +2 -2
- package/package.json +7 -8
- package/src/modules/record_locks/widgets/injection/record-locking/widget.client.tsx +1 -1
- 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
- package/src/modules/sso/api/callback/oidc/route.ts +2 -2
- package/src/modules/sso/i18n/de.json +2 -0
- package/src/modules/sso/i18n/en.json +2 -0
- package/src/modules/sso/i18n/es.json +2 -0
- package/src/modules/sso/i18n/pl.json +2 -0
- package/src/modules/sso/lib/errors.ts +35 -0
- package/src/modules/sso/services/accountLinkingService.ts +2 -1
|
@@ -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)) {
|
|
@@ -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
|
|
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
|
|
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
|
|
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
|
|
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
|
-
|
|
42
|
-
sid: string
|
|
43
|
-
sub: string
|
|
44
|
-
tid: string
|
|
45
|
-
oid: string
|
|
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 =
|
|
669
|
-
|
|
670
|
-
|
|
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
|
|
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",
|