This commit is contained in:
Bastian Wagner
2026-07-20 17:01:16 +02:00
parent 2f84a109e8
commit aa7758c9dd
38 changed files with 1129 additions and 134 deletions

View File

@@ -0,0 +1,139 @@
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<Record<string, unknown>>,
): Promise<UserEntity> {
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.',
);
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<UserEntity> {
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;
}
}