import type { EntityManager } from '@mikro-orm/postgresql' import { User } from '@open-mercato/core/modules/auth/data/entities' import { findOneWithDecryption } from '@open-mercato/shared/lib/encryption/find' import { EnforcementScope, MfaEnforcementPolicy, UserMfaMethod, } from '../data/entities' import type { EnforcementPolicyInput, UpdateEnforcementPolicyInput, } from '../data/validators' import { emitSecurityEvent } from '../events' type EnforcementResult = { enforced: boolean policy?: MfaEnforcementPolicy } type ComplianceReport = { total: number enrolled: number pending: number overdue: number } type EnforcementPolicyListFilters = { scope?: EnforcementScope } export type EnforcementActorContext = { tenantId: string | null isSuperAdmin: boolean } type UserCompliance = { compliant: boolean deadline?: Date enforced: boolean } export function isEnforcementDeadlineOverdue( deadline?: Date | null, now = Date.now(), ): boolean { if (!(deadline instanceof Date)) return false const deadlineTime = deadline.getTime() if (Number.isNaN(deadlineTime)) return false return deadlineTime <= now } export type MfaEnforcementAuthScope = { tenantId: string | null organizationId?: string | null isSuperAdmin?: boolean } export class MfaEnforcementServiceError extends Error { constructor( message: string, public readonly statusCode: number, ) { super(message) this.name = 'MfaEnforcementServiceError' } } export class MfaEnforcementService { constructor(private readonly em: EntityManager) {} async isEnforced(tenantId: string, orgId?: string): Promise { const policy = await this.resolveEffectivePolicy(tenantId, orgId) if (!policy || !policy.isEnforced) { return { enforced: false, policy: policy ?? undefined } } return { enforced: true, policy } } async getPolicyById(id: string): Promise { return this.em.findOne(MfaEnforcementPolicy, { id, deletedAt: null }) } async listPolicies( filters?: EnforcementPolicyListFilters, actor?: EnforcementActorContext, ): Promise { const tenantConstraint = actor && !actor.isSuperAdmin ? { tenantId: actor.tenantId } : {} return this.em.find( MfaEnforcementPolicy, { deletedAt: null, ...(filters?.scope ? { scope: filters.scope } : {}), ...tenantConstraint, }, { orderBy: { updatedAt: 'desc' }, }, ) } async getComplianceReport( scope: EnforcementScope, scopeId?: string, actor?: EnforcementActorContext, ): Promise { const { tenantId, organizationId } = this.resolveScopeFilters(scope, scopeId) this.assertActorOwnsScopeFilters(actor, scope, tenantId) const users = await this.em.find(User, { deletedAt: null, ...(tenantId ? { tenantId } : {}), ...(organizationId ? { organizationId } : {}), }) const total = users.length if (total === 0) { return { total: 0, enrolled: 0, pending: 0, overdue: 0 } } const userIds = users.map((user) => user.id) const policy = await this.findPolicyByScope(scope, tenantId, organizationId) const methodFilter = this.buildAllowedMethodsFilter(policy?.allowedMethods ?? null) const methods = await this.em.find(UserMfaMethod, { userId: { $in: userIds }, isActive: true, deletedAt: null, ...methodFilter, }) const enrolledUserIds = new Set(methods.map((method) => method.userId)) const enrolled = enrolledUserIds.size const unenrolled = Math.max(0, total - enrolled) const now = Date.now() const overdue = isEnforcementDeadlineOverdue(policy?.enforcementDeadline, now) ? unenrolled : 0 const pending = Math.max(0, unenrolled - overdue) return { total, enrolled, pending, overdue, } } /** * @deprecated Since 0.6 — pass an {@link MfaEnforcementAuthScope} so the policy * write is enforced against the caller's actual tenant/organization scope * rather than caller-supplied body fields. The no-scope overload now treats * the caller as a non-superadmin with unknown tenant and rejects every write * with 403; it will be removed in a future release. */ async createPolicy( data: EnforcementPolicyInput, adminId: string, ): Promise async createPolicy( data: EnforcementPolicyInput, adminId: string, scope: MfaEnforcementAuthScope, ): Promise async createPolicy( data: EnforcementPolicyInput, adminId: string, scope?: MfaEnforcementAuthScope, ): Promise { const normalized = this.normalizePolicyInput(data) const effectiveScope: MfaEnforcementAuthScope = scope ?? { tenantId: null, isSuperAdmin: false } this.assertCreatePolicyScope(normalized, effectiveScope) const existing = await this.findPolicyByScope( normalized.scope, normalized.tenantId ?? undefined, normalized.organizationId ?? undefined, ) if (existing) { existing.isEnforced = normalized.isEnforced existing.allowedMethods = normalized.allowedMethods existing.enforcementDeadline = normalized.enforcementDeadline existing.enforcedBy = adminId existing.updatedAt = new Date() await this.em.flush() await emitSecurityEvent('security.enforcement.updated', { adminId, policyId: existing.id, scope: existing.scope, }) await this.emitDeadlineReminderRequest(existing.id) return existing } const now = new Date() const policy = this.em.create(MfaEnforcementPolicy, { scope: normalized.scope, tenantId: normalized.tenantId, organizationId: normalized.organizationId, isEnforced: normalized.isEnforced, allowedMethods: normalized.allowedMethods, enforcementDeadline: normalized.enforcementDeadline, enforcedBy: adminId, createdAt: now, updatedAt: now, }) this.em.persist(policy) await this.em.flush() await emitSecurityEvent('security.enforcement.created', { adminId, policyId: policy.id, scope: policy.scope, }) await this.emitDeadlineReminderRequest(policy.id) return policy } /** * @deprecated Since 0.6 — pass an {@link MfaEnforcementAuthScope} so the policy * update is enforced against the caller's actual tenant/organization scope * rather than caller-supplied body fields. The no-scope overload now treats * the caller as a non-superadmin with unknown tenant and rejects every write * with 403; it will be removed in a future release. */ async updatePolicy( id: string, data: UpdateEnforcementPolicyInput, adminId: string, ): Promise async updatePolicy( id: string, data: UpdateEnforcementPolicyInput, adminId: string, scope: MfaEnforcementAuthScope, ): Promise async updatePolicy( id: string, data: UpdateEnforcementPolicyInput, adminId: string, scope?: MfaEnforcementAuthScope, ): Promise { const effectiveScope: MfaEnforcementAuthScope = scope ?? { tenantId: null, isSuperAdmin: false } const policy = await this.em.findOne(MfaEnforcementPolicy, { id, deletedAt: null, }) if (!policy) { throw new MfaEnforcementServiceError('Enforcement policy not found', 404) } const mergedInput = this.normalizePolicyInput({ scope: data.scope ?? policy.scope, tenantId: data.tenantId ?? policy.tenantId ?? undefined, organizationId: data.organizationId ?? policy.organizationId ?? undefined, isEnforced: data.isEnforced ?? policy.isEnforced, allowedMethods: data.allowedMethods ?? policy.allowedMethods ?? null, enforcementDeadline: data.enforcementDeadline === undefined ? (policy.enforcementDeadline ?? null) : data.enforcementDeadline, }) this.assertUpdatePolicyScope(policy, mergedInput, effectiveScope) if ( mergedInput.scope !== policy.scope || mergedInput.tenantId !== (policy.tenantId ?? null) || mergedInput.organizationId !== (policy.organizationId ?? null) ) { const conflict = await this.findPolicyByScope( mergedInput.scope, mergedInput.tenantId ?? undefined, mergedInput.organizationId ?? undefined, ) if (conflict && conflict.id !== policy.id) { throw new MfaEnforcementServiceError('Enforcement policy already exists for this scope', 409) } } policy.scope = mergedInput.scope policy.tenantId = mergedInput.tenantId policy.organizationId = mergedInput.organizationId policy.isEnforced = mergedInput.isEnforced policy.allowedMethods = mergedInput.allowedMethods policy.enforcementDeadline = mergedInput.enforcementDeadline policy.enforcedBy = adminId policy.updatedAt = new Date() await this.em.flush() await emitSecurityEvent('security.enforcement.updated', { adminId, policyId: policy.id, scope: policy.scope, }) await this.emitDeadlineReminderRequest(policy.id) return policy } /** * @deprecated Since 0.6 — pass an {@link MfaEnforcementAuthScope} so the policy * delete is enforced against the caller's actual tenant/organization scope * rather than the caller-supplied id alone. The no-scope overload now treats * the caller as a non-superadmin with unknown tenant and rejects every delete * with 403; it will be removed in a future release. */ async deletePolicy(id: string): Promise async deletePolicy(id: string, scope: MfaEnforcementAuthScope): Promise async deletePolicy(id: string, scope?: MfaEnforcementAuthScope): Promise { const effectiveScope: MfaEnforcementAuthScope = scope ?? { tenantId: null, isSuperAdmin: false } const policy = await this.em.findOne(MfaEnforcementPolicy, { id, deletedAt: null, }) if (!policy) { throw new MfaEnforcementServiceError('Enforcement policy not found', 404) } this.assertDeletePolicyScope(policy, effectiveScope) const now = new Date() policy.deletedAt = now policy.updatedAt = now await this.em.flush() } async checkUserCompliance(userId: string): Promise { const policy = await this.getEffectivePolicyForUser(userId) if (!policy || !policy.isEnforced) { return { compliant: true, enforced: false } } const methodFilter = this.buildAllowedMethodsFilter(policy.allowedMethods ?? null) const methodCount = await this.em.count(UserMfaMethod, { userId, isActive: true, deletedAt: null, ...methodFilter, }) return { compliant: methodCount > 0, enforced: true, deadline: policy.enforcementDeadline ?? undefined, } } async getEffectivePolicyForUser(userId: string): Promise { const user = await this.findUserById(userId) if (!user?.tenantId) { throw new MfaEnforcementServiceError('User not found', 404) } return this.resolveEffectivePolicy(user.tenantId, user.organizationId ?? undefined) } private async resolveEffectivePolicy( tenantId: string, orgId?: string, ): Promise { if (orgId) { const organizationPolicy = await this.findPolicyByScope( EnforcementScope.ORGANISATION, tenantId, orgId, ) if (organizationPolicy) return organizationPolicy } const tenantPolicy = await this.findPolicyByScope(EnforcementScope.TENANT, tenantId, undefined) if (tenantPolicy) return tenantPolicy return this.findPolicyByScope(EnforcementScope.PLATFORM, undefined, undefined) } private assertCreatePolicyScope( normalized: { scope: EnforcementScope tenantId: string | null organizationId: string | null }, scope: MfaEnforcementAuthScope, ): void { if (scope.isSuperAdmin) return // Fail closed when the caller has no concrete tenant context. Mirrors the // MfaAdminService.loadUserForScope precedent — a missing scope.tenantId for // a non-superadmin means we cannot prove ownership of anything. if (!scope.tenantId) { throw new MfaEnforcementServiceError('Insufficient scope for enforcement policy', 403) } if (normalized.scope === EnforcementScope.PLATFORM) { throw new MfaEnforcementServiceError('Insufficient scope for enforcement policy', 403) } if (normalized.tenantId !== scope.tenantId) { throw new MfaEnforcementServiceError('Insufficient scope for enforcement policy', 403) } if ( normalized.scope === EnforcementScope.ORGANISATION && scope.organizationId !== undefined && scope.organizationId !== null && normalized.organizationId !== scope.organizationId ) { throw new MfaEnforcementServiceError('Insufficient scope for enforcement policy', 403) } } private assertUpdatePolicyScope( existing: MfaEnforcementPolicy, proposed: { scope: EnforcementScope tenantId: string | null organizationId: string | null }, scope: MfaEnforcementAuthScope, ): void { if (scope.isSuperAdmin) return if (!scope.tenantId) { throw new MfaEnforcementServiceError('Insufficient scope for enforcement policy', 403) } if ( existing.scope === EnforcementScope.PLATFORM || existing.tenantId !== scope.tenantId ) { throw new MfaEnforcementServiceError('Insufficient scope for enforcement policy', 403) } if (proposed.scope === EnforcementScope.PLATFORM) { throw new MfaEnforcementServiceError('Insufficient scope for enforcement policy', 403) } if (proposed.tenantId !== scope.tenantId) { throw new MfaEnforcementServiceError('Insufficient scope for enforcement policy', 403) } if ( proposed.scope === EnforcementScope.ORGANISATION && scope.organizationId !== undefined && scope.organizationId !== null && proposed.organizationId !== scope.organizationId ) { throw new MfaEnforcementServiceError('Insufficient scope for enforcement policy', 403) } if ( existing.scope === EnforcementScope.ORGANISATION && scope.organizationId !== undefined && scope.organizationId !== null && existing.organizationId !== scope.organizationId ) { throw new MfaEnforcementServiceError('Insufficient scope for enforcement policy', 403) } } private assertDeletePolicyScope( existing: MfaEnforcementPolicy, scope: MfaEnforcementAuthScope, ): void { if (scope.isSuperAdmin) return if (!scope.tenantId) { throw new MfaEnforcementServiceError('Insufficient scope for enforcement policy', 403) } if ( existing.scope === EnforcementScope.PLATFORM || existing.tenantId !== scope.tenantId ) { throw new MfaEnforcementServiceError('Insufficient scope for enforcement policy', 403) } if ( existing.scope === EnforcementScope.ORGANISATION && scope.organizationId !== undefined && scope.organizationId !== null && existing.organizationId !== scope.organizationId ) { throw new MfaEnforcementServiceError('Insufficient scope for enforcement policy', 403) } } private async findPolicyByScope( scope: EnforcementScope, tenantId?: string, organizationId?: string, ): Promise { return this.em.findOne( MfaEnforcementPolicy, { scope, tenantId: tenantId ?? null, organizationId: organizationId ?? null, deletedAt: null, }, { orderBy: { updatedAt: 'desc' }, }, ) } private assertActorOwnsScopeFilters( actor: EnforcementActorContext | undefined, scope: EnforcementScope, tenantId: string | null | undefined, ): void { if (!actor || actor.isSuperAdmin) return if (scope === EnforcementScope.PLATFORM) { throw new MfaEnforcementServiceError( 'Platform scope requires platform administrator privileges.', 403, ) } if (!tenantId || tenantId !== actor.tenantId) { throw new MfaEnforcementServiceError('Not authorized for the requested scope.', 403) } } private resolveScopeFilters( scope: EnforcementScope, scopeId?: string, ): { tenantId?: string; organizationId?: string } { if (scope === EnforcementScope.PLATFORM) { return {} } if (!scopeId) { throw new MfaEnforcementServiceError('scopeId is required for tenant and organisation scopes', 400) } if (scope === EnforcementScope.TENANT) { return { tenantId: scopeId } } const [tenantId, organizationId] = scopeId.split(':') if (!tenantId || !organizationId) { throw new MfaEnforcementServiceError( "organisation scopeId must use ':' format", 400, ) } return { tenantId, organizationId } } private normalizePolicyInput(data: { scope: EnforcementScope tenantId?: string | null organizationId?: string | null isEnforced?: boolean allowedMethods?: string[] | null enforcementDeadline?: Date | null }): { scope: EnforcementScope tenantId: string | null organizationId: string | null isEnforced: boolean allowedMethods: string[] | null enforcementDeadline: Date | null } { const isEnforced = data.isEnforced ?? true const allowedMethods = this.normalizeAllowedMethods(data.allowedMethods) const enforcementDeadline = data.enforcementDeadline ?? null if (data.scope === EnforcementScope.PLATFORM) { return { scope: data.scope, tenantId: null, organizationId: null, isEnforced, allowedMethods, enforcementDeadline, } } if (data.scope === EnforcementScope.TENANT) { if (!data.tenantId) { throw new MfaEnforcementServiceError('tenantId is required for tenant scope', 400) } return { scope: data.scope, tenantId: data.tenantId, organizationId: null, isEnforced, allowedMethods, enforcementDeadline, } } if (!data.tenantId || !data.organizationId) { throw new MfaEnforcementServiceError( 'tenantId and organizationId are required for organisation scope', 400, ) } return { scope: data.scope, tenantId: data.tenantId, organizationId: data.organizationId, isEnforced, allowedMethods, enforcementDeadline, } } private normalizeAllowedMethods(allowedMethods?: string[] | null): string[] | null { if (!allowedMethods || allowedMethods.length === 0) { return null } return Array.from(new Set(allowedMethods.map((method) => method.trim()).filter(Boolean))) } private buildAllowedMethodsFilter( allowedMethods?: string[] | null, ): { type?: { $in: string[] } } { if (!allowedMethods || allowedMethods.length === 0) { return {} } return { type: { $in: allowedMethods } } } private async findUserById(userId: string): Promise { return findOneWithDecryption(this.em, User, { id: userId, deletedAt: null }, undefined, {}) } private async emitDeadlineReminderRequest(policyId: string): Promise { await emitSecurityEvent('security.enforcement.deadline_reminder_requested', { policyId, }) } } export default MfaEnforcementService