import { Injectable, Logger } from '@nestjs/common'; import { InjectDataSource } from '@nestjs/typeorm'; import { DataSource } from 'typeorm'; import { AuditService } from '../audit/audit.service'; import { AuditAction } from '../audit/entities/audit-log.entity'; import { ApiError } from '../common/errors/api-error'; import { ErrorCode } from '../common/errors/error-codes'; import { AppConfigService } from '../config/config.service'; import { RolesService } from '../roles/roles.service'; import { UserEntity } from '../users/entities/user.entity'; import { UserRoleAssignmentEntity, UserRoleAssignmentSource, } from '../users/entities/user-role-assignment.entity'; import { OidcRoleExtractorService } from './oidc-role-extractor.service'; @Injectable() export class OidcAdminSynchronizationService { private readonly logger = new Logger(OidcAdminSynchronizationService.name); constructor( private readonly config: AppConfigService, private readonly extractor: OidcRoleExtractorService, private readonly roles: RolesService, private readonly audit: AuditService, @InjectDataSource() private readonly dataSource: DataSource, ) {} async synchronize( userId: string, claims: Readonly>, ): Promise { const adminRoleName = this.config.oidc.adminRole; if (!adminRoleName) return this.reloadUser(userId); const claimPath = this.config.oidc.rolesClaim; if (!claimPath) throw new Error( 'OIDC_ROLES_CLAIM fehlt trotz validierter Konfiguration.', ); console.log(claims, claimPath) const extracted = this.extractor.extract(claims, claimPath); if (!extracted.readable) { this.logger.error( { userId, claimPath, reason: extracted.reason }, 'OIDC-Administrator-Synchronisierung: Rollenclaim konnte nicht gelesen werden', ); throw new ApiError( ErrorCode.Unauthorized, 'Die OIDC-Rollen konnten nicht sicher validiert werden.', 401, ); } const normalize = (value: string) => this.config.oidc.roleMatchCaseSensitive ? value : value.toLocaleLowerCase('en-US'); const hasAdminRole = extracted.roles.some( (role) => normalize(role) === normalize(adminRoleName), ); await this.dataSource.transaction(async (manager) => { const { admin } = await this.roles.ensureSystemRoles(manager); const assignments = manager.getRepository(UserRoleAssignmentEntity); const existing = await assignments.findOneBy({ userId, roleId: admin.id, source: UserRoleAssignmentSource.Oidc, }); if (hasAdminRole) { if (existing) { existing.lastSynchronizedAt = new Date(); await assignments.save(existing); } else { await manager.query( `INSERT INTO user_role_assignments (id, user_id, role_id, source, last_synchronized_at, created_at, updated_at) VALUES (UUID(), ?, ?, ?, CURRENT_TIMESTAMP(3), CURRENT_TIMESTAMP(3), CURRENT_TIMESTAMP(3)) ON DUPLICATE KEY UPDATE last_synchronized_at = CURRENT_TIMESTAMP(3), updated_at = CURRENT_TIMESTAMP(3)`, [userId, admin.id, UserRoleAssignmentSource.Oidc], ); await manager.query( 'INSERT IGNORE INTO user_roles (user_id, role_id) VALUES (?, ?)', [userId, admin.id], ); await this.audit.record( userId, AuditAction.OidcAdminAssigned, 'user', userId, { roleId: admin.id, oidcRole: adminRoleName, source: UserRoleAssignmentSource.Oidc, }, manager, ); } } else if (existing) { await assignments.remove(existing); const remaining = await assignments.countBy({ userId, roleId: admin.id, }); if (remaining === 0) { await manager.query( 'DELETE FROM user_roles WHERE user_id = ? AND role_id = ?', [userId, admin.id], ); } await this.audit.record( userId, AuditAction.OidcAdminRemoved, 'user', userId, { roleId: admin.id, oidcRole: adminRoleName, source: UserRoleAssignmentSource.Oidc, }, manager, ); } }); return this.reloadUser(userId); } private async reloadUser(userId: string): Promise { const user = await this.dataSource.getRepository(UserEntity).findOne({ where: { id: userId }, relations: { roles: { permissions: true }, settings: true }, }); if (!user) throw new ApiError( ErrorCode.UserNotFound, 'Der Benutzer wurde nicht gefunden.', 404, ); return user; } }