diff --git a/.env.example b/.env.example index 8a108b5..c005b33 100644 --- a/.env.example +++ b/.env.example @@ -18,6 +18,9 @@ OIDC_SCOPES=openid profile email OIDC_LOGOUT_URL= OIDC_ALLOWED_ALGORITHMS=RS256 OIDC_HTTP_TIMEOUT_MS=5000 +OIDC_ADMIN_ROLE=hauspilot-admin +OIDC_ROLES_CLAIM=roles +OIDC_ROLE_MATCH_CASE_SENSITIVE=true SESSION_COOKIE_NAME=app_session SESSION_IDLE_TIMEOUT_SECONDS=28800 diff --git a/README.md b/README.md index c217d46..40f37c4 100644 --- a/README.md +++ b/README.md @@ -61,6 +61,7 @@ laufen. - [Architektur](docs/architecture.md): Monorepo, Backend, Frontend, API-Client und Modulgrenzen - [Entwicklung](docs/development.md): Workflows fuer Features, Migrationen, Tests und API-Client - [Security-Modell](docs/security.md): OIDC, Sessions, CSRF, Rollen, Permissions und Logging +- [OIDC-Administratoren](docs/oidc-administrator.md): sichere Admin-Synchronisierung und Recovery - [Designsystem](docs/design-system.md): Tokens, UI-Komponenten, responsive Regeln und Accessibility - [Adminbereich](docs/admin.md): Benutzer, Rollen, Sessions, Audit und letzter-Admin-Schutz - [Benachrichtigungen](docs/notifications.md): Datenmodell, API, Permissions, Polling und Erweiterung diff --git a/apps/backend/src/audit/audit.service.ts b/apps/backend/src/audit/audit.service.ts index 16060be..b0c4a13 100644 --- a/apps/backend/src/audit/audit.service.ts +++ b/apps/backend/src/audit/audit.service.ts @@ -2,6 +2,7 @@ import { Injectable } from '@nestjs/common'; import { getRequestId } from '../common/request-context/request-context'; import { AuditAction, AuditLogEntity } from './entities/audit-log.entity'; import { AuditRepository } from './repositories/audit.repository'; +import type { EntityManager } from 'typeorm'; @Injectable() export class AuditService { @@ -13,6 +14,7 @@ export class AuditService { targetType: string, targetId: string, metadata: Record | null = null, + manager?: EntityManager, ): Promise { const entry = new AuditLogEntity(); entry.actorUserId = actorUserId; @@ -21,7 +23,7 @@ export class AuditService { entry.targetId = targetId; entry.metadata = metadata; entry.requestId = getRequestId(); - await this.audit.save(entry); + await this.audit.save(entry, manager); } async list(page = 1, pageSize = 20) { diff --git a/apps/backend/src/audit/entities/audit-log.entity.ts b/apps/backend/src/audit/entities/audit-log.entity.ts index ce09ec0..592abee 100644 --- a/apps/backend/src/audit/entities/audit-log.entity.ts +++ b/apps/backend/src/audit/entities/audit-log.entity.ts @@ -11,6 +11,8 @@ export enum AuditAction { UserDeactivated = 'USER_DEACTIVATED', UserRoleAssigned = 'USER_ROLE_ASSIGNED', UserRoleRemoved = 'USER_ROLE_REMOVED', + OidcAdminAssigned = 'OIDC_ADMIN_ASSIGNED', + OidcAdminRemoved = 'OIDC_ADMIN_REMOVED', RoleCreated = 'ROLE_CREATED', RoleUpdated = 'ROLE_UPDATED', RoleDeleted = 'ROLE_DELETED', diff --git a/apps/backend/src/audit/repositories/audit.repository.ts b/apps/backend/src/audit/repositories/audit.repository.ts index ed41615..0aaf7cb 100644 --- a/apps/backend/src/audit/repositories/audit.repository.ts +++ b/apps/backend/src/audit/repositories/audit.repository.ts @@ -1,6 +1,6 @@ import { Injectable } from '@nestjs/common'; import { InjectRepository } from '@nestjs/typeorm'; -import { Repository } from 'typeorm'; +import { EntityManager, Repository } from 'typeorm'; import { AuditLogEntity } from '../entities/audit-log.entity'; @Injectable() @@ -10,8 +10,11 @@ export class AuditRepository { private readonly repo: Repository, ) {} - save(entry: AuditLogEntity): Promise { - return this.repo.save(entry); + save( + entry: AuditLogEntity, + manager?: EntityManager, + ): Promise { + return (manager?.getRepository(AuditLogEntity) ?? this.repo).save(entry); } list(page: number, pageSize: number): Promise<[AuditLogEntity[], number]> { diff --git a/apps/backend/src/auth/auth.module.ts b/apps/backend/src/auth/auth.module.ts index 86393cf..955089d 100644 --- a/apps/backend/src/auth/auth.module.ts +++ b/apps/backend/src/auth/auth.module.ts @@ -9,6 +9,10 @@ import { UsersRepository } from '../users/repositories/users.repository'; import { OidcLoginStateEntity } from './entities/oidc-login-state.entity'; import { AuthController } from './auth.controller'; import { AuthService } from './auth.service'; +import { OidcRoleExtractorService } from './oidc-role-extractor.service'; +import { OidcAdminSynchronizationService } from './oidc-admin-synchronization.service'; +import { UserRoleAssignmentEntity } from '../users/entities/user-role-assignment.entity'; +import { AuditModule } from '../audit/audit.module'; @Module({ imports: [ @@ -16,12 +20,20 @@ import { AuthService } from './auth.service'; OidcLoginStateEntity, UserEntity, UserSettingsEntity, + UserRoleAssignmentEntity, ]), RolesModule, SessionsModule, + AuditModule, ], controllers: [AuthController], - providers: [AuthService, ExternalHttpClient, UsersRepository], + providers: [ + AuthService, + ExternalHttpClient, + UsersRepository, + OidcRoleExtractorService, + OidcAdminSynchronizationService, + ], exports: [AuthService], }) export class AuthModule {} diff --git a/apps/backend/src/auth/auth.service.spec.ts b/apps/backend/src/auth/auth.service.spec.ts index 131e97a..cab0b9b 100644 --- a/apps/backend/src/auth/auth.service.spec.ts +++ b/apps/backend/src/auth/auth.service.spec.ts @@ -1,5 +1,5 @@ import { describe, expect, it, vi } from 'vitest'; -import type { DataSource, Repository } from 'typeorm'; +import type { DataSource, EntityManager, Repository } from 'typeorm'; import type { ExternalHttpClient } from '../common/http/external-http-client'; import type { AppConfigService } from '../config/config.service'; import type { RolesService } from '../roles/roles.service'; @@ -7,8 +7,82 @@ import type { SessionsService } from '../sessions/sessions.service'; import type { UsersRepository } from '../users/repositories/users.repository'; import type { OidcLoginStateEntity } from './entities/oidc-login-state.entity'; import { AuthService } from './auth.service'; +import type { OidcAdminSynchronizationService } from './oidc-admin-synchronization.service'; +import { UserEntity } from '../users/entities/user.entity'; +import { UserSettingsEntity } from '../users/entities/user-settings.entity'; +import type { RoleEntity } from '../roles/entities/role.entity'; describe('AuthService', () => { + it('does not grant the first or second provisioned user the admin role', async () => { + let sequence = 0; + const userRole = { + id: 'role-user', + name: 'user', + roleKey: 'USER', + } as RoleEntity; + const adminRole = { + id: 'role-admin', + name: 'admin', + roleKey: 'ADMIN', + } as RoleEntity; + const manager = { + query: () => Promise.resolve(), + getRepository: (entity: unknown) => { + if (entity === UserEntity) { + return { + findOne: () => Promise.resolve(null), + save: (user: UserEntity) => { + user.id = `user-${++sequence}`; + return Promise.resolve(user); + }, + }; + } + if (entity === UserSettingsEntity) { + return { + findOne: () => Promise.resolve(null), + save: (settings: UserSettingsEntity) => Promise.resolve(settings), + }; + } + throw new Error('Unerwartetes Repository'); + }, + } as unknown as EntityManager; + const service = new AuthService( + {} as AppConfigService, + {} as ExternalHttpClient, + { + ensureSystemRoles: () => + Promise.resolve({ admin: adminRole, user: userRole }), + } as unknown as RolesService, + {} as UsersRepository, + {} as SessionsService, + {} as OidcAdminSynchronizationService, + { + transaction: ( + action: (transactionManager: EntityManager) => Promise, + ) => action(manager), + } as DataSource, + {} as Repository, + ); + const provision = service as unknown as { + upsertLocalUser: ( + issuer: string, + profile: { sub: string; name: string }, + ) => Promise; + }; + + const first = await provision.upsertLocalUser('issuer', { + sub: 'first', + name: 'First', + }); + const second = await provision.upsertLocalUser('issuer', { + sub: 'second', + name: 'Second', + }); + + expect(first.roles.map((role) => role.roleKey)).toEqual(['USER']); + expect(second.roles.map((role) => role.roleKey)).toEqual(['USER']); + }); + it('stores only an internal return path in the short-lived OIDC login state', async () => { const saved: OidcLoginStateEntity[] = []; const service = new AuthService( @@ -36,6 +110,7 @@ describe('AuthService', () => { {} as RolesService, {} as UsersRepository, {} as SessionsService, + {} as OidcAdminSynchronizationService, {} as DataSource, { delete: () => Promise.resolve({}), @@ -76,6 +151,7 @@ describe('AuthService', () => { {} as RolesService, {} as UsersRepository, { revoke, getIdTokenForLogout } as unknown as SessionsService, + {} as OidcAdminSynchronizationService, {} as DataSource, {} as Repository, ); diff --git a/apps/backend/src/auth/auth.service.ts b/apps/backend/src/auth/auth.service.ts index 64d3d93..3bc0bc4 100644 --- a/apps/backend/src/auth/auth.service.ts +++ b/apps/backend/src/auth/auth.service.ts @@ -17,6 +17,8 @@ import type { OidcTokenResponse, OidcUserInfo, } from './oidc.types'; +import { OidcAdminSynchronizationService } from './oidc-admin-synchronization.service'; +import { UserRoleAssignmentSource } from '../users/entities/user-role-assignment.entity'; @Injectable() export class AuthService { @@ -26,6 +28,7 @@ export class AuthService { private readonly roles: RolesService, private readonly users: UsersRepository, private readonly sessions: SessionsService, + private readonly adminSynchronization: OidcAdminSynchronizationService, @InjectDataSource() private readonly dataSource: DataSource, @InjectRepository(OidcLoginStateEntity) private readonly loginStates: Repository, @@ -83,12 +86,12 @@ export class AuthService { code, loginState.codeVerifier, ); - const profile = await this.verifyAndLoadProfile( + const { profile, claims } = await this.verifyAndLoadProfile( discovery, tokens, loginState.nonce, ); - const user = await this.upsertLocalUser(discovery.issuer, profile); + let user = await this.upsertLocalUser(discovery.issuer, profile); if (!user.active) { throw new ApiError( ErrorCode.UserDisabled, @@ -96,6 +99,7 @@ export class AuthService { 403, ); } + user = await this.adminSynchronization.synchronize(user.id, claims); const result = await this.sessions.createSession( user, @@ -129,8 +133,7 @@ export class AuthService { return this.dataSource.transaction(async (manager) => { await manager.query("SELECT GET_LOCK('business_app_first_admin', 10)"); try { - const { admin, user: userRole } = - await this.roles.ensureSystemRoles(manager); + const { user: userRole } = await this.roles.ensureSystemRoles(manager); let user = await manager.getRepository(UserEntity).findOne({ where: { issuer, subject: profile.sub }, relations: { roles: true, settings: true }, @@ -141,10 +144,6 @@ export class AuthService { user.subject = profile.sub; user.active = true; user.roles = [userRole]; - const userCount = await manager.getRepository(UserEntity).count(); - if (userCount === 0) { - user.roles = [userRole, admin]; - } } user.name = profile.name ?? profile.email ?? profile.sub; user.email = profile.email ?? null; @@ -154,6 +153,12 @@ export class AuthService { : null; user.lastLoginAt = new Date(); const savedUser = await manager.getRepository(UserEntity).save(user); + await manager.query( + `INSERT IGNORE INTO user_role_assignments + (id, user_id, role_id, source, last_synchronized_at, created_at, updated_at) + VALUES (UUID(), ?, ?, ?, NULL, CURRENT_TIMESTAMP(3), CURRENT_TIMESTAMP(3))`, + [savedUser.id, userRole.id, UserRoleAssignmentSource.System], + ); savedUser.settings = await this.ensureUserSettings(manager, savedUser); return savedUser; } finally { @@ -266,7 +271,7 @@ export class AuthService { discovery: OidcDiscovery, tokens: OidcTokenResponse, nonce: string, - ): Promise { + ): Promise<{ profile: OidcUserInfo; claims: Record }> { const { createRemoteJWKSet, decodeProtectedHeader, jwtVerify } = await import('jose'); const protectedHeader = decodeProtectedHeader(tokens.id_token); @@ -305,7 +310,7 @@ export class AuthService { if (typeof payload['email_verified'] === 'boolean') { fallback.email_verified = payload['email_verified']; } - return fallback; + return { profile: fallback, claims: { ...payload } }; } const userInfo = await this.http.requestJson( discovery.userinfo_endpoint, @@ -335,7 +340,7 @@ export class AuthService { ) { userInfo.email_verified = payload['email_verified']; } - return userInfo; + return { profile: userInfo, claims: { ...payload, ...userInfo } }; } private isAllowedOidcAlgorithm(algorithm: string | undefined): boolean { diff --git a/apps/backend/src/auth/oidc-admin-synchronization.service.spec.ts b/apps/backend/src/auth/oidc-admin-synchronization.service.spec.ts new file mode 100644 index 0000000..9535793 --- /dev/null +++ b/apps/backend/src/auth/oidc-admin-synchronization.service.spec.ts @@ -0,0 +1,140 @@ +import { describe, expect, it, vi } from 'vitest'; +import type { DataSource, EntityManager } from 'typeorm'; +import type { AuditService } from '../audit/audit.service'; +import type { AppConfigService } from '../config/config.service'; +import type { RoleEntity } from '../roles/entities/role.entity'; +import type { RolesService } from '../roles/roles.service'; +import type { UserEntity } from '../users/entities/user.entity'; +import { UserRoleAssignmentSource } from '../users/entities/user-role-assignment.entity'; +import { OidcAdminSynchronizationService } from './oidc-admin-synchronization.service'; +import { OidcRoleExtractorService } from './oidc-role-extractor.service'; + +function fixture( + existing: boolean, + remainingSources = 0, + adminRole: string | undefined = 'hauspilot-admin', + caseSensitive = true, +) { + const assignment = { + id: 'assignment-1', + userId: 'user-1', + roleId: 'admin-role', + source: UserRoleAssignmentSource.Oidc, + lastSynchronizedAt: new Date(), + }; + const repository = { + findOneBy: vi.fn(() => Promise.resolve(existing ? assignment : null)), + save: vi.fn(() => Promise.resolve(assignment)), + insert: vi.fn(() => + Promise.resolve({ identifiers: [], generatedMaps: [], raw: [] }), + ), + remove: vi.fn(() => Promise.resolve(assignment)), + countBy: vi.fn(() => Promise.resolve(remainingSources)), + }; + const query = vi.fn(() => Promise.resolve([])); + const manager = { + getRepository: () => repository, + query, + } as unknown as EntityManager; + const user = { id: 'user-1', roles: [] } as unknown as UserEntity; + const dataSource = { + transaction: (callback: (value: EntityManager) => Promise) => + callback(manager), + getRepository: () => ({ findOne: () => Promise.resolve(user) }), + } as unknown as DataSource; + const audit = { + record: vi.fn(() => Promise.resolve()), + } as unknown as AuditService; + const roles = { + ensureSystemRoles: () => + Promise.resolve({ + admin: { id: 'admin-role' } as RoleEntity, + user: {} as RoleEntity, + }), + } as RolesService; + const config = { + oidc: { + adminRole, + rolesClaim: 'realm_access.roles', + roleMatchCaseSensitive: caseSensitive, + }, + } as AppConfigService; + return { + service: new OidcAdminSynchronizationService( + config, + new OidcRoleExtractorService(), + roles, + audit, + dataSource, + ), + repository, + query, + audit, + }; +} + +describe('OidcAdminSynchronizationService', () => { + it('adds the OIDC source and effective admin role idempotently', async () => { + const first = fixture(false); + await first.service.synchronize('user-1', { + realm_access: { roles: ['hauspilot-admin'] }, + }); + expect(first.query).toHaveBeenCalledWith( + expect.stringContaining('ON DUPLICATE KEY UPDATE'), + ['user-1', 'admin-role', UserRoleAssignmentSource.Oidc], + ); + expect(first.query).toHaveBeenCalledWith( + 'INSERT IGNORE INTO user_roles (user_id, role_id) VALUES (?, ?)', + ['user-1', 'admin-role'], + ); + + const repeated = fixture(true); + await repeated.service.synchronize('user-1', { + realm_access: { roles: ['hauspilot-admin'] }, + }); + expect(repeated.repository.save).toHaveBeenCalledOnce(); + }); + + it('removes only the OIDC source while another source keeps the effective role', async () => { + const current = fixture(true, 1); + await current.service.synchronize('user-1', { + realm_access: { roles: ['hauspilot-user'] }, + }); + expect(current.repository.remove).toHaveBeenCalledOnce(); + expect(current.query).not.toHaveBeenCalledWith( + 'DELETE FROM user_roles WHERE user_id = ? AND role_id = ?', + expect.anything(), + ); + }); + + it('fails safely without changing assignments when the configured claim is missing', async () => { + const current = fixture(true); + await expect( + current.service.synchronize('user-1', { sub: '123' }), + ).rejects.toMatchObject({ + status: 401, + }); + expect(current.repository.remove).not.toHaveBeenCalled(); + expect(current.repository.insert).not.toHaveBeenCalled(); + }); + + it('does not add or remove assignments when synchronization is disabled', async () => { + const current = fixture(true, 0, ''); + await current.service.synchronize('user-1', { + realm_access: { roles: [] }, + }); + expect(current.repository.remove).not.toHaveBeenCalled(); + expect(current.query).not.toHaveBeenCalled(); + }); + + it('honors case-insensitive matching only when explicitly configured', async () => { + const current = fixture(false, 0, 'hauspilot-admin', false); + await current.service.synchronize('user-1', { + realm_access: { roles: ['HAUSPILOT-ADMIN'] }, + }); + expect(current.query).toHaveBeenCalledWith( + expect.stringContaining('ON DUPLICATE KEY UPDATE'), + ['user-1', 'admin-role', UserRoleAssignmentSource.Oidc], + ); + }); +}); diff --git a/apps/backend/src/auth/oidc-admin-synchronization.service.ts b/apps/backend/src/auth/oidc-admin-synchronization.service.ts new file mode 100644 index 0000000..9b7fe4f --- /dev/null +++ b/apps/backend/src/auth/oidc-admin-synchronization.service.ts @@ -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>, + ): 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.', + ); + 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; + } +} diff --git a/apps/backend/src/auth/oidc-role-extractor.service.spec.ts b/apps/backend/src/auth/oidc-role-extractor.service.spec.ts new file mode 100644 index 0000000..fbb1704 --- /dev/null +++ b/apps/backend/src/auth/oidc-role-extractor.service.spec.ts @@ -0,0 +1,49 @@ +import { describe, expect, it } from 'vitest'; +import { OidcRoleExtractorService } from './oidc-role-extractor.service'; + +describe('OidcRoleExtractorService', () => { + const extractor = new OidcRoleExtractorService(); + + it('reads nested arrays, trims values and removes duplicates and empty roles', () => { + expect( + extractor.extract( + { + realm_access: { + roles: [ + ' hauspilot-admin ', + 'hauspilot-user', + '', + 'hauspilot-admin', + ], + }, + }, + 'realm_access.roles', + ), + ).toEqual({ readable: true, roles: ['hauspilot-admin', 'hauspilot-user'] }); + }); + + it('accepts a single role string', () => { + expect(extractor.extract({ roles: 'hauspilot-admin' }, 'roles')).toEqual({ + readable: true, + roles: ['hauspilot-admin'], + }); + }); + + it('distinguishes a missing claim from a valid claim without the admin role', () => { + expect(extractor.extract({ sub: '123' }, 'roles')).toEqual({ + readable: false, + reason: 'missing', + }); + expect(extractor.extract({ roles: ['hauspilot-user'] }, 'roles')).toEqual({ + readable: true, + roles: ['hauspilot-user'], + }); + }); + + it('rejects mixed or object claim formats', () => { + expect(extractor.extract({ roles: ['valid', 42] }, 'roles')).toEqual({ + readable: false, + reason: 'invalid', + }); + }); +}); diff --git a/apps/backend/src/auth/oidc-role-extractor.service.ts b/apps/backend/src/auth/oidc-role-extractor.service.ts new file mode 100644 index 0000000..a53f655 --- /dev/null +++ b/apps/backend/src/auth/oidc-role-extractor.service.ts @@ -0,0 +1,44 @@ +import { Injectable } from '@nestjs/common'; + +export type OidcRoleExtractionResult = + | { readable: true; roles: string[] } + | { readable: false; reason: 'missing' | 'invalid' }; + +@Injectable() +export class OidcRoleExtractorService { + extract( + claims: Readonly>, + claimPath: string, + ): OidcRoleExtractionResult { + let value: unknown = claims; + for (const segment of claimPath.split('.')) { + if ( + typeof value !== 'object' || + value === null || + Array.isArray(value) || + !(segment in value) + ) { + return { readable: false, reason: 'missing' }; + } + value = (value as Record)[segment]; + } + const values: unknown[] = + typeof value === 'string' ? [value] : Array.isArray(value) ? value : []; + if ( + (!Array.isArray(value) && typeof value !== 'string') || + values.some((entry) => typeof entry !== 'string') + ) { + return { readable: false, reason: 'invalid' }; + } + const roles: string[] = []; + for (const entry of values) { + if (typeof entry !== 'string') continue; + const role = entry.trim(); + if (role && !roles.includes(role)) roles.push(role); + } + return { + readable: true, + roles, + }; + } +} diff --git a/apps/backend/src/config/config.service.spec.ts b/apps/backend/src/config/config.service.spec.ts index e36faac..6372954 100644 --- a/apps/backend/src/config/config.service.spec.ts +++ b/apps/backend/src/config/config.service.spec.ts @@ -101,4 +101,30 @@ describe('loadConfigFromEnv', () => { }), ).toThrow(/OIDC_ALLOWED_ALGORITHMS/); }); + + it('reads and trims the OIDC administrator mapping without an unsafe default', () => { + const configured = loadConfigFromEnv({ + ...validEnv, + OIDC_ADMIN_ROLE: ' hauspilot-admin ', + OIDC_ROLES_CLAIM: 'realm_access.roles', + }); + const disabled = loadConfigFromEnv({ ...validEnv, OIDC_ADMIN_ROLE: '' }); + + expect(configured.oidc.adminRole).toBe('hauspilot-admin'); + expect(configured.oidc.rolesClaim).toBe('realm_access.roles'); + expect(disabled.oidc.adminRole).toBeUndefined(); + }); + + it('requires a valid roles claim path when OIDC admin synchronization is enabled', () => { + expect(() => + loadConfigFromEnv({ ...validEnv, OIDC_ADMIN_ROLE: 'hauspilot-admin' }), + ).toThrow(/OIDC_ROLES_CLAIM/); + expect(() => + loadConfigFromEnv({ + ...validEnv, + OIDC_ADMIN_ROLE: 'hauspilot-admin', + OIDC_ROLES_CLAIM: 'realm access.roles', + }), + ).toThrow(/OIDC_ROLES_CLAIM/); + }); }); diff --git a/apps/backend/src/config/config.types.ts b/apps/backend/src/config/config.types.ts index c02e04d..6342862 100644 --- a/apps/backend/src/config/config.types.ts +++ b/apps/backend/src/config/config.types.ts @@ -26,6 +26,9 @@ export interface AppConfig { scopes: string; allowedAlgorithms: string[]; httpTimeoutMs: number; + adminRole?: string; + rolesClaim?: string; + roleMatchCaseSensitive: boolean; logoutUrl?: string; }; session: { diff --git a/apps/backend/src/config/env.ts b/apps/backend/src/config/env.ts index 2ecb915..0635e89 100644 --- a/apps/backend/src/config/env.ts +++ b/apps/backend/src/config/env.ts @@ -10,85 +10,129 @@ const booleanFromString = z .pipe(z.enum(['true', 'false'])) .transform((value) => value === 'true'); -const envSchema = z.object({ - NODE_ENV: z - .enum(['development', 'test', 'production']) - .default('development'), - PORT: z.coerce.number().int().min(1).max(65535).default(3000), - APP_BASE_URL: z.url(), - FRONTEND_BASE_URL: z.url().optional(), - TRUST_PROXY: booleanFromString.default(false), - DATABASE_HOST: z.string().min(1), - DATABASE_PORT: z.coerce.number().int().min(1).max(65535).default(3306), - DATABASE_NAME: z.string().min(1), - DATABASE_USER: z.string().min(1), - DATABASE_PASSWORD: z.string().min(1), - DATABASE_SSL: booleanFromString.default(false), - OIDC_ISSUER: z.url(), - OIDC_CLIENT_ID: z.string().min(1), - OIDC_CLIENT_SECRET: z.string().min(1), - OIDC_SCOPES: z.string().min(1).default('openid profile email'), - OIDC_LOGOUT_URL: z.preprocess( - (value) => (value === '' ? undefined : value), - z.url().optional(), - ), - OIDC_ALLOWED_ALGORITHMS: z - .string() - .min(1) - .transform((value) => +const optionalTrimmedString = (maxLength: number) => + z.preprocess( + (value) => + typeof value === 'string' && value.trim() === '' ? undefined : value, + z + .string() + .trim() + .max(maxLength) + .refine((value) => + Array.from(value).every((character) => { + const code = character.charCodeAt(0); + return code > 31 && code !== 127; + }), + ) + .optional(), + ); + +const envSchema = z + .object({ + NODE_ENV: z + .enum(['development', 'test', 'production']) + .default('development'), + PORT: z.coerce.number().int().min(1).max(65535).default(3000), + APP_BASE_URL: z.url(), + FRONTEND_BASE_URL: z.url().optional(), + TRUST_PROXY: booleanFromString.default(false), + DATABASE_HOST: z.string().min(1), + DATABASE_PORT: z.coerce.number().int().min(1).max(65535).default(3306), + DATABASE_NAME: z.string().min(1), + DATABASE_USER: z.string().min(1), + DATABASE_PASSWORD: z.string().min(1), + DATABASE_SSL: booleanFromString.default(false), + OIDC_ISSUER: z.url(), + OIDC_CLIENT_ID: z.string().min(1), + OIDC_CLIENT_SECRET: z.string().min(1), + OIDC_SCOPES: z.string().min(1).default('openid profile email'), + OIDC_LOGOUT_URL: z.preprocess( + (value) => (value === '' ? undefined : value), + z.url().optional(), + ), + OIDC_ALLOWED_ALGORITHMS: z + .string() + .min(1) + .transform((value) => + value + .split(/[\s,]+/) + .map((entry) => entry.trim()) + .filter(Boolean), + ) + .refine( + (algorithms) => + algorithms.length > 0 && + algorithms.every((algorithm) => algorithm.toLowerCase() !== 'none'), + 'OIDC_ALLOWED_ALGORITHMS darf none nicht erlauben.', + ), + OIDC_HTTP_TIMEOUT_MS: z.coerce + .number() + .int() + .min(1000) + .max(30000) + .default(5000), + OIDC_ADMIN_ROLE: optionalTrimmedString(160), + OIDC_ROLES_CLAIM: optionalTrimmedString(255).refine( + (value) => + value === undefined || + /^[A-Za-z0-9_-]+(?:\.[A-Za-z0-9_-]+)*$/.test(value), + 'OIDC_ROLES_CLAIM muss ein gueltiger Claim-Pfad sein.', + ), + OIDC_ROLE_MATCH_CASE_SENSITIVE: booleanFromString.default(true), + SESSION_COOKIE_NAME: z.string().min(1).default('app_session'), + SESSION_IDLE_TIMEOUT_SECONDS: z.coerce + .number() + .int() + .min(300) + .default(28800), + SESSION_ABSOLUTE_TIMEOUT_SECONDS: z.coerce + .number() + .int() + .min(3600) + .default(604800), + SESSION_SECRET: z.string().min(32), + SESSION_ENCRYPTION_KEY: z.string().min(32), + CORS_ORIGINS: z.string().transform((value) => value - .split(/[\s,]+/) + .split(',') .map((entry) => entry.trim()) .filter(Boolean), - ) - .refine( - (algorithms) => - algorithms.length > 0 && - algorithms.every((algorithm) => algorithm.toLowerCase() !== 'none'), - 'OIDC_ALLOWED_ALGORITHMS darf none nicht erlauben.', ), - OIDC_HTTP_TIMEOUT_MS: z.coerce - .number() - .int() - .min(1000) - .max(30000) - .default(5000), - SESSION_COOKIE_NAME: z.string().min(1).default('app_session'), - SESSION_IDLE_TIMEOUT_SECONDS: z.coerce.number().int().min(300).default(28800), - SESSION_ABSOLUTE_TIMEOUT_SECONDS: z.coerce - .number() - .int() - .min(3600) - .default(604800), - SESSION_SECRET: z.string().min(32), - SESSION_ENCRYPTION_KEY: z.string().min(32), - CORS_ORIGINS: z.string().transform((value) => - value - .split(',') - .map((entry) => entry.trim()) - .filter(Boolean), - ), - CSRF_HEADER_NAME: z.string().min(1).default('X-CSRF-Token'), - LOG_LEVEL: z.string().min(1).default('info'), - SWAGGER_ENABLED: booleanFromString.default(false), - DOCUMENT_STORAGE_PATH: z.string().min(1).default('storage/documents'), - DOCUMENT_MAX_FILE_SIZE_BYTES: z.coerce - .number() - .int() - .min(1024) - .max(50 * 1024 * 1024) - .default(10 * 1024 * 1024), - REMINDER_INTERVAL_MS: z.coerce.number().int().min(60000).default(900000), - REMINDER_DUE_SOON_DAYS: z.coerce.number().int().min(1).max(30).default(3), - RATE_LIMIT_WINDOW_SECONDS: z.coerce.number().int().min(1).default(60), - RATE_LIMIT_MAX_REQUESTS: z.coerce.number().int().min(1).default(300), - RATE_LIMIT_SENSITIVE_WINDOW_SECONDS: z.coerce - .number() - .int() - .min(1) - .default(60), - RATE_LIMIT_SENSITIVE_MAX_REQUESTS: z.coerce.number().int().min(1).default(10), -}); + CSRF_HEADER_NAME: z.string().min(1).default('X-CSRF-Token'), + LOG_LEVEL: z.string().min(1).default('info'), + SWAGGER_ENABLED: booleanFromString.default(false), + DOCUMENT_STORAGE_PATH: z.string().min(1).default('storage/documents'), + DOCUMENT_MAX_FILE_SIZE_BYTES: z.coerce + .number() + .int() + .min(1024) + .max(50 * 1024 * 1024) + .default(10 * 1024 * 1024), + REMINDER_INTERVAL_MS: z.coerce.number().int().min(60000).default(900000), + REMINDER_DUE_SOON_DAYS: z.coerce.number().int().min(1).max(30).default(3), + RATE_LIMIT_WINDOW_SECONDS: z.coerce.number().int().min(1).default(60), + RATE_LIMIT_MAX_REQUESTS: z.coerce.number().int().min(1).default(300), + RATE_LIMIT_SENSITIVE_WINDOW_SECONDS: z.coerce + .number() + .int() + .min(1) + .default(60), + RATE_LIMIT_SENSITIVE_MAX_REQUESTS: z.coerce + .number() + .int() + .min(1) + .default(10), + }) + .superRefine((value, context) => { + if (value.OIDC_ADMIN_ROLE && !value.OIDC_ROLES_CLAIM) { + context.addIssue({ + code: 'custom', + path: ['OIDC_ROLES_CLAIM'], + message: + 'OIDC_ROLES_CLAIM ist erforderlich, wenn OIDC_ADMIN_ROLE gesetzt ist.', + }); + } + }); export function loadConfigFromEnv(env: Record): AppConfig { const parsed = envSchema.safeParse(env); @@ -134,6 +178,9 @@ export function loadConfigFromEnv(env: Record): AppConfig { scopes: value.OIDC_SCOPES, allowedAlgorithms: value.OIDC_ALLOWED_ALGORITHMS, httpTimeoutMs: value.OIDC_HTTP_TIMEOUT_MS, + roleMatchCaseSensitive: value.OIDC_ROLE_MATCH_CASE_SENSITIVE, + ...(value.OIDC_ADMIN_ROLE ? { adminRole: value.OIDC_ADMIN_ROLE } : {}), + ...(value.OIDC_ROLES_CLAIM ? { rolesClaim: value.OIDC_ROLES_CLAIM } : {}), ...(value.OIDC_LOGOUT_URL ? { logoutUrl: value.OIDC_LOGOUT_URL } : {}), }, session: { diff --git a/apps/backend/src/database/entities.ts b/apps/backend/src/database/entities.ts index dc748b1..d3cdc60 100644 --- a/apps/backend/src/database/entities.ts +++ b/apps/backend/src/database/entities.ts @@ -11,6 +11,7 @@ import { PermissionEntity } from '../roles/entities/permission.entity'; import { SessionEntity } from '../sessions/entities/session.entity'; import { UserSettingsEntity } from '../users/entities/user-settings.entity'; import { UserEntity } from '../users/entities/user.entity'; +import { UserRoleAssignmentEntity } from '../users/entities/user-role-assignment.entity'; import { BudgetCategoryEntity, BuildingEntity, @@ -49,6 +50,7 @@ export const entities = [ SessionEntity, UserSettingsEntity, UserEntity, + UserRoleAssignmentEntity, BuildingEntity, FloorEntity, RoomEntity, diff --git a/apps/backend/src/database/migrations/1720000008000-AddRoleAssignmentSources.ts b/apps/backend/src/database/migrations/1720000008000-AddRoleAssignmentSources.ts new file mode 100644 index 0000000..086ca02 --- /dev/null +++ b/apps/backend/src/database/migrations/1720000008000-AddRoleAssignmentSources.ts @@ -0,0 +1,46 @@ +import type { MigrationInterface, QueryRunner } from 'typeorm'; + +export class AddRoleAssignmentSources1720000008000 + implements MigrationInterface +{ + name = 'AddRoleAssignmentSources1720000008000'; + + async up(queryRunner: QueryRunner): Promise { + await queryRunner.query( + 'ALTER TABLE roles ADD COLUMN role_key varchar(40) NULL AFTER id, ADD UNIQUE KEY uq_roles_role_key (role_key)', + ); + await queryRunner.query( + "UPDATE roles SET role_key = 'ADMIN' WHERE name = 'admin'", + ); + await queryRunner.query( + "UPDATE roles SET role_key = 'USER' WHERE name = 'user'", + ); + await queryRunner.query(` + CREATE TABLE user_role_assignments ( + id char(36) NOT NULL, + user_id char(36) NOT NULL, + role_id char(36) NOT NULL, + source varchar(20) NOT NULL, + last_synchronized_at datetime(3) NULL, + created_at datetime(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3), + updated_at datetime(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3), + UNIQUE KEY uq_user_role_assignments_source (user_id, role_id, source), + KEY idx_user_role_assignments_user (user_id), + CONSTRAINT fk_user_role_assignments_user FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE, + CONSTRAINT fk_user_role_assignments_role FOREIGN KEY (role_id) REFERENCES roles(id) ON DELETE CASCADE, + PRIMARY KEY (id) + ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci + `); + await queryRunner.query(` + INSERT INTO user_role_assignments (id, user_id, role_id, source, last_synchronized_at) + SELECT UUID(), user_id, role_id, 'MANUAL', NULL FROM user_roles + `); + } + + async down(queryRunner: QueryRunner): Promise { + await queryRunner.query('DROP TABLE user_role_assignments'); + await queryRunner.query( + 'ALTER TABLE roles DROP INDEX uq_roles_role_key, DROP COLUMN role_key', + ); + } +} diff --git a/apps/backend/src/database/typeorm-cli.datasource.ts b/apps/backend/src/database/typeorm-cli.datasource.ts index 1afbff5..5d98bcf 100644 --- a/apps/backend/src/database/typeorm-cli.datasource.ts +++ b/apps/backend/src/database/typeorm-cli.datasource.ts @@ -10,6 +10,7 @@ import { AddRenovationDomain1720000004000 } from './migrations/1720000004000-Add import { AddMentionsAndReminders1720000005000 } from './migrations/1720000005000-AddMentionsAndReminders'; import { AddDefaultProjectFloors1720000006000 } from './migrations/1720000006000-AddDefaultProjectFloors'; import { AddFurniturePlanning1720000007000 } from './migrations/1720000007000-AddFurniturePlanning'; +import { AddRoleAssignmentSources1720000008000 } from './migrations/1720000008000-AddRoleAssignmentSources'; const config = loadConfigForCli(); @@ -35,5 +36,6 @@ export default new DataSource({ AddMentionsAndReminders1720000005000, AddDefaultProjectFloors1720000006000, AddFurniturePlanning1720000007000, + AddRoleAssignmentSources1720000008000, ], }); diff --git a/apps/backend/src/database/typeorm-options.ts b/apps/backend/src/database/typeorm-options.ts index 6e0ebc0..53d7266 100644 --- a/apps/backend/src/database/typeorm-options.ts +++ b/apps/backend/src/database/typeorm-options.ts @@ -9,6 +9,7 @@ import { AddRenovationDomain1720000004000 } from './migrations/1720000004000-Add import { AddMentionsAndReminders1720000005000 } from './migrations/1720000005000-AddMentionsAndReminders'; import { AddDefaultProjectFloors1720000006000 } from './migrations/1720000006000-AddDefaultProjectFloors'; import { AddFurniturePlanning1720000007000 } from './migrations/1720000007000-AddFurniturePlanning'; +import { AddRoleAssignmentSources1720000008000 } from './migrations/1720000008000-AddRoleAssignmentSources'; export function typeOrmOptionsFactory( config: AppConfigService, @@ -35,6 +36,7 @@ export function typeOrmOptionsFactory( AddMentionsAndReminders1720000005000, AddDefaultProjectFloors1720000006000, AddFurniturePlanning1720000007000, + AddRoleAssignmentSources1720000008000, ], }; } diff --git a/apps/backend/src/main.ts b/apps/backend/src/main.ts index c1dfcb7..7c7551b 100644 --- a/apps/backend/src/main.ts +++ b/apps/backend/src/main.ts @@ -3,6 +3,7 @@ import cookieParser from 'cookie-parser'; import type { NextFunction, Request, Response } from 'express'; import helmet from 'helmet'; import pinoHttp from 'pino-http'; +import { Logger } from '@nestjs/common'; import { NestFactory } from '@nestjs/core'; import { DocumentBuilder, SwaggerModule } from '@nestjs/swagger'; import type { NestExpressApplication } from '@nestjs/platform-express'; @@ -16,6 +17,11 @@ async function bootstrap() { bufferLogs: true, }); const config = app.get(AppConfigService); + if (!config.oidc.adminRole) { + new Logger('Configuration').warn( + 'OIDC_ADMIN_ROLE ist nicht konfiguriert. Die automatische Administrator-Synchronisierung ist deaktiviert; es gibt keinen First-User-Fallback.', + ); + } app.use( pinoHttp({ diff --git a/apps/backend/src/notifications/tests/notification-integrations.spec.ts b/apps/backend/src/notifications/tests/notification-integrations.spec.ts index 95cedd1..17e6ea5 100644 --- a/apps/backend/src/notifications/tests/notification-integrations.spec.ts +++ b/apps/backend/src/notifications/tests/notification-integrations.spec.ts @@ -65,11 +65,20 @@ describe('Notification integrations', () => { active: true, roles: [{ id: 'role-user', name: 'user' }], }; + let userLoad = 0; const users: Pick = { - findByIdWithRoles: () => - Promise.resolve( - user as Awaited>, - ), + findByIdWithRoles: () => { + const loaded = + userLoad++ === 0 + ? user + : { + ...user, + roles: [...user.roles, { id: 'role-editor', name: 'Editor' }], + }; + return Promise.resolve( + loaded as Awaited>, + ); + }, save: (entry) => Promise.resolve(entry), }; const roles: Pick = { @@ -87,8 +96,28 @@ describe('Notification integrations', () => { ); }, }; + const assignmentRepository = { + findBy: () => + Promise.resolve([ + { userId: 'user-1', roleId: 'role-user', source: 'MANUAL' }, + ]), + remove: () => Promise.resolve(), + insert: () => Promise.resolve(), + createQueryBuilder: () => ({ + select: () => ({ + where: () => ({ + getRawMany: () => + Promise.resolve([ + { roleId: 'role-user' }, + { roleId: 'role-editor' }, + ]), + }), + }), + }), + }; const manager = { query: () => Promise.resolve(), + getRepository: () => assignmentRepository, } as unknown as EntityManager; const dataSource = { transaction: (action: (manager: EntityManager) => Promise) => diff --git a/apps/backend/src/roles/entities/role.entity.ts b/apps/backend/src/roles/entities/role.entity.ts index 19c37c1..edf7a8c 100644 --- a/apps/backend/src/roles/entities/role.entity.ts +++ b/apps/backend/src/roles/entities/role.entity.ts @@ -17,6 +17,9 @@ export class RoleEntity { @PrimaryGeneratedColumn('uuid') id!: string; + @Column({ name: 'role_key', type: 'varchar', length: 40, nullable: true }) + roleKey!: string | null; + @Column({ type: 'varchar', length: 80 }) name!: string; diff --git a/apps/backend/src/roles/roles.service.ts b/apps/backend/src/roles/roles.service.ts index 38b6341..fc3b47e 100644 --- a/apps/backend/src/roles/roles.service.ts +++ b/apps/backend/src/roles/roles.service.ts @@ -11,6 +11,8 @@ import { RoleEntity } from './entities/role.entity'; import { allPermissions, Permission } from './permissions'; import type { CreateRoleDto, UpdateRoleDto } from './dto/role.dto'; import { RolesRepository } from './repositories/roles.repository'; +import { AppConfigService } from '../config/config.service'; +import { adminRoleKey, userRoleKey } from './system-role-keys'; const adminRoleName = 'admin'; const userRoleName = 'user'; @@ -20,6 +22,7 @@ export class RolesService { constructor( private readonly roles: RolesRepository, private readonly audit: AuditService, + private readonly config: AppConfigService, @InjectDataSource() private readonly dataSource: DataSource, ) {} @@ -78,12 +81,12 @@ export class RolesService { await this.assertRoleNameAvailable(normalizedName, id, manager); role.name = normalizedName; } - if (role.name === adminRoleName) { + if (role.roleKey === adminRoleKey) { this.assertAdminPermissions(dto.permissions); } role.description = dto.description?.trim() ?? ''; role.permissions = await this.loadPermissionEntities( - role.name === adminRoleName ? allPermissions : dto.permissions, + role.roleKey === adminRoleKey ? allPermissions : dto.permissions, manager, ); const saved = await this.roles.save(role, manager); @@ -178,12 +181,14 @@ export class RolesService { await this.syncPermissions(manager); const admin = await this.ensureRole( adminRoleName, + adminRoleKey, allPermissions, true, manager, ); const user = await this.ensureRole( userRoleName, + userRoleKey, [ Permission.ItemsRead, Permission.SessionsReadOwn, @@ -223,6 +228,7 @@ export class RolesService { private async ensureRole( name: string, + roleKey: string, permissions: Permission[], protectedRole: boolean, manager?: EntityManager, @@ -237,6 +243,7 @@ export class RolesService { role.name = name; role.protected = protectedRole; } + role.roleKey = roleKey; role.permissions = await this.loadPermissionEntities(permissions, manager); role.protected = protectedRole; return repo.save(role); @@ -303,6 +310,13 @@ export class RolesService { description: role.description, system: role.protected, protected: role.protected, + roleKey: role.roleKey, + oidcManaged: + role.roleKey === adminRoleKey && Boolean(this.config.oidc.adminRole), + oidcRoleName: + role.roleKey === adminRoleKey + ? (this.config.oidc.adminRole ?? null) + : null, permissions: role.permissions, userCount: role.users?.length ?? 0, users: role.users, diff --git a/apps/backend/src/roles/system-role-keys.ts b/apps/backend/src/roles/system-role-keys.ts new file mode 100644 index 0000000..19f91fe --- /dev/null +++ b/apps/backend/src/roles/system-role-keys.ts @@ -0,0 +1,2 @@ +export const adminRoleKey = 'ADMIN'; +export const userRoleKey = 'USER'; diff --git a/apps/backend/src/users/entities/user-role-assignment.entity.ts b/apps/backend/src/users/entities/user-role-assignment.entity.ts new file mode 100644 index 0000000..6aee092 --- /dev/null +++ b/apps/backend/src/users/entities/user-role-assignment.entity.ts @@ -0,0 +1,46 @@ +import { + Column, + CreateDateColumn, + Entity, + Index, + PrimaryGeneratedColumn, + Unique, + UpdateDateColumn, +} from 'typeorm'; + +export enum UserRoleAssignmentSource { + Manual = 'MANUAL', + Oidc = 'OIDC', + System = 'SYSTEM', +} + +@Entity('user_role_assignments') +@Unique('uq_user_role_assignments_source', ['userId', 'roleId', 'source']) +@Index('idx_user_role_assignments_user', ['userId']) +export class UserRoleAssignmentEntity { + @PrimaryGeneratedColumn('uuid') + id!: string; + + @Column({ name: 'user_id', type: 'char', length: 36 }) + userId!: string; + + @Column({ name: 'role_id', type: 'char', length: 36 }) + roleId!: string; + + @Column({ type: 'varchar', length: 20 }) + source!: UserRoleAssignmentSource; + + @Column({ + name: 'last_synchronized_at', + type: 'datetime', + precision: 3, + nullable: true, + }) + lastSynchronizedAt!: Date | null; + + @CreateDateColumn({ name: 'created_at', type: 'datetime', precision: 3 }) + createdAt!: Date; + + @UpdateDateColumn({ name: 'updated_at', type: 'datetime', precision: 3 }) + updatedAt!: Date; +} diff --git a/apps/backend/src/users/repositories/users.repository.ts b/apps/backend/src/users/repositories/users.repository.ts index 468b17b..5c0141d 100644 --- a/apps/backend/src/users/repositories/users.repository.ts +++ b/apps/backend/src/users/repositories/users.repository.ts @@ -6,6 +6,7 @@ import type { AdminUserSortField, } from '../dto/user.dto'; import { UserEntity } from '../entities/user.entity'; +import { adminRoleKey } from '../../roles/system-role-keys'; @Injectable() export class UsersRepository { @@ -115,7 +116,7 @@ export class UsersRepository { .createQueryBuilder('user') .innerJoin('user.roles', 'role') .where('user.active = :active', { active: true }) - .andWhere('role.name = :role', { role: 'admin' }) + .andWhere('role.roleKey = :roleKey', { roleKey: adminRoleKey }) .orderBy('user.createdAt', 'ASC'); if (excludedUserId) { qb.andWhere('user.id <> :excludedUserId', { excludedUserId }); diff --git a/apps/backend/src/users/users.module.ts b/apps/backend/src/users/users.module.ts index c20620c..71f055e 100644 --- a/apps/backend/src/users/users.module.ts +++ b/apps/backend/src/users/users.module.ts @@ -9,10 +9,15 @@ import { UserEntity } from './entities/user.entity'; import { UsersRepository } from './repositories/users.repository'; import { UsersController } from './users.controller'; import { UsersService } from './users.service'; +import { UserRoleAssignmentEntity } from './entities/user-role-assignment.entity'; @Module({ imports: [ - TypeOrmModule.forFeature([UserEntity, UserSettingsEntity]), + TypeOrmModule.forFeature([ + UserEntity, + UserSettingsEntity, + UserRoleAssignmentEntity, + ]), RolesModule, AuditModule, SessionsModule, diff --git a/apps/backend/src/users/users.service.ts b/apps/backend/src/users/users.service.ts index 77b5d94..e1c864f 100644 --- a/apps/backend/src/users/users.service.ts +++ b/apps/backend/src/users/users.service.ts @@ -9,11 +9,16 @@ import { ErrorCode } from '../common/errors/error-codes'; import { NotificationType } from '../notifications/notification-types'; import { NotificationsService } from '../notifications/notifications.service'; import { RolesService } from '../roles/roles.service'; +import { adminRoleKey } from '../roles/system-role-keys'; import { RoleEntity } from '../roles/entities/role.entity'; import { SessionsService } from '../sessions/sessions.service'; import type { AdminSessionDto } from '../sessions/sessions.service'; import { UserSettingsEntity } from './entities/user-settings.entity'; import { UserEntity } from './entities/user.entity'; +import { + UserRoleAssignmentEntity, + UserRoleAssignmentSource, +} from './entities/user-role-assignment.entity'; import type { AdminUserListQueryDto } from './dto/user.dto'; import { UsersRepository } from './repositories/users.repository'; @@ -22,6 +27,7 @@ interface AdminRoleSummary { name: string; description: string; system: boolean; + sources: UserRoleAssignmentSource[]; } interface AdminUserListItem { @@ -63,7 +69,7 @@ export class UsersService { const [users, total] = await this.users.adminSearch(query); const items = await Promise.all( users.map(async (user) => ({ - ...this.toAdminListItem(user), + ...this.toAdminListItem(user, await this.roleSources(user.id)), activeSessionCount: await this.sessions.countActiveForUser(user.id), })), ); @@ -76,7 +82,7 @@ export class UsersService { ): Promise { const user = await this.getWithRoles(id); return { - ...this.toAdminListItem(user), + ...this.toAdminListItem(user, await this.roleSources(user.id)), activeSessionCount: await this.sessions.countActiveForUser(user.id), effectivePermissions: this.effectivePermissions(user), sessions: await this.sessions.listForAdmin(user.id, currentSessionId), @@ -120,7 +126,7 @@ export class UsersService { 409, ); } - if (!active && this.hasRole(user, 'admin')) { + if (!active && this.hasRole(user, adminRoleKey)) { await this.assertAnotherActiveAdminRemains(userId, manager); } user.active = active; @@ -151,14 +157,8 @@ export class UsersService { userId: string, roleId: string, ): Promise { - const user = await this.getWithRoles(userId); - if (user.roles.some((role) => role.id === roleId)) { - return user; - } - return this.replaceRoles(actor, userId, [ - ...user.roles.map((role) => role.id), - roleId, - ]); + const roleIds = await this.manualRoleIds(userId); + return this.replaceRoles(actor, userId, [...roleIds, roleId]); } async removeRole( @@ -166,14 +166,11 @@ export class UsersService { userId: string, roleId: string, ): Promise { - const user = await this.getWithRoles(userId); - if (!user.roles.some((role) => role.id === roleId)) { - return user; - } + const roleIds = await this.manualRoleIds(userId); return this.replaceRoles( actor, userId, - user.roles.filter((role) => role.id !== roleId).map((role) => role.id), + roleIds.filter((id) => id !== roleId), ); } @@ -235,7 +232,7 @@ export class UsersService { .innerJoin('user.roles', 'role') .where('user.active = :active', { active: true }) .andWhere('user.id <> :excludedUserId', { excludedUserId }) - .andWhere('role.name = :role', { role: 'admin' }) + .andWhere('role.roleKey = :roleKey', { roleKey: adminRoleKey }) .getCount(); if (result < 1) { throw new ApiError( @@ -254,16 +251,58 @@ export class UsersService { return this.withAdminLock(async (manager) => { const user = await this.getWithRoles(userId, manager); const previousRoleNames = new Set(user.roles.map((role) => role.name)); - const previousRoleIds = new Set(user.roles.map((role) => role.id)); - const roles = await Promise.all( - roleIds.map((id) => this.roles.getRole(id, manager)), + const previouslyAdmin = user.roles.some( + (role) => role.roleKey === adminRoleKey, ); - const nextRoleNames = new Set(roles.map((role) => role.name)); - user.roles = roles; - if (previousRoleNames.has('admin') && !nextRoleNames.has('admin')) { + const assignmentRepo = manager.getRepository(UserRoleAssignmentEntity); + const previousAssignments = await assignmentRepo.findBy({ + userId, + source: UserRoleAssignmentSource.Manual, + }); + const previousRoleIds = new Set( + previousAssignments.map((entry) => entry.roleId), + ); + const roles = await Promise.all( + [...new Set(roleIds)].map((id) => this.roles.getRole(id, manager)), + ); + const nextManualIds = new Set(roles.map((role) => role.id)); + for (const assignment of previousAssignments) { + if (!nextManualIds.has(assignment.roleId)) + await assignmentRepo.remove(assignment); + } + for (const role of roles) { + if (!previousRoleIds.has(role.id)) { + await assignmentRepo.insert({ + userId, + roleId: role.id, + source: UserRoleAssignmentSource.Manual, + lastSynchronizedAt: null, + }); + } + } + const effectiveRows = await assignmentRepo + .createQueryBuilder('assignment') + .select('DISTINCT assignment.roleId', 'roleId') + .where('assignment.userId = :userId', { userId }) + .getRawMany<{ roleId: string }>(); + const effectiveRoles = await Promise.all( + effectiveRows.map((entry) => this.roles.getRole(entry.roleId, manager)), + ); + const nextRoleNames = new Set(effectiveRoles.map((role) => role.name)); + if ( + previouslyAdmin && + !effectiveRoles.some((role) => role.roleKey === adminRoleKey) + ) { await this.assertAnotherActiveAdminRemains(userId, manager); } - const saved = await this.users.save(user, manager); + await manager.query('DELETE FROM user_roles WHERE user_id = ?', [userId]); + for (const role of effectiveRoles) { + await manager.query( + 'INSERT INTO user_roles (user_id, role_id) VALUES (?, ?)', + [userId, role.id], + ); + } + const saved = await this.getWithRoles(user.id, manager); await this.recordRoleAudit(actor, userId, previousRoleIds, roles); await this.notifyRoleChanges(userId, previousRoleNames, nextRoleNames); return saved; @@ -333,7 +372,10 @@ export class UsersService { }); } - private toAdminListItem(user: UserEntity): AdminUserListItem { + private toAdminListItem( + user: UserEntity, + sources: Map, + ): AdminUserListItem { return { id: user.id, name: user.name, @@ -344,6 +386,7 @@ export class UsersService { name: role.name, description: role.description, system: role.protected, + sources: sources.get(role.id) ?? [], })), lastLoginAt: user.lastLoginAt?.toISOString() ?? null, createdAt: user.createdAt.toISOString(), @@ -351,6 +394,34 @@ export class UsersService { }; } + private async manualRoleIds(userId: string): Promise { + const assignments = await this.dataSource + .getRepository(UserRoleAssignmentEntity) + .findBy({ + userId, + source: UserRoleAssignmentSource.Manual, + }); + return assignments.map((entry) => entry.roleId); + } + + private async roleSources( + userId: string, + ): Promise> { + const assignments = await this.dataSource + .getRepository(UserRoleAssignmentEntity) + .findBy({ + userId, + }); + const result = new Map(); + for (const assignment of assignments) { + result.set(assignment.roleId, [ + ...(result.get(assignment.roleId) ?? []), + assignment.source, + ]); + } + return result; + } + private effectivePermissions(user: UserEntity): string[] { return Array.from( new Set( @@ -361,8 +432,8 @@ export class UsersService { ).sort(); } - private hasRole(user: UserEntity, roleName: string): boolean { - return user.roles.some((role) => role.name === roleName); + private hasRole(user: UserEntity, roleKey: string): boolean { + return user.roles.some((role) => role.roleKey === roleKey); } private async notifyRoleChanges( diff --git a/apps/frontend/src/app/features/admin/admin-role-detail.page.spec.ts b/apps/frontend/src/app/features/admin/admin-role-detail.page.spec.ts index 03a994d..93d8d0f 100644 --- a/apps/frontend/src/app/features/admin/admin-role-detail.page.spec.ts +++ b/apps/frontend/src/app/features/admin/admin-role-detail.page.spec.ts @@ -3,6 +3,7 @@ import { ActivatedRoute, Router } from '@angular/router'; import { ApiClientService } from '@boilerplate/api-client'; import { AdminRoleDetailPageComponent } from './admin-role-detail.page'; import { adminPermissionGroups } from './admin-permissions'; +import { of } from 'rxjs'; describe('AdminRoleDetailPageComponent', () => { it('groups permissions for the role editor', async () => { @@ -31,4 +32,41 @@ describe('AdminRoleDetailPageComponent', () => { expect(element.textContent).toContain('Benutzer aktivieren'); expect(element.textContent).toContain('notifications.manage'); }); + + it('shows the environment-managed OIDC mapping for the system admin role', async () => { + await TestBed.configureTestingModule({ + imports: [AdminRoleDetailPageComponent], + providers: [ + { + provide: ActivatedRoute, + useValue: { snapshot: { paramMap: { get: () => 'admin-role' } } }, + }, + { provide: Router, useValue: { navigate: vi.fn() } }, + { + provide: ApiClientService, + useValue: { + adminRole: () => + of({ + id: 'admin-role', + name: 'admin', + description: 'Administrator', + system: true, + protected: true, + roleKey: 'ADMIN', + oidcManaged: true, + oidcRoleName: 'hauspilot-admin', + permissions: [], + }), + }, + }, + ], + }).compileComponents(); + const fixture = TestBed.createComponent(AdminRoleDetailPageComponent); + fixture.detectChanges(); + const host = fixture.nativeElement as HTMLElement; + + expect(host.textContent).toContain('Automatische OIDC-Verwaltung'); + expect(host.textContent).toContain('hauspilot-admin'); + expect(host.textContent).toContain('OIDC_ADMIN_ROLE'); + }); }); diff --git a/apps/frontend/src/app/features/admin/admin-role-detail.page.ts b/apps/frontend/src/app/features/admin/admin-role-detail.page.ts index acfab26..3584ed5 100644 --- a/apps/frontend/src/app/features/admin/admin-role-detail.page.ts +++ b/apps/frontend/src/app/features/admin/admin-role-detail.page.ts @@ -31,6 +31,19 @@ import { adminPermissionGroups } from './admin-permissions'; @if (role()?.system) { Systemrolle } + @if (role()?.roleKey === 'ADMIN'; as isAdminRole) { + + }