generated from bastian/boilerplate
deploy
This commit is contained in:
@@ -18,6 +18,9 @@ OIDC_SCOPES=openid profile email
|
|||||||
OIDC_LOGOUT_URL=
|
OIDC_LOGOUT_URL=
|
||||||
OIDC_ALLOWED_ALGORITHMS=RS256
|
OIDC_ALLOWED_ALGORITHMS=RS256
|
||||||
OIDC_HTTP_TIMEOUT_MS=5000
|
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_COOKIE_NAME=app_session
|
||||||
SESSION_IDLE_TIMEOUT_SECONDS=28800
|
SESSION_IDLE_TIMEOUT_SECONDS=28800
|
||||||
|
|||||||
@@ -61,6 +61,7 @@ laufen.
|
|||||||
- [Architektur](docs/architecture.md): Monorepo, Backend, Frontend, API-Client und Modulgrenzen
|
- [Architektur](docs/architecture.md): Monorepo, Backend, Frontend, API-Client und Modulgrenzen
|
||||||
- [Entwicklung](docs/development.md): Workflows fuer Features, Migrationen, Tests und API-Client
|
- [Entwicklung](docs/development.md): Workflows fuer Features, Migrationen, Tests und API-Client
|
||||||
- [Security-Modell](docs/security.md): OIDC, Sessions, CSRF, Rollen, Permissions und Logging
|
- [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
|
- [Designsystem](docs/design-system.md): Tokens, UI-Komponenten, responsive Regeln und Accessibility
|
||||||
- [Adminbereich](docs/admin.md): Benutzer, Rollen, Sessions, Audit und letzter-Admin-Schutz
|
- [Adminbereich](docs/admin.md): Benutzer, Rollen, Sessions, Audit und letzter-Admin-Schutz
|
||||||
- [Benachrichtigungen](docs/notifications.md): Datenmodell, API, Permissions, Polling und Erweiterung
|
- [Benachrichtigungen](docs/notifications.md): Datenmodell, API, Permissions, Polling und Erweiterung
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import { Injectable } from '@nestjs/common';
|
|||||||
import { getRequestId } from '../common/request-context/request-context';
|
import { getRequestId } from '../common/request-context/request-context';
|
||||||
import { AuditAction, AuditLogEntity } from './entities/audit-log.entity';
|
import { AuditAction, AuditLogEntity } from './entities/audit-log.entity';
|
||||||
import { AuditRepository } from './repositories/audit.repository';
|
import { AuditRepository } from './repositories/audit.repository';
|
||||||
|
import type { EntityManager } from 'typeorm';
|
||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
export class AuditService {
|
export class AuditService {
|
||||||
@@ -13,6 +14,7 @@ export class AuditService {
|
|||||||
targetType: string,
|
targetType: string,
|
||||||
targetId: string,
|
targetId: string,
|
||||||
metadata: Record<string, string | number | boolean | null> | null = null,
|
metadata: Record<string, string | number | boolean | null> | null = null,
|
||||||
|
manager?: EntityManager,
|
||||||
): Promise<void> {
|
): Promise<void> {
|
||||||
const entry = new AuditLogEntity();
|
const entry = new AuditLogEntity();
|
||||||
entry.actorUserId = actorUserId;
|
entry.actorUserId = actorUserId;
|
||||||
@@ -21,7 +23,7 @@ export class AuditService {
|
|||||||
entry.targetId = targetId;
|
entry.targetId = targetId;
|
||||||
entry.metadata = metadata;
|
entry.metadata = metadata;
|
||||||
entry.requestId = getRequestId();
|
entry.requestId = getRequestId();
|
||||||
await this.audit.save(entry);
|
await this.audit.save(entry, manager);
|
||||||
}
|
}
|
||||||
|
|
||||||
async list(page = 1, pageSize = 20) {
|
async list(page = 1, pageSize = 20) {
|
||||||
|
|||||||
@@ -11,6 +11,8 @@ export enum AuditAction {
|
|||||||
UserDeactivated = 'USER_DEACTIVATED',
|
UserDeactivated = 'USER_DEACTIVATED',
|
||||||
UserRoleAssigned = 'USER_ROLE_ASSIGNED',
|
UserRoleAssigned = 'USER_ROLE_ASSIGNED',
|
||||||
UserRoleRemoved = 'USER_ROLE_REMOVED',
|
UserRoleRemoved = 'USER_ROLE_REMOVED',
|
||||||
|
OidcAdminAssigned = 'OIDC_ADMIN_ASSIGNED',
|
||||||
|
OidcAdminRemoved = 'OIDC_ADMIN_REMOVED',
|
||||||
RoleCreated = 'ROLE_CREATED',
|
RoleCreated = 'ROLE_CREATED',
|
||||||
RoleUpdated = 'ROLE_UPDATED',
|
RoleUpdated = 'ROLE_UPDATED',
|
||||||
RoleDeleted = 'ROLE_DELETED',
|
RoleDeleted = 'ROLE_DELETED',
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { Injectable } from '@nestjs/common';
|
import { Injectable } from '@nestjs/common';
|
||||||
import { InjectRepository } from '@nestjs/typeorm';
|
import { InjectRepository } from '@nestjs/typeorm';
|
||||||
import { Repository } from 'typeorm';
|
import { EntityManager, Repository } from 'typeorm';
|
||||||
import { AuditLogEntity } from '../entities/audit-log.entity';
|
import { AuditLogEntity } from '../entities/audit-log.entity';
|
||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
@@ -10,8 +10,11 @@ export class AuditRepository {
|
|||||||
private readonly repo: Repository<AuditLogEntity>,
|
private readonly repo: Repository<AuditLogEntity>,
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
save(entry: AuditLogEntity): Promise<AuditLogEntity> {
|
save(
|
||||||
return this.repo.save(entry);
|
entry: AuditLogEntity,
|
||||||
|
manager?: EntityManager,
|
||||||
|
): Promise<AuditLogEntity> {
|
||||||
|
return (manager?.getRepository(AuditLogEntity) ?? this.repo).save(entry);
|
||||||
}
|
}
|
||||||
|
|
||||||
list(page: number, pageSize: number): Promise<[AuditLogEntity[], number]> {
|
list(page: number, pageSize: number): Promise<[AuditLogEntity[], number]> {
|
||||||
|
|||||||
@@ -9,6 +9,10 @@ import { UsersRepository } from '../users/repositories/users.repository';
|
|||||||
import { OidcLoginStateEntity } from './entities/oidc-login-state.entity';
|
import { OidcLoginStateEntity } from './entities/oidc-login-state.entity';
|
||||||
import { AuthController } from './auth.controller';
|
import { AuthController } from './auth.controller';
|
||||||
import { AuthService } from './auth.service';
|
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({
|
@Module({
|
||||||
imports: [
|
imports: [
|
||||||
@@ -16,12 +20,20 @@ import { AuthService } from './auth.service';
|
|||||||
OidcLoginStateEntity,
|
OidcLoginStateEntity,
|
||||||
UserEntity,
|
UserEntity,
|
||||||
UserSettingsEntity,
|
UserSettingsEntity,
|
||||||
|
UserRoleAssignmentEntity,
|
||||||
]),
|
]),
|
||||||
RolesModule,
|
RolesModule,
|
||||||
SessionsModule,
|
SessionsModule,
|
||||||
|
AuditModule,
|
||||||
],
|
],
|
||||||
controllers: [AuthController],
|
controllers: [AuthController],
|
||||||
providers: [AuthService, ExternalHttpClient, UsersRepository],
|
providers: [
|
||||||
|
AuthService,
|
||||||
|
ExternalHttpClient,
|
||||||
|
UsersRepository,
|
||||||
|
OidcRoleExtractorService,
|
||||||
|
OidcAdminSynchronizationService,
|
||||||
|
],
|
||||||
exports: [AuthService],
|
exports: [AuthService],
|
||||||
})
|
})
|
||||||
export class AuthModule {}
|
export class AuthModule {}
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { describe, expect, it, vi } from 'vitest';
|
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 { ExternalHttpClient } from '../common/http/external-http-client';
|
||||||
import type { AppConfigService } from '../config/config.service';
|
import type { AppConfigService } from '../config/config.service';
|
||||||
import type { RolesService } from '../roles/roles.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 { UsersRepository } from '../users/repositories/users.repository';
|
||||||
import type { OidcLoginStateEntity } from './entities/oidc-login-state.entity';
|
import type { OidcLoginStateEntity } from './entities/oidc-login-state.entity';
|
||||||
import { AuthService } from './auth.service';
|
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', () => {
|
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: <T>(
|
||||||
|
action: (transactionManager: EntityManager) => Promise<T>,
|
||||||
|
) => action(manager),
|
||||||
|
} as DataSource,
|
||||||
|
{} as Repository<OidcLoginStateEntity>,
|
||||||
|
);
|
||||||
|
const provision = service as unknown as {
|
||||||
|
upsertLocalUser: (
|
||||||
|
issuer: string,
|
||||||
|
profile: { sub: string; name: string },
|
||||||
|
) => Promise<UserEntity>;
|
||||||
|
};
|
||||||
|
|
||||||
|
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 () => {
|
it('stores only an internal return path in the short-lived OIDC login state', async () => {
|
||||||
const saved: OidcLoginStateEntity[] = [];
|
const saved: OidcLoginStateEntity[] = [];
|
||||||
const service = new AuthService(
|
const service = new AuthService(
|
||||||
@@ -36,6 +110,7 @@ describe('AuthService', () => {
|
|||||||
{} as RolesService,
|
{} as RolesService,
|
||||||
{} as UsersRepository,
|
{} as UsersRepository,
|
||||||
{} as SessionsService,
|
{} as SessionsService,
|
||||||
|
{} as OidcAdminSynchronizationService,
|
||||||
{} as DataSource,
|
{} as DataSource,
|
||||||
{
|
{
|
||||||
delete: () => Promise.resolve({}),
|
delete: () => Promise.resolve({}),
|
||||||
@@ -76,6 +151,7 @@ describe('AuthService', () => {
|
|||||||
{} as RolesService,
|
{} as RolesService,
|
||||||
{} as UsersRepository,
|
{} as UsersRepository,
|
||||||
{ revoke, getIdTokenForLogout } as unknown as SessionsService,
|
{ revoke, getIdTokenForLogout } as unknown as SessionsService,
|
||||||
|
{} as OidcAdminSynchronizationService,
|
||||||
{} as DataSource,
|
{} as DataSource,
|
||||||
{} as Repository<OidcLoginStateEntity>,
|
{} as Repository<OidcLoginStateEntity>,
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -17,6 +17,8 @@ import type {
|
|||||||
OidcTokenResponse,
|
OidcTokenResponse,
|
||||||
OidcUserInfo,
|
OidcUserInfo,
|
||||||
} from './oidc.types';
|
} from './oidc.types';
|
||||||
|
import { OidcAdminSynchronizationService } from './oidc-admin-synchronization.service';
|
||||||
|
import { UserRoleAssignmentSource } from '../users/entities/user-role-assignment.entity';
|
||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
export class AuthService {
|
export class AuthService {
|
||||||
@@ -26,6 +28,7 @@ export class AuthService {
|
|||||||
private readonly roles: RolesService,
|
private readonly roles: RolesService,
|
||||||
private readonly users: UsersRepository,
|
private readonly users: UsersRepository,
|
||||||
private readonly sessions: SessionsService,
|
private readonly sessions: SessionsService,
|
||||||
|
private readonly adminSynchronization: OidcAdminSynchronizationService,
|
||||||
@InjectDataSource() private readonly dataSource: DataSource,
|
@InjectDataSource() private readonly dataSource: DataSource,
|
||||||
@InjectRepository(OidcLoginStateEntity)
|
@InjectRepository(OidcLoginStateEntity)
|
||||||
private readonly loginStates: Repository<OidcLoginStateEntity>,
|
private readonly loginStates: Repository<OidcLoginStateEntity>,
|
||||||
@@ -83,12 +86,12 @@ export class AuthService {
|
|||||||
code,
|
code,
|
||||||
loginState.codeVerifier,
|
loginState.codeVerifier,
|
||||||
);
|
);
|
||||||
const profile = await this.verifyAndLoadProfile(
|
const { profile, claims } = await this.verifyAndLoadProfile(
|
||||||
discovery,
|
discovery,
|
||||||
tokens,
|
tokens,
|
||||||
loginState.nonce,
|
loginState.nonce,
|
||||||
);
|
);
|
||||||
const user = await this.upsertLocalUser(discovery.issuer, profile);
|
let user = await this.upsertLocalUser(discovery.issuer, profile);
|
||||||
if (!user.active) {
|
if (!user.active) {
|
||||||
throw new ApiError(
|
throw new ApiError(
|
||||||
ErrorCode.UserDisabled,
|
ErrorCode.UserDisabled,
|
||||||
@@ -96,6 +99,7 @@ export class AuthService {
|
|||||||
403,
|
403,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
user = await this.adminSynchronization.synchronize(user.id, claims);
|
||||||
|
|
||||||
const result = await this.sessions.createSession(
|
const result = await this.sessions.createSession(
|
||||||
user,
|
user,
|
||||||
@@ -129,8 +133,7 @@ export class AuthService {
|
|||||||
return this.dataSource.transaction(async (manager) => {
|
return this.dataSource.transaction(async (manager) => {
|
||||||
await manager.query("SELECT GET_LOCK('business_app_first_admin', 10)");
|
await manager.query("SELECT GET_LOCK('business_app_first_admin', 10)");
|
||||||
try {
|
try {
|
||||||
const { admin, user: userRole } =
|
const { user: userRole } = await this.roles.ensureSystemRoles(manager);
|
||||||
await this.roles.ensureSystemRoles(manager);
|
|
||||||
let user = await manager.getRepository(UserEntity).findOne({
|
let user = await manager.getRepository(UserEntity).findOne({
|
||||||
where: { issuer, subject: profile.sub },
|
where: { issuer, subject: profile.sub },
|
||||||
relations: { roles: true, settings: true },
|
relations: { roles: true, settings: true },
|
||||||
@@ -141,10 +144,6 @@ export class AuthService {
|
|||||||
user.subject = profile.sub;
|
user.subject = profile.sub;
|
||||||
user.active = true;
|
user.active = true;
|
||||||
user.roles = [userRole];
|
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.name = profile.name ?? profile.email ?? profile.sub;
|
||||||
user.email = profile.email ?? null;
|
user.email = profile.email ?? null;
|
||||||
@@ -154,6 +153,12 @@ export class AuthService {
|
|||||||
: null;
|
: null;
|
||||||
user.lastLoginAt = new Date();
|
user.lastLoginAt = new Date();
|
||||||
const savedUser = await manager.getRepository(UserEntity).save(user);
|
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);
|
savedUser.settings = await this.ensureUserSettings(manager, savedUser);
|
||||||
return savedUser;
|
return savedUser;
|
||||||
} finally {
|
} finally {
|
||||||
@@ -266,7 +271,7 @@ export class AuthService {
|
|||||||
discovery: OidcDiscovery,
|
discovery: OidcDiscovery,
|
||||||
tokens: OidcTokenResponse,
|
tokens: OidcTokenResponse,
|
||||||
nonce: string,
|
nonce: string,
|
||||||
): Promise<OidcUserInfo> {
|
): Promise<{ profile: OidcUserInfo; claims: Record<string, unknown> }> {
|
||||||
const { createRemoteJWKSet, decodeProtectedHeader, jwtVerify } =
|
const { createRemoteJWKSet, decodeProtectedHeader, jwtVerify } =
|
||||||
await import('jose');
|
await import('jose');
|
||||||
const protectedHeader = decodeProtectedHeader(tokens.id_token);
|
const protectedHeader = decodeProtectedHeader(tokens.id_token);
|
||||||
@@ -305,7 +310,7 @@ export class AuthService {
|
|||||||
if (typeof payload['email_verified'] === 'boolean') {
|
if (typeof payload['email_verified'] === 'boolean') {
|
||||||
fallback.email_verified = payload['email_verified'];
|
fallback.email_verified = payload['email_verified'];
|
||||||
}
|
}
|
||||||
return fallback;
|
return { profile: fallback, claims: { ...payload } };
|
||||||
}
|
}
|
||||||
const userInfo = await this.http.requestJson<OidcUserInfo>(
|
const userInfo = await this.http.requestJson<OidcUserInfo>(
|
||||||
discovery.userinfo_endpoint,
|
discovery.userinfo_endpoint,
|
||||||
@@ -335,7 +340,7 @@ export class AuthService {
|
|||||||
) {
|
) {
|
||||||
userInfo.email_verified = payload['email_verified'];
|
userInfo.email_verified = payload['email_verified'];
|
||||||
}
|
}
|
||||||
return userInfo;
|
return { profile: userInfo, claims: { ...payload, ...userInfo } };
|
||||||
}
|
}
|
||||||
|
|
||||||
private isAllowedOidcAlgorithm(algorithm: string | undefined): boolean {
|
private isAllowedOidcAlgorithm(algorithm: string | undefined): boolean {
|
||||||
|
|||||||
140
apps/backend/src/auth/oidc-admin-synchronization.service.spec.ts
Normal file
140
apps/backend/src/auth/oidc-admin-synchronization.service.spec.ts
Normal file
@@ -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<void>) =>
|
||||||
|
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],
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
139
apps/backend/src/auth/oidc-admin-synchronization.service.ts
Normal file
139
apps/backend/src/auth/oidc-admin-synchronization.service.ts
Normal 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
49
apps/backend/src/auth/oidc-role-extractor.service.spec.ts
Normal file
49
apps/backend/src/auth/oidc-role-extractor.service.spec.ts
Normal file
@@ -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',
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
44
apps/backend/src/auth/oidc-role-extractor.service.ts
Normal file
44
apps/backend/src/auth/oidc-role-extractor.service.ts
Normal file
@@ -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<Record<string, unknown>>,
|
||||||
|
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<string, unknown>)[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,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -101,4 +101,30 @@ describe('loadConfigFromEnv', () => {
|
|||||||
}),
|
}),
|
||||||
).toThrow(/OIDC_ALLOWED_ALGORITHMS/);
|
).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/);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -26,6 +26,9 @@ export interface AppConfig {
|
|||||||
scopes: string;
|
scopes: string;
|
||||||
allowedAlgorithms: string[];
|
allowedAlgorithms: string[];
|
||||||
httpTimeoutMs: number;
|
httpTimeoutMs: number;
|
||||||
|
adminRole?: string;
|
||||||
|
rolesClaim?: string;
|
||||||
|
roleMatchCaseSensitive: boolean;
|
||||||
logoutUrl?: string;
|
logoutUrl?: string;
|
||||||
};
|
};
|
||||||
session: {
|
session: {
|
||||||
|
|||||||
@@ -10,85 +10,129 @@ const booleanFromString = z
|
|||||||
.pipe(z.enum(['true', 'false']))
|
.pipe(z.enum(['true', 'false']))
|
||||||
.transform((value) => value === 'true');
|
.transform((value) => value === 'true');
|
||||||
|
|
||||||
const envSchema = z.object({
|
const optionalTrimmedString = (maxLength: number) =>
|
||||||
NODE_ENV: z
|
z.preprocess(
|
||||||
.enum(['development', 'test', 'production'])
|
(value) =>
|
||||||
.default('development'),
|
typeof value === 'string' && value.trim() === '' ? undefined : value,
|
||||||
PORT: z.coerce.number().int().min(1).max(65535).default(3000),
|
z
|
||||||
APP_BASE_URL: z.url(),
|
.string()
|
||||||
FRONTEND_BASE_URL: z.url().optional(),
|
.trim()
|
||||||
TRUST_PROXY: booleanFromString.default(false),
|
.max(maxLength)
|
||||||
DATABASE_HOST: z.string().min(1),
|
.refine((value) =>
|
||||||
DATABASE_PORT: z.coerce.number().int().min(1).max(65535).default(3306),
|
Array.from(value).every((character) => {
|
||||||
DATABASE_NAME: z.string().min(1),
|
const code = character.charCodeAt(0);
|
||||||
DATABASE_USER: z.string().min(1),
|
return code > 31 && code !== 127;
|
||||||
DATABASE_PASSWORD: z.string().min(1),
|
}),
|
||||||
DATABASE_SSL: booleanFromString.default(false),
|
)
|
||||||
OIDC_ISSUER: z.url(),
|
.optional(),
|
||||||
OIDC_CLIENT_ID: z.string().min(1),
|
);
|
||||||
OIDC_CLIENT_SECRET: z.string().min(1),
|
|
||||||
OIDC_SCOPES: z.string().min(1).default('openid profile email'),
|
const envSchema = z
|
||||||
OIDC_LOGOUT_URL: z.preprocess(
|
.object({
|
||||||
(value) => (value === '' ? undefined : value),
|
NODE_ENV: z
|
||||||
z.url().optional(),
|
.enum(['development', 'test', 'production'])
|
||||||
),
|
.default('development'),
|
||||||
OIDC_ALLOWED_ALGORITHMS: z
|
PORT: z.coerce.number().int().min(1).max(65535).default(3000),
|
||||||
.string()
|
APP_BASE_URL: z.url(),
|
||||||
.min(1)
|
FRONTEND_BASE_URL: z.url().optional(),
|
||||||
.transform((value) =>
|
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
|
value
|
||||||
.split(/[\s,]+/)
|
.split(',')
|
||||||
.map((entry) => entry.trim())
|
.map((entry) => entry.trim())
|
||||||
.filter(Boolean),
|
.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
|
CSRF_HEADER_NAME: z.string().min(1).default('X-CSRF-Token'),
|
||||||
.number()
|
LOG_LEVEL: z.string().min(1).default('info'),
|
||||||
.int()
|
SWAGGER_ENABLED: booleanFromString.default(false),
|
||||||
.min(1000)
|
DOCUMENT_STORAGE_PATH: z.string().min(1).default('storage/documents'),
|
||||||
.max(30000)
|
DOCUMENT_MAX_FILE_SIZE_BYTES: z.coerce
|
||||||
.default(5000),
|
.number()
|
||||||
SESSION_COOKIE_NAME: z.string().min(1).default('app_session'),
|
.int()
|
||||||
SESSION_IDLE_TIMEOUT_SECONDS: z.coerce.number().int().min(300).default(28800),
|
.min(1024)
|
||||||
SESSION_ABSOLUTE_TIMEOUT_SECONDS: z.coerce
|
.max(50 * 1024 * 1024)
|
||||||
.number()
|
.default(10 * 1024 * 1024),
|
||||||
.int()
|
REMINDER_INTERVAL_MS: z.coerce.number().int().min(60000).default(900000),
|
||||||
.min(3600)
|
REMINDER_DUE_SOON_DAYS: z.coerce.number().int().min(1).max(30).default(3),
|
||||||
.default(604800),
|
RATE_LIMIT_WINDOW_SECONDS: z.coerce.number().int().min(1).default(60),
|
||||||
SESSION_SECRET: z.string().min(32),
|
RATE_LIMIT_MAX_REQUESTS: z.coerce.number().int().min(1).default(300),
|
||||||
SESSION_ENCRYPTION_KEY: z.string().min(32),
|
RATE_LIMIT_SENSITIVE_WINDOW_SECONDS: z.coerce
|
||||||
CORS_ORIGINS: z.string().transform((value) =>
|
.number()
|
||||||
value
|
.int()
|
||||||
.split(',')
|
.min(1)
|
||||||
.map((entry) => entry.trim())
|
.default(60),
|
||||||
.filter(Boolean),
|
RATE_LIMIT_SENSITIVE_MAX_REQUESTS: z.coerce
|
||||||
),
|
.number()
|
||||||
CSRF_HEADER_NAME: z.string().min(1).default('X-CSRF-Token'),
|
.int()
|
||||||
LOG_LEVEL: z.string().min(1).default('info'),
|
.min(1)
|
||||||
SWAGGER_ENABLED: booleanFromString.default(false),
|
.default(10),
|
||||||
DOCUMENT_STORAGE_PATH: z.string().min(1).default('storage/documents'),
|
})
|
||||||
DOCUMENT_MAX_FILE_SIZE_BYTES: z.coerce
|
.superRefine((value, context) => {
|
||||||
.number()
|
if (value.OIDC_ADMIN_ROLE && !value.OIDC_ROLES_CLAIM) {
|
||||||
.int()
|
context.addIssue({
|
||||||
.min(1024)
|
code: 'custom',
|
||||||
.max(50 * 1024 * 1024)
|
path: ['OIDC_ROLES_CLAIM'],
|
||||||
.default(10 * 1024 * 1024),
|
message:
|
||||||
REMINDER_INTERVAL_MS: z.coerce.number().int().min(60000).default(900000),
|
'OIDC_ROLES_CLAIM ist erforderlich, wenn OIDC_ADMIN_ROLE gesetzt ist.',
|
||||||
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),
|
|
||||||
});
|
|
||||||
|
|
||||||
export function loadConfigFromEnv(env: Record<string, unknown>): AppConfig {
|
export function loadConfigFromEnv(env: Record<string, unknown>): AppConfig {
|
||||||
const parsed = envSchema.safeParse(env);
|
const parsed = envSchema.safeParse(env);
|
||||||
@@ -134,6 +178,9 @@ export function loadConfigFromEnv(env: Record<string, unknown>): AppConfig {
|
|||||||
scopes: value.OIDC_SCOPES,
|
scopes: value.OIDC_SCOPES,
|
||||||
allowedAlgorithms: value.OIDC_ALLOWED_ALGORITHMS,
|
allowedAlgorithms: value.OIDC_ALLOWED_ALGORITHMS,
|
||||||
httpTimeoutMs: value.OIDC_HTTP_TIMEOUT_MS,
|
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 } : {}),
|
...(value.OIDC_LOGOUT_URL ? { logoutUrl: value.OIDC_LOGOUT_URL } : {}),
|
||||||
},
|
},
|
||||||
session: {
|
session: {
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ import { PermissionEntity } from '../roles/entities/permission.entity';
|
|||||||
import { SessionEntity } from '../sessions/entities/session.entity';
|
import { SessionEntity } from '../sessions/entities/session.entity';
|
||||||
import { UserSettingsEntity } from '../users/entities/user-settings.entity';
|
import { UserSettingsEntity } from '../users/entities/user-settings.entity';
|
||||||
import { UserEntity } from '../users/entities/user.entity';
|
import { UserEntity } from '../users/entities/user.entity';
|
||||||
|
import { UserRoleAssignmentEntity } from '../users/entities/user-role-assignment.entity';
|
||||||
import {
|
import {
|
||||||
BudgetCategoryEntity,
|
BudgetCategoryEntity,
|
||||||
BuildingEntity,
|
BuildingEntity,
|
||||||
@@ -49,6 +50,7 @@ export const entities = [
|
|||||||
SessionEntity,
|
SessionEntity,
|
||||||
UserSettingsEntity,
|
UserSettingsEntity,
|
||||||
UserEntity,
|
UserEntity,
|
||||||
|
UserRoleAssignmentEntity,
|
||||||
BuildingEntity,
|
BuildingEntity,
|
||||||
FloorEntity,
|
FloorEntity,
|
||||||
RoomEntity,
|
RoomEntity,
|
||||||
|
|||||||
@@ -0,0 +1,46 @@
|
|||||||
|
import type { MigrationInterface, QueryRunner } from 'typeorm';
|
||||||
|
|
||||||
|
export class AddRoleAssignmentSources1720000008000
|
||||||
|
implements MigrationInterface
|
||||||
|
{
|
||||||
|
name = 'AddRoleAssignmentSources1720000008000';
|
||||||
|
|
||||||
|
async up(queryRunner: QueryRunner): Promise<void> {
|
||||||
|
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<void> {
|
||||||
|
await queryRunner.query('DROP TABLE user_role_assignments');
|
||||||
|
await queryRunner.query(
|
||||||
|
'ALTER TABLE roles DROP INDEX uq_roles_role_key, DROP COLUMN role_key',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -10,6 +10,7 @@ import { AddRenovationDomain1720000004000 } from './migrations/1720000004000-Add
|
|||||||
import { AddMentionsAndReminders1720000005000 } from './migrations/1720000005000-AddMentionsAndReminders';
|
import { AddMentionsAndReminders1720000005000 } from './migrations/1720000005000-AddMentionsAndReminders';
|
||||||
import { AddDefaultProjectFloors1720000006000 } from './migrations/1720000006000-AddDefaultProjectFloors';
|
import { AddDefaultProjectFloors1720000006000 } from './migrations/1720000006000-AddDefaultProjectFloors';
|
||||||
import { AddFurniturePlanning1720000007000 } from './migrations/1720000007000-AddFurniturePlanning';
|
import { AddFurniturePlanning1720000007000 } from './migrations/1720000007000-AddFurniturePlanning';
|
||||||
|
import { AddRoleAssignmentSources1720000008000 } from './migrations/1720000008000-AddRoleAssignmentSources';
|
||||||
|
|
||||||
const config = loadConfigForCli();
|
const config = loadConfigForCli();
|
||||||
|
|
||||||
@@ -35,5 +36,6 @@ export default new DataSource({
|
|||||||
AddMentionsAndReminders1720000005000,
|
AddMentionsAndReminders1720000005000,
|
||||||
AddDefaultProjectFloors1720000006000,
|
AddDefaultProjectFloors1720000006000,
|
||||||
AddFurniturePlanning1720000007000,
|
AddFurniturePlanning1720000007000,
|
||||||
|
AddRoleAssignmentSources1720000008000,
|
||||||
],
|
],
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ import { AddRenovationDomain1720000004000 } from './migrations/1720000004000-Add
|
|||||||
import { AddMentionsAndReminders1720000005000 } from './migrations/1720000005000-AddMentionsAndReminders';
|
import { AddMentionsAndReminders1720000005000 } from './migrations/1720000005000-AddMentionsAndReminders';
|
||||||
import { AddDefaultProjectFloors1720000006000 } from './migrations/1720000006000-AddDefaultProjectFloors';
|
import { AddDefaultProjectFloors1720000006000 } from './migrations/1720000006000-AddDefaultProjectFloors';
|
||||||
import { AddFurniturePlanning1720000007000 } from './migrations/1720000007000-AddFurniturePlanning';
|
import { AddFurniturePlanning1720000007000 } from './migrations/1720000007000-AddFurniturePlanning';
|
||||||
|
import { AddRoleAssignmentSources1720000008000 } from './migrations/1720000008000-AddRoleAssignmentSources';
|
||||||
|
|
||||||
export function typeOrmOptionsFactory(
|
export function typeOrmOptionsFactory(
|
||||||
config: AppConfigService,
|
config: AppConfigService,
|
||||||
@@ -35,6 +36,7 @@ export function typeOrmOptionsFactory(
|
|||||||
AddMentionsAndReminders1720000005000,
|
AddMentionsAndReminders1720000005000,
|
||||||
AddDefaultProjectFloors1720000006000,
|
AddDefaultProjectFloors1720000006000,
|
||||||
AddFurniturePlanning1720000007000,
|
AddFurniturePlanning1720000007000,
|
||||||
|
AddRoleAssignmentSources1720000008000,
|
||||||
],
|
],
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import cookieParser from 'cookie-parser';
|
|||||||
import type { NextFunction, Request, Response } from 'express';
|
import type { NextFunction, Request, Response } from 'express';
|
||||||
import helmet from 'helmet';
|
import helmet from 'helmet';
|
||||||
import pinoHttp from 'pino-http';
|
import pinoHttp from 'pino-http';
|
||||||
|
import { Logger } from '@nestjs/common';
|
||||||
import { NestFactory } from '@nestjs/core';
|
import { NestFactory } from '@nestjs/core';
|
||||||
import { DocumentBuilder, SwaggerModule } from '@nestjs/swagger';
|
import { DocumentBuilder, SwaggerModule } from '@nestjs/swagger';
|
||||||
import type { NestExpressApplication } from '@nestjs/platform-express';
|
import type { NestExpressApplication } from '@nestjs/platform-express';
|
||||||
@@ -16,6 +17,11 @@ async function bootstrap() {
|
|||||||
bufferLogs: true,
|
bufferLogs: true,
|
||||||
});
|
});
|
||||||
const config = app.get(AppConfigService);
|
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(
|
app.use(
|
||||||
pinoHttp({
|
pinoHttp({
|
||||||
|
|||||||
@@ -65,11 +65,20 @@ describe('Notification integrations', () => {
|
|||||||
active: true,
|
active: true,
|
||||||
roles: [{ id: 'role-user', name: 'user' }],
|
roles: [{ id: 'role-user', name: 'user' }],
|
||||||
};
|
};
|
||||||
|
let userLoad = 0;
|
||||||
const users: Pick<UsersRepository, 'findByIdWithRoles' | 'save'> = {
|
const users: Pick<UsersRepository, 'findByIdWithRoles' | 'save'> = {
|
||||||
findByIdWithRoles: () =>
|
findByIdWithRoles: () => {
|
||||||
Promise.resolve(
|
const loaded =
|
||||||
user as Awaited<ReturnType<UsersRepository['findByIdWithRoles']>>,
|
userLoad++ === 0
|
||||||
),
|
? user
|
||||||
|
: {
|
||||||
|
...user,
|
||||||
|
roles: [...user.roles, { id: 'role-editor', name: 'Editor' }],
|
||||||
|
};
|
||||||
|
return Promise.resolve(
|
||||||
|
loaded as Awaited<ReturnType<UsersRepository['findByIdWithRoles']>>,
|
||||||
|
);
|
||||||
|
},
|
||||||
save: (entry) => Promise.resolve(entry),
|
save: (entry) => Promise.resolve(entry),
|
||||||
};
|
};
|
||||||
const roles: Pick<RolesService, 'getRole'> = {
|
const roles: Pick<RolesService, 'getRole'> = {
|
||||||
@@ -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 = {
|
const manager = {
|
||||||
query: () => Promise.resolve(),
|
query: () => Promise.resolve(),
|
||||||
|
getRepository: () => assignmentRepository,
|
||||||
} as unknown as EntityManager;
|
} as unknown as EntityManager;
|
||||||
const dataSource = {
|
const dataSource = {
|
||||||
transaction: <T>(action: (manager: EntityManager) => Promise<T>) =>
|
transaction: <T>(action: (manager: EntityManager) => Promise<T>) =>
|
||||||
|
|||||||
@@ -17,6 +17,9 @@ export class RoleEntity {
|
|||||||
@PrimaryGeneratedColumn('uuid')
|
@PrimaryGeneratedColumn('uuid')
|
||||||
id!: string;
|
id!: string;
|
||||||
|
|
||||||
|
@Column({ name: 'role_key', type: 'varchar', length: 40, nullable: true })
|
||||||
|
roleKey!: string | null;
|
||||||
|
|
||||||
@Column({ type: 'varchar', length: 80 })
|
@Column({ type: 'varchar', length: 80 })
|
||||||
name!: string;
|
name!: string;
|
||||||
|
|
||||||
|
|||||||
@@ -11,6 +11,8 @@ import { RoleEntity } from './entities/role.entity';
|
|||||||
import { allPermissions, Permission } from './permissions';
|
import { allPermissions, Permission } from './permissions';
|
||||||
import type { CreateRoleDto, UpdateRoleDto } from './dto/role.dto';
|
import type { CreateRoleDto, UpdateRoleDto } from './dto/role.dto';
|
||||||
import { RolesRepository } from './repositories/roles.repository';
|
import { RolesRepository } from './repositories/roles.repository';
|
||||||
|
import { AppConfigService } from '../config/config.service';
|
||||||
|
import { adminRoleKey, userRoleKey } from './system-role-keys';
|
||||||
|
|
||||||
const adminRoleName = 'admin';
|
const adminRoleName = 'admin';
|
||||||
const userRoleName = 'user';
|
const userRoleName = 'user';
|
||||||
@@ -20,6 +22,7 @@ export class RolesService {
|
|||||||
constructor(
|
constructor(
|
||||||
private readonly roles: RolesRepository,
|
private readonly roles: RolesRepository,
|
||||||
private readonly audit: AuditService,
|
private readonly audit: AuditService,
|
||||||
|
private readonly config: AppConfigService,
|
||||||
@InjectDataSource() private readonly dataSource: DataSource,
|
@InjectDataSource() private readonly dataSource: DataSource,
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
@@ -78,12 +81,12 @@ export class RolesService {
|
|||||||
await this.assertRoleNameAvailable(normalizedName, id, manager);
|
await this.assertRoleNameAvailable(normalizedName, id, manager);
|
||||||
role.name = normalizedName;
|
role.name = normalizedName;
|
||||||
}
|
}
|
||||||
if (role.name === adminRoleName) {
|
if (role.roleKey === adminRoleKey) {
|
||||||
this.assertAdminPermissions(dto.permissions);
|
this.assertAdminPermissions(dto.permissions);
|
||||||
}
|
}
|
||||||
role.description = dto.description?.trim() ?? '';
|
role.description = dto.description?.trim() ?? '';
|
||||||
role.permissions = await this.loadPermissionEntities(
|
role.permissions = await this.loadPermissionEntities(
|
||||||
role.name === adminRoleName ? allPermissions : dto.permissions,
|
role.roleKey === adminRoleKey ? allPermissions : dto.permissions,
|
||||||
manager,
|
manager,
|
||||||
);
|
);
|
||||||
const saved = await this.roles.save(role, manager);
|
const saved = await this.roles.save(role, manager);
|
||||||
@@ -178,12 +181,14 @@ export class RolesService {
|
|||||||
await this.syncPermissions(manager);
|
await this.syncPermissions(manager);
|
||||||
const admin = await this.ensureRole(
|
const admin = await this.ensureRole(
|
||||||
adminRoleName,
|
adminRoleName,
|
||||||
|
adminRoleKey,
|
||||||
allPermissions,
|
allPermissions,
|
||||||
true,
|
true,
|
||||||
manager,
|
manager,
|
||||||
);
|
);
|
||||||
const user = await this.ensureRole(
|
const user = await this.ensureRole(
|
||||||
userRoleName,
|
userRoleName,
|
||||||
|
userRoleKey,
|
||||||
[
|
[
|
||||||
Permission.ItemsRead,
|
Permission.ItemsRead,
|
||||||
Permission.SessionsReadOwn,
|
Permission.SessionsReadOwn,
|
||||||
@@ -223,6 +228,7 @@ export class RolesService {
|
|||||||
|
|
||||||
private async ensureRole(
|
private async ensureRole(
|
||||||
name: string,
|
name: string,
|
||||||
|
roleKey: string,
|
||||||
permissions: Permission[],
|
permissions: Permission[],
|
||||||
protectedRole: boolean,
|
protectedRole: boolean,
|
||||||
manager?: EntityManager,
|
manager?: EntityManager,
|
||||||
@@ -237,6 +243,7 @@ export class RolesService {
|
|||||||
role.name = name;
|
role.name = name;
|
||||||
role.protected = protectedRole;
|
role.protected = protectedRole;
|
||||||
}
|
}
|
||||||
|
role.roleKey = roleKey;
|
||||||
role.permissions = await this.loadPermissionEntities(permissions, manager);
|
role.permissions = await this.loadPermissionEntities(permissions, manager);
|
||||||
role.protected = protectedRole;
|
role.protected = protectedRole;
|
||||||
return repo.save(role);
|
return repo.save(role);
|
||||||
@@ -303,6 +310,13 @@ export class RolesService {
|
|||||||
description: role.description,
|
description: role.description,
|
||||||
system: role.protected,
|
system: role.protected,
|
||||||
protected: 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,
|
permissions: role.permissions,
|
||||||
userCount: role.users?.length ?? 0,
|
userCount: role.users?.length ?? 0,
|
||||||
users: role.users,
|
users: role.users,
|
||||||
|
|||||||
2
apps/backend/src/roles/system-role-keys.ts
Normal file
2
apps/backend/src/roles/system-role-keys.ts
Normal file
@@ -0,0 +1,2 @@
|
|||||||
|
export const adminRoleKey = 'ADMIN';
|
||||||
|
export const userRoleKey = 'USER';
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -6,6 +6,7 @@ import type {
|
|||||||
AdminUserSortField,
|
AdminUserSortField,
|
||||||
} from '../dto/user.dto';
|
} from '../dto/user.dto';
|
||||||
import { UserEntity } from '../entities/user.entity';
|
import { UserEntity } from '../entities/user.entity';
|
||||||
|
import { adminRoleKey } from '../../roles/system-role-keys';
|
||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
export class UsersRepository {
|
export class UsersRepository {
|
||||||
@@ -115,7 +116,7 @@ export class UsersRepository {
|
|||||||
.createQueryBuilder('user')
|
.createQueryBuilder('user')
|
||||||
.innerJoin('user.roles', 'role')
|
.innerJoin('user.roles', 'role')
|
||||||
.where('user.active = :active', { active: true })
|
.where('user.active = :active', { active: true })
|
||||||
.andWhere('role.name = :role', { role: 'admin' })
|
.andWhere('role.roleKey = :roleKey', { roleKey: adminRoleKey })
|
||||||
.orderBy('user.createdAt', 'ASC');
|
.orderBy('user.createdAt', 'ASC');
|
||||||
if (excludedUserId) {
|
if (excludedUserId) {
|
||||||
qb.andWhere('user.id <> :excludedUserId', { excludedUserId });
|
qb.andWhere('user.id <> :excludedUserId', { excludedUserId });
|
||||||
|
|||||||
@@ -9,10 +9,15 @@ import { UserEntity } from './entities/user.entity';
|
|||||||
import { UsersRepository } from './repositories/users.repository';
|
import { UsersRepository } from './repositories/users.repository';
|
||||||
import { UsersController } from './users.controller';
|
import { UsersController } from './users.controller';
|
||||||
import { UsersService } from './users.service';
|
import { UsersService } from './users.service';
|
||||||
|
import { UserRoleAssignmentEntity } from './entities/user-role-assignment.entity';
|
||||||
|
|
||||||
@Module({
|
@Module({
|
||||||
imports: [
|
imports: [
|
||||||
TypeOrmModule.forFeature([UserEntity, UserSettingsEntity]),
|
TypeOrmModule.forFeature([
|
||||||
|
UserEntity,
|
||||||
|
UserSettingsEntity,
|
||||||
|
UserRoleAssignmentEntity,
|
||||||
|
]),
|
||||||
RolesModule,
|
RolesModule,
|
||||||
AuditModule,
|
AuditModule,
|
||||||
SessionsModule,
|
SessionsModule,
|
||||||
|
|||||||
@@ -9,11 +9,16 @@ import { ErrorCode } from '../common/errors/error-codes';
|
|||||||
import { NotificationType } from '../notifications/notification-types';
|
import { NotificationType } from '../notifications/notification-types';
|
||||||
import { NotificationsService } from '../notifications/notifications.service';
|
import { NotificationsService } from '../notifications/notifications.service';
|
||||||
import { RolesService } from '../roles/roles.service';
|
import { RolesService } from '../roles/roles.service';
|
||||||
|
import { adminRoleKey } from '../roles/system-role-keys';
|
||||||
import { RoleEntity } from '../roles/entities/role.entity';
|
import { RoleEntity } from '../roles/entities/role.entity';
|
||||||
import { SessionsService } from '../sessions/sessions.service';
|
import { SessionsService } from '../sessions/sessions.service';
|
||||||
import type { AdminSessionDto } from '../sessions/sessions.service';
|
import type { AdminSessionDto } from '../sessions/sessions.service';
|
||||||
import { UserSettingsEntity } from './entities/user-settings.entity';
|
import { UserSettingsEntity } from './entities/user-settings.entity';
|
||||||
import { UserEntity } from './entities/user.entity';
|
import { UserEntity } from './entities/user.entity';
|
||||||
|
import {
|
||||||
|
UserRoleAssignmentEntity,
|
||||||
|
UserRoleAssignmentSource,
|
||||||
|
} from './entities/user-role-assignment.entity';
|
||||||
import type { AdminUserListQueryDto } from './dto/user.dto';
|
import type { AdminUserListQueryDto } from './dto/user.dto';
|
||||||
import { UsersRepository } from './repositories/users.repository';
|
import { UsersRepository } from './repositories/users.repository';
|
||||||
|
|
||||||
@@ -22,6 +27,7 @@ interface AdminRoleSummary {
|
|||||||
name: string;
|
name: string;
|
||||||
description: string;
|
description: string;
|
||||||
system: boolean;
|
system: boolean;
|
||||||
|
sources: UserRoleAssignmentSource[];
|
||||||
}
|
}
|
||||||
|
|
||||||
interface AdminUserListItem {
|
interface AdminUserListItem {
|
||||||
@@ -63,7 +69,7 @@ export class UsersService {
|
|||||||
const [users, total] = await this.users.adminSearch(query);
|
const [users, total] = await this.users.adminSearch(query);
|
||||||
const items = await Promise.all(
|
const items = await Promise.all(
|
||||||
users.map(async (user) => ({
|
users.map(async (user) => ({
|
||||||
...this.toAdminListItem(user),
|
...this.toAdminListItem(user, await this.roleSources(user.id)),
|
||||||
activeSessionCount: await this.sessions.countActiveForUser(user.id),
|
activeSessionCount: await this.sessions.countActiveForUser(user.id),
|
||||||
})),
|
})),
|
||||||
);
|
);
|
||||||
@@ -76,7 +82,7 @@ export class UsersService {
|
|||||||
): Promise<AdminUserDetail> {
|
): Promise<AdminUserDetail> {
|
||||||
const user = await this.getWithRoles(id);
|
const user = await this.getWithRoles(id);
|
||||||
return {
|
return {
|
||||||
...this.toAdminListItem(user),
|
...this.toAdminListItem(user, await this.roleSources(user.id)),
|
||||||
activeSessionCount: await this.sessions.countActiveForUser(user.id),
|
activeSessionCount: await this.sessions.countActiveForUser(user.id),
|
||||||
effectivePermissions: this.effectivePermissions(user),
|
effectivePermissions: this.effectivePermissions(user),
|
||||||
sessions: await this.sessions.listForAdmin(user.id, currentSessionId),
|
sessions: await this.sessions.listForAdmin(user.id, currentSessionId),
|
||||||
@@ -120,7 +126,7 @@ export class UsersService {
|
|||||||
409,
|
409,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
if (!active && this.hasRole(user, 'admin')) {
|
if (!active && this.hasRole(user, adminRoleKey)) {
|
||||||
await this.assertAnotherActiveAdminRemains(userId, manager);
|
await this.assertAnotherActiveAdminRemains(userId, manager);
|
||||||
}
|
}
|
||||||
user.active = active;
|
user.active = active;
|
||||||
@@ -151,14 +157,8 @@ export class UsersService {
|
|||||||
userId: string,
|
userId: string,
|
||||||
roleId: string,
|
roleId: string,
|
||||||
): Promise<UserEntity> {
|
): Promise<UserEntity> {
|
||||||
const user = await this.getWithRoles(userId);
|
const roleIds = await this.manualRoleIds(userId);
|
||||||
if (user.roles.some((role) => role.id === roleId)) {
|
return this.replaceRoles(actor, userId, [...roleIds, roleId]);
|
||||||
return user;
|
|
||||||
}
|
|
||||||
return this.replaceRoles(actor, userId, [
|
|
||||||
...user.roles.map((role) => role.id),
|
|
||||||
roleId,
|
|
||||||
]);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async removeRole(
|
async removeRole(
|
||||||
@@ -166,14 +166,11 @@ export class UsersService {
|
|||||||
userId: string,
|
userId: string,
|
||||||
roleId: string,
|
roleId: string,
|
||||||
): Promise<UserEntity> {
|
): Promise<UserEntity> {
|
||||||
const user = await this.getWithRoles(userId);
|
const roleIds = await this.manualRoleIds(userId);
|
||||||
if (!user.roles.some((role) => role.id === roleId)) {
|
|
||||||
return user;
|
|
||||||
}
|
|
||||||
return this.replaceRoles(
|
return this.replaceRoles(
|
||||||
actor,
|
actor,
|
||||||
userId,
|
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')
|
.innerJoin('user.roles', 'role')
|
||||||
.where('user.active = :active', { active: true })
|
.where('user.active = :active', { active: true })
|
||||||
.andWhere('user.id <> :excludedUserId', { excludedUserId })
|
.andWhere('user.id <> :excludedUserId', { excludedUserId })
|
||||||
.andWhere('role.name = :role', { role: 'admin' })
|
.andWhere('role.roleKey = :roleKey', { roleKey: adminRoleKey })
|
||||||
.getCount();
|
.getCount();
|
||||||
if (result < 1) {
|
if (result < 1) {
|
||||||
throw new ApiError(
|
throw new ApiError(
|
||||||
@@ -254,16 +251,58 @@ export class UsersService {
|
|||||||
return this.withAdminLock(async (manager) => {
|
return this.withAdminLock(async (manager) => {
|
||||||
const user = await this.getWithRoles(userId, manager);
|
const user = await this.getWithRoles(userId, manager);
|
||||||
const previousRoleNames = new Set(user.roles.map((role) => role.name));
|
const previousRoleNames = new Set(user.roles.map((role) => role.name));
|
||||||
const previousRoleIds = new Set(user.roles.map((role) => role.id));
|
const previouslyAdmin = user.roles.some(
|
||||||
const roles = await Promise.all(
|
(role) => role.roleKey === adminRoleKey,
|
||||||
roleIds.map((id) => this.roles.getRole(id, manager)),
|
|
||||||
);
|
);
|
||||||
const nextRoleNames = new Set(roles.map((role) => role.name));
|
const assignmentRepo = manager.getRepository(UserRoleAssignmentEntity);
|
||||||
user.roles = roles;
|
const previousAssignments = await assignmentRepo.findBy({
|
||||||
if (previousRoleNames.has('admin') && !nextRoleNames.has('admin')) {
|
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);
|
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.recordRoleAudit(actor, userId, previousRoleIds, roles);
|
||||||
await this.notifyRoleChanges(userId, previousRoleNames, nextRoleNames);
|
await this.notifyRoleChanges(userId, previousRoleNames, nextRoleNames);
|
||||||
return saved;
|
return saved;
|
||||||
@@ -333,7 +372,10 @@ export class UsersService {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
private toAdminListItem(user: UserEntity): AdminUserListItem {
|
private toAdminListItem(
|
||||||
|
user: UserEntity,
|
||||||
|
sources: Map<string, UserRoleAssignmentSource[]>,
|
||||||
|
): AdminUserListItem {
|
||||||
return {
|
return {
|
||||||
id: user.id,
|
id: user.id,
|
||||||
name: user.name,
|
name: user.name,
|
||||||
@@ -344,6 +386,7 @@ export class UsersService {
|
|||||||
name: role.name,
|
name: role.name,
|
||||||
description: role.description,
|
description: role.description,
|
||||||
system: role.protected,
|
system: role.protected,
|
||||||
|
sources: sources.get(role.id) ?? [],
|
||||||
})),
|
})),
|
||||||
lastLoginAt: user.lastLoginAt?.toISOString() ?? null,
|
lastLoginAt: user.lastLoginAt?.toISOString() ?? null,
|
||||||
createdAt: user.createdAt.toISOString(),
|
createdAt: user.createdAt.toISOString(),
|
||||||
@@ -351,6 +394,34 @@ export class UsersService {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private async manualRoleIds(userId: string): Promise<string[]> {
|
||||||
|
const assignments = await this.dataSource
|
||||||
|
.getRepository(UserRoleAssignmentEntity)
|
||||||
|
.findBy({
|
||||||
|
userId,
|
||||||
|
source: UserRoleAssignmentSource.Manual,
|
||||||
|
});
|
||||||
|
return assignments.map((entry) => entry.roleId);
|
||||||
|
}
|
||||||
|
|
||||||
|
private async roleSources(
|
||||||
|
userId: string,
|
||||||
|
): Promise<Map<string, UserRoleAssignmentSource[]>> {
|
||||||
|
const assignments = await this.dataSource
|
||||||
|
.getRepository(UserRoleAssignmentEntity)
|
||||||
|
.findBy({
|
||||||
|
userId,
|
||||||
|
});
|
||||||
|
const result = new Map<string, UserRoleAssignmentSource[]>();
|
||||||
|
for (const assignment of assignments) {
|
||||||
|
result.set(assignment.roleId, [
|
||||||
|
...(result.get(assignment.roleId) ?? []),
|
||||||
|
assignment.source,
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
private effectivePermissions(user: UserEntity): string[] {
|
private effectivePermissions(user: UserEntity): string[] {
|
||||||
return Array.from(
|
return Array.from(
|
||||||
new Set(
|
new Set(
|
||||||
@@ -361,8 +432,8 @@ export class UsersService {
|
|||||||
).sort();
|
).sort();
|
||||||
}
|
}
|
||||||
|
|
||||||
private hasRole(user: UserEntity, roleName: string): boolean {
|
private hasRole(user: UserEntity, roleKey: string): boolean {
|
||||||
return user.roles.some((role) => role.name === roleName);
|
return user.roles.some((role) => role.roleKey === roleKey);
|
||||||
}
|
}
|
||||||
|
|
||||||
private async notifyRoleChanges(
|
private async notifyRoleChanges(
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import { ActivatedRoute, Router } from '@angular/router';
|
|||||||
import { ApiClientService } from '@boilerplate/api-client';
|
import { ApiClientService } from '@boilerplate/api-client';
|
||||||
import { AdminRoleDetailPageComponent } from './admin-role-detail.page';
|
import { AdminRoleDetailPageComponent } from './admin-role-detail.page';
|
||||||
import { adminPermissionGroups } from './admin-permissions';
|
import { adminPermissionGroups } from './admin-permissions';
|
||||||
|
import { of } from 'rxjs';
|
||||||
|
|
||||||
describe('AdminRoleDetailPageComponent', () => {
|
describe('AdminRoleDetailPageComponent', () => {
|
||||||
it('groups permissions for the role editor', async () => {
|
it('groups permissions for the role editor', async () => {
|
||||||
@@ -31,4 +32,41 @@ describe('AdminRoleDetailPageComponent', () => {
|
|||||||
expect(element.textContent).toContain('Benutzer aktivieren');
|
expect(element.textContent).toContain('Benutzer aktivieren');
|
||||||
expect(element.textContent).toContain('notifications.manage');
|
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');
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -31,6 +31,19 @@ import { adminPermissionGroups } from './admin-permissions';
|
|||||||
@if (role()?.system) {
|
@if (role()?.system) {
|
||||||
<span class="system">Systemrolle</span>
|
<span class="system">Systemrolle</span>
|
||||||
}
|
}
|
||||||
|
@if (role()?.roleKey === 'ADMIN'; as isAdminRole) {
|
||||||
|
<aside class="oidc-notice">
|
||||||
|
<strong>Automatische OIDC-Verwaltung</strong>
|
||||||
|
@if (role()?.oidcManaged) {
|
||||||
|
<span
|
||||||
|
>OIDC-Rolle: <code>{{ role()?.oidcRoleName }}</code></span
|
||||||
|
>
|
||||||
|
<small>Quelle: Umgebungsvariable OIDC_ADMIN_ROLE</small>
|
||||||
|
} @else {
|
||||||
|
<span>Die automatische Administrator-Synchronisierung ist nicht konfiguriert.</span>
|
||||||
|
}
|
||||||
|
</aside>
|
||||||
|
}
|
||||||
<label>
|
<label>
|
||||||
Name
|
Name
|
||||||
<input
|
<input
|
||||||
@@ -168,6 +181,14 @@ import { adminPermissionGroups } from './admin-permissions';
|
|||||||
padding: 4px 8px;
|
padding: 4px 8px;
|
||||||
color: var(--color-text-secondary);
|
color: var(--color-text-secondary);
|
||||||
}
|
}
|
||||||
|
.oidc-notice {
|
||||||
|
display: grid;
|
||||||
|
gap: 6px;
|
||||||
|
padding: 12px;
|
||||||
|
border: 1px solid var(--color-border);
|
||||||
|
border-radius: 8px;
|
||||||
|
background: var(--color-background);
|
||||||
|
}
|
||||||
@media (min-width: 900px) {
|
@media (min-width: 900px) {
|
||||||
.groups {
|
.groups {
|
||||||
grid-template-columns: 1fr 1fr;
|
grid-template-columns: 1fr 1fr;
|
||||||
|
|||||||
@@ -0,0 +1,50 @@
|
|||||||
|
import { TestBed } from '@angular/core/testing';
|
||||||
|
import { ActivatedRoute } from '@angular/router';
|
||||||
|
import { ApiClientService, type AdminUserDetailDto, type RoleDto } from '@boilerplate/api-client';
|
||||||
|
import { of } from 'rxjs';
|
||||||
|
import { AdminUserDetailPageComponent } from './admin-user-detail.page';
|
||||||
|
|
||||||
|
describe('AdminUserDetailPageComponent', () => {
|
||||||
|
it('shows OIDC role provenance and does not offer local removal without a manual source', async () => {
|
||||||
|
const adminRole = {
|
||||||
|
id: 'admin-role',
|
||||||
|
name: 'admin',
|
||||||
|
description: 'Administrator',
|
||||||
|
system: true,
|
||||||
|
protected: true,
|
||||||
|
roleKey: 'ADMIN',
|
||||||
|
oidcManaged: true,
|
||||||
|
oidcRoleName: 'hauspilot-admin',
|
||||||
|
permissions: [],
|
||||||
|
} satisfies RoleDto;
|
||||||
|
const user = {
|
||||||
|
id: 'user-1',
|
||||||
|
name: 'Bastian',
|
||||||
|
email: 'admin@example.test',
|
||||||
|
active: true,
|
||||||
|
roles: [{ id: adminRole.id, name: 'admin', system: true, sources: ['OIDC'] }],
|
||||||
|
lastLoginAt: '2026-07-20',
|
||||||
|
createdAt: '2026-07-20',
|
||||||
|
activeSessionCount: 0,
|
||||||
|
effectivePermissions: [],
|
||||||
|
sessions: [],
|
||||||
|
} satisfies AdminUserDetailDto;
|
||||||
|
await TestBed.configureTestingModule({
|
||||||
|
imports: [AdminUserDetailPageComponent],
|
||||||
|
providers: [
|
||||||
|
{ provide: ActivatedRoute, useValue: { snapshot: { paramMap: { get: () => user.id } } } },
|
||||||
|
{
|
||||||
|
provide: ApiClientService,
|
||||||
|
useValue: { adminRoles: () => of([adminRole]), adminUser: () => of(user) },
|
||||||
|
},
|
||||||
|
],
|
||||||
|
}).compileComponents();
|
||||||
|
const fixture = TestBed.createComponent(AdminUserDetailPageComponent);
|
||||||
|
fixture.detectChanges();
|
||||||
|
const host = fixture.nativeElement as HTMLElement;
|
||||||
|
|
||||||
|
expect(host.textContent).toContain('Herkunft: OIDC');
|
||||||
|
expect(host.textContent).toContain('Entfernen Sie die externe Rolle im Identity Provider');
|
||||||
|
expect(host.textContent).not.toContain('Manuelle Zuweisung entfernen');
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -50,11 +50,20 @@ import {
|
|||||||
<h3>Rollen</h3>
|
<h3>Rollen</h3>
|
||||||
<div class="chips">
|
<div class="chips">
|
||||||
@for (role of currentUser.roles; track role.id) {
|
@for (role of currentUser.roles; track role.id) {
|
||||||
<span>
|
<span class="role-chip">
|
||||||
{{ role.name }}
|
<span>
|
||||||
<button type="button" (click)="removeRole(currentUser.id, role.id, role.name)">
|
<strong>{{ role.name }}</strong>
|
||||||
Entfernen
|
<small>Herkunft: {{ roleSourceLabel(role.sources) }}</small>
|
||||||
</button>
|
</span>
|
||||||
|
@if (role.sources.includes('MANUAL')) {
|
||||||
|
<button type="button" (click)="removeRole(currentUser.id, role.id, role.name)">
|
||||||
|
Manuelle Zuweisung entfernen
|
||||||
|
</button>
|
||||||
|
} @else if (role.sources.includes('OIDC')) {
|
||||||
|
<small class="managed-hint">
|
||||||
|
Automatisch verwaltet. Entfernen Sie die externe Rolle im Identity Provider.
|
||||||
|
</small>
|
||||||
|
}
|
||||||
</span>
|
</span>
|
||||||
}
|
}
|
||||||
</div>
|
</div>
|
||||||
@@ -203,6 +212,13 @@ import {
|
|||||||
border-radius: 999px;
|
border-radius: 999px;
|
||||||
padding: 4px 6px 4px 10px;
|
padding: 4px 6px 4px 10px;
|
||||||
}
|
}
|
||||||
|
.role-chip > span {
|
||||||
|
display: grid;
|
||||||
|
}
|
||||||
|
.managed-hint {
|
||||||
|
max-width: 20rem;
|
||||||
|
color: var(--color-text-muted);
|
||||||
|
}
|
||||||
.chips button {
|
.chips button {
|
||||||
min-height: 34px;
|
min-height: 34px;
|
||||||
background: var(--color-danger);
|
background: var(--color-danger);
|
||||||
@@ -323,8 +339,15 @@ export class AdminUserDetailPageComponent {
|
|||||||
}
|
}
|
||||||
|
|
||||||
availableRoles(user: AdminUserDetailDto): RoleDto[] {
|
availableRoles(user: AdminUserDetailDto): RoleDto[] {
|
||||||
const assigned = new Set(user.roles.map((role) => role.id));
|
const manuallyAssigned = new Set(
|
||||||
return this.roles().filter((role) => !assigned.has(role.id));
|
user.roles.filter((role) => role.sources.includes('MANUAL')).map((role) => role.id),
|
||||||
|
);
|
||||||
|
return this.roles().filter((role) => !manuallyAssigned.has(role.id));
|
||||||
|
}
|
||||||
|
|
||||||
|
roleSourceLabel(sources: AdminUserDetailDto['roles'][number]['sources']): string {
|
||||||
|
const labels = { MANUAL: 'Manuell', OIDC: 'OIDC', SYSTEM: 'System' } as const;
|
||||||
|
return sources.map((source) => labels[source]).join(' + ');
|
||||||
}
|
}
|
||||||
|
|
||||||
messageFor(error: ApiErrorBody): string {
|
messageFor(error: ApiErrorBody): string {
|
||||||
|
|||||||
@@ -1,5 +1,9 @@
|
|||||||
# Adminbereich
|
# Adminbereich
|
||||||
|
|
||||||
|
Die Systemrolle `ADMIN` kann aus den getrennten Quellen `MANUAL` und `OIDC` wirksam sein. Die
|
||||||
|
Benutzerverwaltung zeigt die Herkunft; das externe Mapping ist nur über die Umgebung konfigurierbar.
|
||||||
|
Siehe [Administratoren über OIDC](oidc-administrator.md).
|
||||||
|
|
||||||
Der Adminbereich liegt im Frontend unter `/admin` und verwendet dieselbe
|
Der Adminbereich liegt im Frontend unter `/admin` und verwendet dieselbe
|
||||||
serverseitige Authentifizierung, CSRF-Pruefung und Permission-Logik wie die
|
serverseitige Authentifizierung, CSRF-Pruefung und Permission-Logik wie die
|
||||||
restliche Anwendung. Angular blendet Navigation und Aktionen nur fuer passende
|
restliche Anwendung. Angular blendet Navigation und Aktionen nur fuer passende
|
||||||
|
|||||||
@@ -1,5 +1,8 @@
|
|||||||
# Konfiguration
|
# Konfiguration
|
||||||
|
|
||||||
|
Die globale Administratorrolle wird serverseitig über `OIDC_ADMIN_ROLE` und `OIDC_ROLES_CLAIM`
|
||||||
|
synchronisiert. Details stehen unter [Administratoren über OIDC](oidc-administrator.md).
|
||||||
|
|
||||||
Die Runtime-Konfiguration wird ueber Umgebungsvariablen geladen und mit Zod in
|
Die Runtime-Konfiguration wird ueber Umgebungsvariablen geladen und mit Zod in
|
||||||
`apps/backend/src/config/env.ts` validiert. `.env.example` ist die Referenz fuer
|
`apps/backend/src/config/env.ts` validiert. `.env.example` ist die Referenz fuer
|
||||||
lokale Entwicklung und Deployment-Vorlagen.
|
lokale Entwicklung und Deployment-Vorlagen.
|
||||||
|
|||||||
63
docs/oidc-administrator.md
Normal file
63
docs/oidc-administrator.md
Normal file
@@ -0,0 +1,63 @@
|
|||||||
|
# Administratoren über OIDC
|
||||||
|
|
||||||
|
HausPilot synchronisiert die globale lokale Systemrolle mit dem stabilen Schlüssel `ADMIN` bei
|
||||||
|
jedem erfolgreichen vollständigen OIDC-Login. Die Reihenfolge der Anmeldungen spielt keine Rolle;
|
||||||
|
der frühere Mechanismus „erster Benutzer wird Administrator“ existiert nicht mehr.
|
||||||
|
|
||||||
|
## Konfiguration
|
||||||
|
|
||||||
|
```env
|
||||||
|
OIDC_ADMIN_ROLE=hauspilot-admin
|
||||||
|
OIDC_ROLES_CLAIM=roles
|
||||||
|
OIDC_ROLE_MATCH_CASE_SENSITIVE=true
|
||||||
|
```
|
||||||
|
|
||||||
|
`OIDC_ADMIN_ROLE` enthält den exakten externen Rollennamen und hat bewusst keinen Standardwert.
|
||||||
|
`OIDC_ROLES_CLAIM` unterstützt einfache und verschachtelte Pfade wie `roles`, `groups`,
|
||||||
|
`realm_access.roles` oder `resource_access.hauspilot.roles`. Der Claim darf ein String oder ein
|
||||||
|
Array aus Strings sein; Leerwerte und Duplikate werden entfernt.
|
||||||
|
|
||||||
|
Ist `OIDC_ADMIN_ROLE` leer oder nicht gesetzt, protokolliert der Start eine Warnung. HausPilot
|
||||||
|
fügt dann keine OIDC-Administratorzuweisung hinzu und entfernt auch keine bestehende. Ein gesetztes
|
||||||
|
`OIDC_ADMIN_ROLE` ohne gültiges `OIDC_ROLES_CLAIM` führt zu einem Startfehler.
|
||||||
|
|
||||||
|
## Synchronisierung und Fail-Safe-Verhalten
|
||||||
|
|
||||||
|
Nach Signatur-, Issuer-, Audience- und Nonce-Prüfung des ID Tokens werden dessen Claims mit der
|
||||||
|
subject-geprüften UserInfo zusammengeführt. Enthält der lesbare Rollenclaim `OIDC_ADMIN_ROLE`, wird
|
||||||
|
die Quelle `OIDC` für die lokale Administratorrolle sichergestellt. Fehlt die Rolle in einer
|
||||||
|
gültigen Rollenliste, wird nur diese OIDC-Quelle entfernt.
|
||||||
|
|
||||||
|
Fehlt der konfigurierte Claim technisch oder hat er ein ungültiges Format, wird keine Zuweisung
|
||||||
|
verändert und der Login fail-safe abgebrochen. Tokens und vollständige Claims werden weder
|
||||||
|
protokolliert noch zusätzlich gespeichert. Die Synchronisierung geschieht vor dem Erzeugen der
|
||||||
|
Session. Es gibt derzeit keinen Token-Refresh mit erneuter Claim-Validierung; Änderungen werden
|
||||||
|
beim nächsten vollständigen Login wirksam.
|
||||||
|
|
||||||
|
## Quellenmodell und Migration
|
||||||
|
|
||||||
|
`user_role_assignments` speichert für Benutzer, Rolle und Quelle eine eindeutige Zuweisung mit den
|
||||||
|
Quellen `MANUAL`, `OIDC` und `SYSTEM`. `user_roles` bleibt die effektive Projektion für bestehende
|
||||||
|
Berechtigungsabfragen. Die Migration übernimmt alle vorhandenen Zuordnungen als `MANUAL`; keine
|
||||||
|
bestehende Administratorrolle wird rückwirkend als OIDC interpretiert. Ein Unique Constraint
|
||||||
|
verhindert doppelte Quellen bei parallelen Logins.
|
||||||
|
|
||||||
|
Die Benutzerverwaltung zeigt die Herkunft an. Eine reine OIDC-Zuweisung lässt sich lokal nicht
|
||||||
|
entfernen; die externe Rolle muss im Identity Provider entzogen werden. Bei `MANUAL + OIDC` kann
|
||||||
|
die manuelle Quelle separat entfernt werden. Projektrollen und der Zugriff auf private Projekte
|
||||||
|
bleiben unverändert.
|
||||||
|
|
||||||
|
## Erstkonfiguration und Recovery
|
||||||
|
|
||||||
|
1. Im Identity Provider eine Adminrolle anlegen und mindestens zwei Recovery-fähigen Benutzern geben.
|
||||||
|
2. `OIDC_ADMIN_ROLE` und `OIDC_ROLES_CLAIM` in der Serverumgebung setzen.
|
||||||
|
3. Migrationen kontrolliert mit `npm run migration:run` ausführen und die Anwendung neu starten.
|
||||||
|
4. Benutzer vollständig neu anmelden.
|
||||||
|
|
||||||
|
Änderungen des Rollennamens werden pro Benutzer beim nächsten Login wirksam. Wird die Variable
|
||||||
|
geleert, bleiben bestehende OIDC-Zuweisungen bewusst erhalten. Für Notfälle kann eine kontrollierte
|
||||||
|
Datenbankoperation oder ein separater, nicht öffentlicher CLI-Prozess eine `MANUAL`-Zuweisung
|
||||||
|
setzen. Es gibt keine öffentliche Recovery-Route und keinen First-User-Fallback.
|
||||||
|
|
||||||
|
Tatsächliche Änderungen werden als `OIDC_ADMIN_ASSIGNED` beziehungsweise `OIDC_ADMIN_REMOVED`
|
||||||
|
auditiert; der externe Rollenname darf erscheinen, niemals Tokens oder vollständige Claims.
|
||||||
@@ -1,5 +1,9 @@
|
|||||||
# Security-Modell
|
# Security-Modell
|
||||||
|
|
||||||
|
Globale Administratorrechte werden beim Login ausschließlich aus serverseitig validierten
|
||||||
|
OIDC-Claims synchronisiert. Es gibt keine browserseitige Rollenübermittlung und keinen
|
||||||
|
First-User-Admin-Fallback. Details: [Administratoren über OIDC](oidc-administrator.md).
|
||||||
|
|
||||||
Das Boilerplate trennt Browser, Backend und Identity Provider strikt. Der Browser
|
Das Boilerplate trennt Browser, Backend und Identity Provider strikt. Der Browser
|
||||||
bekommt keine OIDC-Tokens. Das Backend ist fuer Authentifizierung,
|
bekommt keine OIDC-Tokens. Das Backend ist fuer Authentifizierung,
|
||||||
Autorisierung, CSRF und Session-Verwaltung verbindlich.
|
Autorisierung, CSRF und Session-Verwaltung verbindlich.
|
||||||
|
|||||||
@@ -47,6 +47,7 @@ export interface AdminRoleSummaryDto {
|
|||||||
id: string;
|
id: string;
|
||||||
name: string;
|
name: string;
|
||||||
system: boolean;
|
system: boolean;
|
||||||
|
sources: ('MANUAL' | 'OIDC' | 'SYSTEM')[];
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface AdminSessionDto {
|
export interface AdminSessionDto {
|
||||||
@@ -82,6 +83,9 @@ export interface RoleDto {
|
|||||||
description: string;
|
description: string;
|
||||||
system: boolean;
|
system: boolean;
|
||||||
protected: boolean;
|
protected: boolean;
|
||||||
|
roleKey: string | null;
|
||||||
|
oidcManaged: boolean;
|
||||||
|
oidcRoleName: string | null;
|
||||||
permissions: { id: Permission; description: string }[];
|
permissions: { id: Permission; description: string }[];
|
||||||
userCount?: number;
|
userCount?: number;
|
||||||
users?: UserDto[];
|
users?: UserDto[];
|
||||||
|
|||||||
@@ -59,6 +59,7 @@ export interface AdminRoleSummaryDto {
|
|||||||
id: string;
|
id: string;
|
||||||
name: string;
|
name: string;
|
||||||
system: boolean;
|
system: boolean;
|
||||||
|
sources: ('MANUAL' | 'OIDC' | 'SYSTEM')[];
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface AdminSessionDto {
|
export interface AdminSessionDto {
|
||||||
@@ -94,6 +95,9 @@ export interface RoleDto {
|
|||||||
description: string;
|
description: string;
|
||||||
system: boolean;
|
system: boolean;
|
||||||
protected: boolean;
|
protected: boolean;
|
||||||
|
roleKey: string | null;
|
||||||
|
oidcManaged: boolean;
|
||||||
|
oidcRoleName: string | null;
|
||||||
permissions: { id: Permission; description: string }[];
|
permissions: { id: Permission; description: string }[];
|
||||||
userCount?: number;
|
userCount?: number;
|
||||||
users?: UserDto[];
|
users?: UserDto[];
|
||||||
|
|||||||
Reference in New Issue
Block a user