generated from bastian/boilerplate
deploy
This commit is contained in:
@@ -2,6 +2,7 @@ import { Injectable } from '@nestjs/common';
|
||||
import { getRequestId } from '../common/request-context/request-context';
|
||||
import { AuditAction, AuditLogEntity } from './entities/audit-log.entity';
|
||||
import { AuditRepository } from './repositories/audit.repository';
|
||||
import type { EntityManager } from 'typeorm';
|
||||
|
||||
@Injectable()
|
||||
export class AuditService {
|
||||
@@ -13,6 +14,7 @@ export class AuditService {
|
||||
targetType: string,
|
||||
targetId: string,
|
||||
metadata: Record<string, string | number | boolean | null> | null = null,
|
||||
manager?: EntityManager,
|
||||
): Promise<void> {
|
||||
const entry = new AuditLogEntity();
|
||||
entry.actorUserId = actorUserId;
|
||||
@@ -21,7 +23,7 @@ export class AuditService {
|
||||
entry.targetId = targetId;
|
||||
entry.metadata = metadata;
|
||||
entry.requestId = getRequestId();
|
||||
await this.audit.save(entry);
|
||||
await this.audit.save(entry, manager);
|
||||
}
|
||||
|
||||
async list(page = 1, pageSize = 20) {
|
||||
|
||||
@@ -11,6 +11,8 @@ export enum AuditAction {
|
||||
UserDeactivated = 'USER_DEACTIVATED',
|
||||
UserRoleAssigned = 'USER_ROLE_ASSIGNED',
|
||||
UserRoleRemoved = 'USER_ROLE_REMOVED',
|
||||
OidcAdminAssigned = 'OIDC_ADMIN_ASSIGNED',
|
||||
OidcAdminRemoved = 'OIDC_ADMIN_REMOVED',
|
||||
RoleCreated = 'ROLE_CREATED',
|
||||
RoleUpdated = 'ROLE_UPDATED',
|
||||
RoleDeleted = 'ROLE_DELETED',
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import { InjectRepository } from '@nestjs/typeorm';
|
||||
import { Repository } from 'typeorm';
|
||||
import { EntityManager, Repository } from 'typeorm';
|
||||
import { AuditLogEntity } from '../entities/audit-log.entity';
|
||||
|
||||
@Injectable()
|
||||
@@ -10,8 +10,11 @@ export class AuditRepository {
|
||||
private readonly repo: Repository<AuditLogEntity>,
|
||||
) {}
|
||||
|
||||
save(entry: AuditLogEntity): Promise<AuditLogEntity> {
|
||||
return this.repo.save(entry);
|
||||
save(
|
||||
entry: AuditLogEntity,
|
||||
manager?: EntityManager,
|
||||
): Promise<AuditLogEntity> {
|
||||
return (manager?.getRepository(AuditLogEntity) ?? this.repo).save(entry);
|
||||
}
|
||||
|
||||
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 { AuthController } from './auth.controller';
|
||||
import { AuthService } from './auth.service';
|
||||
import { OidcRoleExtractorService } from './oidc-role-extractor.service';
|
||||
import { OidcAdminSynchronizationService } from './oidc-admin-synchronization.service';
|
||||
import { UserRoleAssignmentEntity } from '../users/entities/user-role-assignment.entity';
|
||||
import { AuditModule } from '../audit/audit.module';
|
||||
|
||||
@Module({
|
||||
imports: [
|
||||
@@ -16,12 +20,20 @@ import { AuthService } from './auth.service';
|
||||
OidcLoginStateEntity,
|
||||
UserEntity,
|
||||
UserSettingsEntity,
|
||||
UserRoleAssignmentEntity,
|
||||
]),
|
||||
RolesModule,
|
||||
SessionsModule,
|
||||
AuditModule,
|
||||
],
|
||||
controllers: [AuthController],
|
||||
providers: [AuthService, ExternalHttpClient, UsersRepository],
|
||||
providers: [
|
||||
AuthService,
|
||||
ExternalHttpClient,
|
||||
UsersRepository,
|
||||
OidcRoleExtractorService,
|
||||
OidcAdminSynchronizationService,
|
||||
],
|
||||
exports: [AuthService],
|
||||
})
|
||||
export class AuthModule {}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { describe, expect, it, vi } from 'vitest';
|
||||
import type { DataSource, Repository } from 'typeorm';
|
||||
import type { DataSource, EntityManager, Repository } from 'typeorm';
|
||||
import type { ExternalHttpClient } from '../common/http/external-http-client';
|
||||
import type { AppConfigService } from '../config/config.service';
|
||||
import type { RolesService } from '../roles/roles.service';
|
||||
@@ -7,8 +7,82 @@ import type { SessionsService } from '../sessions/sessions.service';
|
||||
import type { UsersRepository } from '../users/repositories/users.repository';
|
||||
import type { OidcLoginStateEntity } from './entities/oidc-login-state.entity';
|
||||
import { AuthService } from './auth.service';
|
||||
import type { OidcAdminSynchronizationService } from './oidc-admin-synchronization.service';
|
||||
import { UserEntity } from '../users/entities/user.entity';
|
||||
import { UserSettingsEntity } from '../users/entities/user-settings.entity';
|
||||
import type { RoleEntity } from '../roles/entities/role.entity';
|
||||
|
||||
describe('AuthService', () => {
|
||||
it('does not grant the first or second provisioned user the admin role', async () => {
|
||||
let sequence = 0;
|
||||
const userRole = {
|
||||
id: 'role-user',
|
||||
name: 'user',
|
||||
roleKey: 'USER',
|
||||
} as RoleEntity;
|
||||
const adminRole = {
|
||||
id: 'role-admin',
|
||||
name: 'admin',
|
||||
roleKey: 'ADMIN',
|
||||
} as RoleEntity;
|
||||
const manager = {
|
||||
query: () => Promise.resolve(),
|
||||
getRepository: (entity: unknown) => {
|
||||
if (entity === UserEntity) {
|
||||
return {
|
||||
findOne: () => Promise.resolve(null),
|
||||
save: (user: UserEntity) => {
|
||||
user.id = `user-${++sequence}`;
|
||||
return Promise.resolve(user);
|
||||
},
|
||||
};
|
||||
}
|
||||
if (entity === UserSettingsEntity) {
|
||||
return {
|
||||
findOne: () => Promise.resolve(null),
|
||||
save: (settings: UserSettingsEntity) => Promise.resolve(settings),
|
||||
};
|
||||
}
|
||||
throw new Error('Unerwartetes Repository');
|
||||
},
|
||||
} as unknown as EntityManager;
|
||||
const service = new AuthService(
|
||||
{} as AppConfigService,
|
||||
{} as ExternalHttpClient,
|
||||
{
|
||||
ensureSystemRoles: () =>
|
||||
Promise.resolve({ admin: adminRole, user: userRole }),
|
||||
} as unknown as RolesService,
|
||||
{} as UsersRepository,
|
||||
{} as SessionsService,
|
||||
{} as OidcAdminSynchronizationService,
|
||||
{
|
||||
transaction: <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 () => {
|
||||
const saved: OidcLoginStateEntity[] = [];
|
||||
const service = new AuthService(
|
||||
@@ -36,6 +110,7 @@ describe('AuthService', () => {
|
||||
{} as RolesService,
|
||||
{} as UsersRepository,
|
||||
{} as SessionsService,
|
||||
{} as OidcAdminSynchronizationService,
|
||||
{} as DataSource,
|
||||
{
|
||||
delete: () => Promise.resolve({}),
|
||||
@@ -76,6 +151,7 @@ describe('AuthService', () => {
|
||||
{} as RolesService,
|
||||
{} as UsersRepository,
|
||||
{ revoke, getIdTokenForLogout } as unknown as SessionsService,
|
||||
{} as OidcAdminSynchronizationService,
|
||||
{} as DataSource,
|
||||
{} as Repository<OidcLoginStateEntity>,
|
||||
);
|
||||
|
||||
@@ -17,6 +17,8 @@ import type {
|
||||
OidcTokenResponse,
|
||||
OidcUserInfo,
|
||||
} from './oidc.types';
|
||||
import { OidcAdminSynchronizationService } from './oidc-admin-synchronization.service';
|
||||
import { UserRoleAssignmentSource } from '../users/entities/user-role-assignment.entity';
|
||||
|
||||
@Injectable()
|
||||
export class AuthService {
|
||||
@@ -26,6 +28,7 @@ export class AuthService {
|
||||
private readonly roles: RolesService,
|
||||
private readonly users: UsersRepository,
|
||||
private readonly sessions: SessionsService,
|
||||
private readonly adminSynchronization: OidcAdminSynchronizationService,
|
||||
@InjectDataSource() private readonly dataSource: DataSource,
|
||||
@InjectRepository(OidcLoginStateEntity)
|
||||
private readonly loginStates: Repository<OidcLoginStateEntity>,
|
||||
@@ -83,12 +86,12 @@ export class AuthService {
|
||||
code,
|
||||
loginState.codeVerifier,
|
||||
);
|
||||
const profile = await this.verifyAndLoadProfile(
|
||||
const { profile, claims } = await this.verifyAndLoadProfile(
|
||||
discovery,
|
||||
tokens,
|
||||
loginState.nonce,
|
||||
);
|
||||
const user = await this.upsertLocalUser(discovery.issuer, profile);
|
||||
let user = await this.upsertLocalUser(discovery.issuer, profile);
|
||||
if (!user.active) {
|
||||
throw new ApiError(
|
||||
ErrorCode.UserDisabled,
|
||||
@@ -96,6 +99,7 @@ export class AuthService {
|
||||
403,
|
||||
);
|
||||
}
|
||||
user = await this.adminSynchronization.synchronize(user.id, claims);
|
||||
|
||||
const result = await this.sessions.createSession(
|
||||
user,
|
||||
@@ -129,8 +133,7 @@ export class AuthService {
|
||||
return this.dataSource.transaction(async (manager) => {
|
||||
await manager.query("SELECT GET_LOCK('business_app_first_admin', 10)");
|
||||
try {
|
||||
const { admin, user: userRole } =
|
||||
await this.roles.ensureSystemRoles(manager);
|
||||
const { user: userRole } = await this.roles.ensureSystemRoles(manager);
|
||||
let user = await manager.getRepository(UserEntity).findOne({
|
||||
where: { issuer, subject: profile.sub },
|
||||
relations: { roles: true, settings: true },
|
||||
@@ -141,10 +144,6 @@ export class AuthService {
|
||||
user.subject = profile.sub;
|
||||
user.active = true;
|
||||
user.roles = [userRole];
|
||||
const userCount = await manager.getRepository(UserEntity).count();
|
||||
if (userCount === 0) {
|
||||
user.roles = [userRole, admin];
|
||||
}
|
||||
}
|
||||
user.name = profile.name ?? profile.email ?? profile.sub;
|
||||
user.email = profile.email ?? null;
|
||||
@@ -154,6 +153,12 @@ export class AuthService {
|
||||
: null;
|
||||
user.lastLoginAt = new Date();
|
||||
const savedUser = await manager.getRepository(UserEntity).save(user);
|
||||
await manager.query(
|
||||
`INSERT IGNORE INTO user_role_assignments
|
||||
(id, user_id, role_id, source, last_synchronized_at, created_at, updated_at)
|
||||
VALUES (UUID(), ?, ?, ?, NULL, CURRENT_TIMESTAMP(3), CURRENT_TIMESTAMP(3))`,
|
||||
[savedUser.id, userRole.id, UserRoleAssignmentSource.System],
|
||||
);
|
||||
savedUser.settings = await this.ensureUserSettings(manager, savedUser);
|
||||
return savedUser;
|
||||
} finally {
|
||||
@@ -266,7 +271,7 @@ export class AuthService {
|
||||
discovery: OidcDiscovery,
|
||||
tokens: OidcTokenResponse,
|
||||
nonce: string,
|
||||
): Promise<OidcUserInfo> {
|
||||
): Promise<{ profile: OidcUserInfo; claims: Record<string, unknown> }> {
|
||||
const { createRemoteJWKSet, decodeProtectedHeader, jwtVerify } =
|
||||
await import('jose');
|
||||
const protectedHeader = decodeProtectedHeader(tokens.id_token);
|
||||
@@ -305,7 +310,7 @@ export class AuthService {
|
||||
if (typeof payload['email_verified'] === 'boolean') {
|
||||
fallback.email_verified = payload['email_verified'];
|
||||
}
|
||||
return fallback;
|
||||
return { profile: fallback, claims: { ...payload } };
|
||||
}
|
||||
const userInfo = await this.http.requestJson<OidcUserInfo>(
|
||||
discovery.userinfo_endpoint,
|
||||
@@ -335,7 +340,7 @@ export class AuthService {
|
||||
) {
|
||||
userInfo.email_verified = payload['email_verified'];
|
||||
}
|
||||
return userInfo;
|
||||
return { profile: userInfo, claims: { ...payload, ...userInfo } };
|
||||
}
|
||||
|
||||
private isAllowedOidcAlgorithm(algorithm: string | undefined): boolean {
|
||||
|
||||
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/);
|
||||
});
|
||||
|
||||
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;
|
||||
allowedAlgorithms: string[];
|
||||
httpTimeoutMs: number;
|
||||
adminRole?: string;
|
||||
rolesClaim?: string;
|
||||
roleMatchCaseSensitive: boolean;
|
||||
logoutUrl?: string;
|
||||
};
|
||||
session: {
|
||||
|
||||
@@ -10,85 +10,129 @@ const booleanFromString = z
|
||||
.pipe(z.enum(['true', 'false']))
|
||||
.transform((value) => value === 'true');
|
||||
|
||||
const envSchema = z.object({
|
||||
NODE_ENV: z
|
||||
.enum(['development', 'test', 'production'])
|
||||
.default('development'),
|
||||
PORT: z.coerce.number().int().min(1).max(65535).default(3000),
|
||||
APP_BASE_URL: z.url(),
|
||||
FRONTEND_BASE_URL: z.url().optional(),
|
||||
TRUST_PROXY: booleanFromString.default(false),
|
||||
DATABASE_HOST: z.string().min(1),
|
||||
DATABASE_PORT: z.coerce.number().int().min(1).max(65535).default(3306),
|
||||
DATABASE_NAME: z.string().min(1),
|
||||
DATABASE_USER: z.string().min(1),
|
||||
DATABASE_PASSWORD: z.string().min(1),
|
||||
DATABASE_SSL: booleanFromString.default(false),
|
||||
OIDC_ISSUER: z.url(),
|
||||
OIDC_CLIENT_ID: z.string().min(1),
|
||||
OIDC_CLIENT_SECRET: z.string().min(1),
|
||||
OIDC_SCOPES: z.string().min(1).default('openid profile email'),
|
||||
OIDC_LOGOUT_URL: z.preprocess(
|
||||
(value) => (value === '' ? undefined : value),
|
||||
z.url().optional(),
|
||||
),
|
||||
OIDC_ALLOWED_ALGORITHMS: z
|
||||
.string()
|
||||
.min(1)
|
||||
.transform((value) =>
|
||||
const optionalTrimmedString = (maxLength: number) =>
|
||||
z.preprocess(
|
||||
(value) =>
|
||||
typeof value === 'string' && value.trim() === '' ? undefined : value,
|
||||
z
|
||||
.string()
|
||||
.trim()
|
||||
.max(maxLength)
|
||||
.refine((value) =>
|
||||
Array.from(value).every((character) => {
|
||||
const code = character.charCodeAt(0);
|
||||
return code > 31 && code !== 127;
|
||||
}),
|
||||
)
|
||||
.optional(),
|
||||
);
|
||||
|
||||
const envSchema = z
|
||||
.object({
|
||||
NODE_ENV: z
|
||||
.enum(['development', 'test', 'production'])
|
||||
.default('development'),
|
||||
PORT: z.coerce.number().int().min(1).max(65535).default(3000),
|
||||
APP_BASE_URL: z.url(),
|
||||
FRONTEND_BASE_URL: z.url().optional(),
|
||||
TRUST_PROXY: booleanFromString.default(false),
|
||||
DATABASE_HOST: z.string().min(1),
|
||||
DATABASE_PORT: z.coerce.number().int().min(1).max(65535).default(3306),
|
||||
DATABASE_NAME: z.string().min(1),
|
||||
DATABASE_USER: z.string().min(1),
|
||||
DATABASE_PASSWORD: z.string().min(1),
|
||||
DATABASE_SSL: booleanFromString.default(false),
|
||||
OIDC_ISSUER: z.url(),
|
||||
OIDC_CLIENT_ID: z.string().min(1),
|
||||
OIDC_CLIENT_SECRET: z.string().min(1),
|
||||
OIDC_SCOPES: z.string().min(1).default('openid profile email'),
|
||||
OIDC_LOGOUT_URL: z.preprocess(
|
||||
(value) => (value === '' ? undefined : value),
|
||||
z.url().optional(),
|
||||
),
|
||||
OIDC_ALLOWED_ALGORITHMS: z
|
||||
.string()
|
||||
.min(1)
|
||||
.transform((value) =>
|
||||
value
|
||||
.split(/[\s,]+/)
|
||||
.map((entry) => entry.trim())
|
||||
.filter(Boolean),
|
||||
)
|
||||
.refine(
|
||||
(algorithms) =>
|
||||
algorithms.length > 0 &&
|
||||
algorithms.every((algorithm) => algorithm.toLowerCase() !== 'none'),
|
||||
'OIDC_ALLOWED_ALGORITHMS darf none nicht erlauben.',
|
||||
),
|
||||
OIDC_HTTP_TIMEOUT_MS: z.coerce
|
||||
.number()
|
||||
.int()
|
||||
.min(1000)
|
||||
.max(30000)
|
||||
.default(5000),
|
||||
OIDC_ADMIN_ROLE: optionalTrimmedString(160),
|
||||
OIDC_ROLES_CLAIM: optionalTrimmedString(255).refine(
|
||||
(value) =>
|
||||
value === undefined ||
|
||||
/^[A-Za-z0-9_-]+(?:\.[A-Za-z0-9_-]+)*$/.test(value),
|
||||
'OIDC_ROLES_CLAIM muss ein gueltiger Claim-Pfad sein.',
|
||||
),
|
||||
OIDC_ROLE_MATCH_CASE_SENSITIVE: booleanFromString.default(true),
|
||||
SESSION_COOKIE_NAME: z.string().min(1).default('app_session'),
|
||||
SESSION_IDLE_TIMEOUT_SECONDS: z.coerce
|
||||
.number()
|
||||
.int()
|
||||
.min(300)
|
||||
.default(28800),
|
||||
SESSION_ABSOLUTE_TIMEOUT_SECONDS: z.coerce
|
||||
.number()
|
||||
.int()
|
||||
.min(3600)
|
||||
.default(604800),
|
||||
SESSION_SECRET: z.string().min(32),
|
||||
SESSION_ENCRYPTION_KEY: z.string().min(32),
|
||||
CORS_ORIGINS: z.string().transform((value) =>
|
||||
value
|
||||
.split(/[\s,]+/)
|
||||
.split(',')
|
||||
.map((entry) => entry.trim())
|
||||
.filter(Boolean),
|
||||
)
|
||||
.refine(
|
||||
(algorithms) =>
|
||||
algorithms.length > 0 &&
|
||||
algorithms.every((algorithm) => algorithm.toLowerCase() !== 'none'),
|
||||
'OIDC_ALLOWED_ALGORITHMS darf none nicht erlauben.',
|
||||
),
|
||||
OIDC_HTTP_TIMEOUT_MS: z.coerce
|
||||
.number()
|
||||
.int()
|
||||
.min(1000)
|
||||
.max(30000)
|
||||
.default(5000),
|
||||
SESSION_COOKIE_NAME: z.string().min(1).default('app_session'),
|
||||
SESSION_IDLE_TIMEOUT_SECONDS: z.coerce.number().int().min(300).default(28800),
|
||||
SESSION_ABSOLUTE_TIMEOUT_SECONDS: z.coerce
|
||||
.number()
|
||||
.int()
|
||||
.min(3600)
|
||||
.default(604800),
|
||||
SESSION_SECRET: z.string().min(32),
|
||||
SESSION_ENCRYPTION_KEY: z.string().min(32),
|
||||
CORS_ORIGINS: z.string().transform((value) =>
|
||||
value
|
||||
.split(',')
|
||||
.map((entry) => entry.trim())
|
||||
.filter(Boolean),
|
||||
),
|
||||
CSRF_HEADER_NAME: z.string().min(1).default('X-CSRF-Token'),
|
||||
LOG_LEVEL: z.string().min(1).default('info'),
|
||||
SWAGGER_ENABLED: booleanFromString.default(false),
|
||||
DOCUMENT_STORAGE_PATH: z.string().min(1).default('storage/documents'),
|
||||
DOCUMENT_MAX_FILE_SIZE_BYTES: z.coerce
|
||||
.number()
|
||||
.int()
|
||||
.min(1024)
|
||||
.max(50 * 1024 * 1024)
|
||||
.default(10 * 1024 * 1024),
|
||||
REMINDER_INTERVAL_MS: z.coerce.number().int().min(60000).default(900000),
|
||||
REMINDER_DUE_SOON_DAYS: z.coerce.number().int().min(1).max(30).default(3),
|
||||
RATE_LIMIT_WINDOW_SECONDS: z.coerce.number().int().min(1).default(60),
|
||||
RATE_LIMIT_MAX_REQUESTS: z.coerce.number().int().min(1).default(300),
|
||||
RATE_LIMIT_SENSITIVE_WINDOW_SECONDS: z.coerce
|
||||
.number()
|
||||
.int()
|
||||
.min(1)
|
||||
.default(60),
|
||||
RATE_LIMIT_SENSITIVE_MAX_REQUESTS: z.coerce.number().int().min(1).default(10),
|
||||
});
|
||||
CSRF_HEADER_NAME: z.string().min(1).default('X-CSRF-Token'),
|
||||
LOG_LEVEL: z.string().min(1).default('info'),
|
||||
SWAGGER_ENABLED: booleanFromString.default(false),
|
||||
DOCUMENT_STORAGE_PATH: z.string().min(1).default('storage/documents'),
|
||||
DOCUMENT_MAX_FILE_SIZE_BYTES: z.coerce
|
||||
.number()
|
||||
.int()
|
||||
.min(1024)
|
||||
.max(50 * 1024 * 1024)
|
||||
.default(10 * 1024 * 1024),
|
||||
REMINDER_INTERVAL_MS: z.coerce.number().int().min(60000).default(900000),
|
||||
REMINDER_DUE_SOON_DAYS: z.coerce.number().int().min(1).max(30).default(3),
|
||||
RATE_LIMIT_WINDOW_SECONDS: z.coerce.number().int().min(1).default(60),
|
||||
RATE_LIMIT_MAX_REQUESTS: z.coerce.number().int().min(1).default(300),
|
||||
RATE_LIMIT_SENSITIVE_WINDOW_SECONDS: z.coerce
|
||||
.number()
|
||||
.int()
|
||||
.min(1)
|
||||
.default(60),
|
||||
RATE_LIMIT_SENSITIVE_MAX_REQUESTS: z.coerce
|
||||
.number()
|
||||
.int()
|
||||
.min(1)
|
||||
.default(10),
|
||||
})
|
||||
.superRefine((value, context) => {
|
||||
if (value.OIDC_ADMIN_ROLE && !value.OIDC_ROLES_CLAIM) {
|
||||
context.addIssue({
|
||||
code: 'custom',
|
||||
path: ['OIDC_ROLES_CLAIM'],
|
||||
message:
|
||||
'OIDC_ROLES_CLAIM ist erforderlich, wenn OIDC_ADMIN_ROLE gesetzt ist.',
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
export function loadConfigFromEnv(env: Record<string, unknown>): AppConfig {
|
||||
const parsed = envSchema.safeParse(env);
|
||||
@@ -134,6 +178,9 @@ export function loadConfigFromEnv(env: Record<string, unknown>): AppConfig {
|
||||
scopes: value.OIDC_SCOPES,
|
||||
allowedAlgorithms: value.OIDC_ALLOWED_ALGORITHMS,
|
||||
httpTimeoutMs: value.OIDC_HTTP_TIMEOUT_MS,
|
||||
roleMatchCaseSensitive: value.OIDC_ROLE_MATCH_CASE_SENSITIVE,
|
||||
...(value.OIDC_ADMIN_ROLE ? { adminRole: value.OIDC_ADMIN_ROLE } : {}),
|
||||
...(value.OIDC_ROLES_CLAIM ? { rolesClaim: value.OIDC_ROLES_CLAIM } : {}),
|
||||
...(value.OIDC_LOGOUT_URL ? { logoutUrl: value.OIDC_LOGOUT_URL } : {}),
|
||||
},
|
||||
session: {
|
||||
|
||||
@@ -11,6 +11,7 @@ import { PermissionEntity } from '../roles/entities/permission.entity';
|
||||
import { SessionEntity } from '../sessions/entities/session.entity';
|
||||
import { UserSettingsEntity } from '../users/entities/user-settings.entity';
|
||||
import { UserEntity } from '../users/entities/user.entity';
|
||||
import { UserRoleAssignmentEntity } from '../users/entities/user-role-assignment.entity';
|
||||
import {
|
||||
BudgetCategoryEntity,
|
||||
BuildingEntity,
|
||||
@@ -49,6 +50,7 @@ export const entities = [
|
||||
SessionEntity,
|
||||
UserSettingsEntity,
|
||||
UserEntity,
|
||||
UserRoleAssignmentEntity,
|
||||
BuildingEntity,
|
||||
FloorEntity,
|
||||
RoomEntity,
|
||||
|
||||
@@ -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 { AddDefaultProjectFloors1720000006000 } from './migrations/1720000006000-AddDefaultProjectFloors';
|
||||
import { AddFurniturePlanning1720000007000 } from './migrations/1720000007000-AddFurniturePlanning';
|
||||
import { AddRoleAssignmentSources1720000008000 } from './migrations/1720000008000-AddRoleAssignmentSources';
|
||||
|
||||
const config = loadConfigForCli();
|
||||
|
||||
@@ -35,5 +36,6 @@ export default new DataSource({
|
||||
AddMentionsAndReminders1720000005000,
|
||||
AddDefaultProjectFloors1720000006000,
|
||||
AddFurniturePlanning1720000007000,
|
||||
AddRoleAssignmentSources1720000008000,
|
||||
],
|
||||
});
|
||||
|
||||
@@ -9,6 +9,7 @@ import { AddRenovationDomain1720000004000 } from './migrations/1720000004000-Add
|
||||
import { AddMentionsAndReminders1720000005000 } from './migrations/1720000005000-AddMentionsAndReminders';
|
||||
import { AddDefaultProjectFloors1720000006000 } from './migrations/1720000006000-AddDefaultProjectFloors';
|
||||
import { AddFurniturePlanning1720000007000 } from './migrations/1720000007000-AddFurniturePlanning';
|
||||
import { AddRoleAssignmentSources1720000008000 } from './migrations/1720000008000-AddRoleAssignmentSources';
|
||||
|
||||
export function typeOrmOptionsFactory(
|
||||
config: AppConfigService,
|
||||
@@ -35,6 +36,7 @@ export function typeOrmOptionsFactory(
|
||||
AddMentionsAndReminders1720000005000,
|
||||
AddDefaultProjectFloors1720000006000,
|
||||
AddFurniturePlanning1720000007000,
|
||||
AddRoleAssignmentSources1720000008000,
|
||||
],
|
||||
};
|
||||
}
|
||||
|
||||
@@ -3,6 +3,7 @@ import cookieParser from 'cookie-parser';
|
||||
import type { NextFunction, Request, Response } from 'express';
|
||||
import helmet from 'helmet';
|
||||
import pinoHttp from 'pino-http';
|
||||
import { Logger } from '@nestjs/common';
|
||||
import { NestFactory } from '@nestjs/core';
|
||||
import { DocumentBuilder, SwaggerModule } from '@nestjs/swagger';
|
||||
import type { NestExpressApplication } from '@nestjs/platform-express';
|
||||
@@ -16,6 +17,11 @@ async function bootstrap() {
|
||||
bufferLogs: true,
|
||||
});
|
||||
const config = app.get(AppConfigService);
|
||||
if (!config.oidc.adminRole) {
|
||||
new Logger('Configuration').warn(
|
||||
'OIDC_ADMIN_ROLE ist nicht konfiguriert. Die automatische Administrator-Synchronisierung ist deaktiviert; es gibt keinen First-User-Fallback.',
|
||||
);
|
||||
}
|
||||
|
||||
app.use(
|
||||
pinoHttp({
|
||||
|
||||
@@ -65,11 +65,20 @@ describe('Notification integrations', () => {
|
||||
active: true,
|
||||
roles: [{ id: 'role-user', name: 'user' }],
|
||||
};
|
||||
let userLoad = 0;
|
||||
const users: Pick<UsersRepository, 'findByIdWithRoles' | 'save'> = {
|
||||
findByIdWithRoles: () =>
|
||||
Promise.resolve(
|
||||
user as Awaited<ReturnType<UsersRepository['findByIdWithRoles']>>,
|
||||
),
|
||||
findByIdWithRoles: () => {
|
||||
const loaded =
|
||||
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),
|
||||
};
|
||||
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 = {
|
||||
query: () => Promise.resolve(),
|
||||
getRepository: () => assignmentRepository,
|
||||
} as unknown as EntityManager;
|
||||
const dataSource = {
|
||||
transaction: <T>(action: (manager: EntityManager) => Promise<T>) =>
|
||||
|
||||
@@ -17,6 +17,9 @@ export class RoleEntity {
|
||||
@PrimaryGeneratedColumn('uuid')
|
||||
id!: string;
|
||||
|
||||
@Column({ name: 'role_key', type: 'varchar', length: 40, nullable: true })
|
||||
roleKey!: string | null;
|
||||
|
||||
@Column({ type: 'varchar', length: 80 })
|
||||
name!: string;
|
||||
|
||||
|
||||
@@ -11,6 +11,8 @@ import { RoleEntity } from './entities/role.entity';
|
||||
import { allPermissions, Permission } from './permissions';
|
||||
import type { CreateRoleDto, UpdateRoleDto } from './dto/role.dto';
|
||||
import { RolesRepository } from './repositories/roles.repository';
|
||||
import { AppConfigService } from '../config/config.service';
|
||||
import { adminRoleKey, userRoleKey } from './system-role-keys';
|
||||
|
||||
const adminRoleName = 'admin';
|
||||
const userRoleName = 'user';
|
||||
@@ -20,6 +22,7 @@ export class RolesService {
|
||||
constructor(
|
||||
private readonly roles: RolesRepository,
|
||||
private readonly audit: AuditService,
|
||||
private readonly config: AppConfigService,
|
||||
@InjectDataSource() private readonly dataSource: DataSource,
|
||||
) {}
|
||||
|
||||
@@ -78,12 +81,12 @@ export class RolesService {
|
||||
await this.assertRoleNameAvailable(normalizedName, id, manager);
|
||||
role.name = normalizedName;
|
||||
}
|
||||
if (role.name === adminRoleName) {
|
||||
if (role.roleKey === adminRoleKey) {
|
||||
this.assertAdminPermissions(dto.permissions);
|
||||
}
|
||||
role.description = dto.description?.trim() ?? '';
|
||||
role.permissions = await this.loadPermissionEntities(
|
||||
role.name === adminRoleName ? allPermissions : dto.permissions,
|
||||
role.roleKey === adminRoleKey ? allPermissions : dto.permissions,
|
||||
manager,
|
||||
);
|
||||
const saved = await this.roles.save(role, manager);
|
||||
@@ -178,12 +181,14 @@ export class RolesService {
|
||||
await this.syncPermissions(manager);
|
||||
const admin = await this.ensureRole(
|
||||
adminRoleName,
|
||||
adminRoleKey,
|
||||
allPermissions,
|
||||
true,
|
||||
manager,
|
||||
);
|
||||
const user = await this.ensureRole(
|
||||
userRoleName,
|
||||
userRoleKey,
|
||||
[
|
||||
Permission.ItemsRead,
|
||||
Permission.SessionsReadOwn,
|
||||
@@ -223,6 +228,7 @@ export class RolesService {
|
||||
|
||||
private async ensureRole(
|
||||
name: string,
|
||||
roleKey: string,
|
||||
permissions: Permission[],
|
||||
protectedRole: boolean,
|
||||
manager?: EntityManager,
|
||||
@@ -237,6 +243,7 @@ export class RolesService {
|
||||
role.name = name;
|
||||
role.protected = protectedRole;
|
||||
}
|
||||
role.roleKey = roleKey;
|
||||
role.permissions = await this.loadPermissionEntities(permissions, manager);
|
||||
role.protected = protectedRole;
|
||||
return repo.save(role);
|
||||
@@ -303,6 +310,13 @@ export class RolesService {
|
||||
description: role.description,
|
||||
system: role.protected,
|
||||
protected: role.protected,
|
||||
roleKey: role.roleKey,
|
||||
oidcManaged:
|
||||
role.roleKey === adminRoleKey && Boolean(this.config.oidc.adminRole),
|
||||
oidcRoleName:
|
||||
role.roleKey === adminRoleKey
|
||||
? (this.config.oidc.adminRole ?? null)
|
||||
: null,
|
||||
permissions: role.permissions,
|
||||
userCount: role.users?.length ?? 0,
|
||||
users: role.users,
|
||||
|
||||
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,
|
||||
} from '../dto/user.dto';
|
||||
import { UserEntity } from '../entities/user.entity';
|
||||
import { adminRoleKey } from '../../roles/system-role-keys';
|
||||
|
||||
@Injectable()
|
||||
export class UsersRepository {
|
||||
@@ -115,7 +116,7 @@ export class UsersRepository {
|
||||
.createQueryBuilder('user')
|
||||
.innerJoin('user.roles', 'role')
|
||||
.where('user.active = :active', { active: true })
|
||||
.andWhere('role.name = :role', { role: 'admin' })
|
||||
.andWhere('role.roleKey = :roleKey', { roleKey: adminRoleKey })
|
||||
.orderBy('user.createdAt', 'ASC');
|
||||
if (excludedUserId) {
|
||||
qb.andWhere('user.id <> :excludedUserId', { excludedUserId });
|
||||
|
||||
@@ -9,10 +9,15 @@ import { UserEntity } from './entities/user.entity';
|
||||
import { UsersRepository } from './repositories/users.repository';
|
||||
import { UsersController } from './users.controller';
|
||||
import { UsersService } from './users.service';
|
||||
import { UserRoleAssignmentEntity } from './entities/user-role-assignment.entity';
|
||||
|
||||
@Module({
|
||||
imports: [
|
||||
TypeOrmModule.forFeature([UserEntity, UserSettingsEntity]),
|
||||
TypeOrmModule.forFeature([
|
||||
UserEntity,
|
||||
UserSettingsEntity,
|
||||
UserRoleAssignmentEntity,
|
||||
]),
|
||||
RolesModule,
|
||||
AuditModule,
|
||||
SessionsModule,
|
||||
|
||||
@@ -9,11 +9,16 @@ import { ErrorCode } from '../common/errors/error-codes';
|
||||
import { NotificationType } from '../notifications/notification-types';
|
||||
import { NotificationsService } from '../notifications/notifications.service';
|
||||
import { RolesService } from '../roles/roles.service';
|
||||
import { adminRoleKey } from '../roles/system-role-keys';
|
||||
import { RoleEntity } from '../roles/entities/role.entity';
|
||||
import { SessionsService } from '../sessions/sessions.service';
|
||||
import type { AdminSessionDto } from '../sessions/sessions.service';
|
||||
import { UserSettingsEntity } from './entities/user-settings.entity';
|
||||
import { UserEntity } from './entities/user.entity';
|
||||
import {
|
||||
UserRoleAssignmentEntity,
|
||||
UserRoleAssignmentSource,
|
||||
} from './entities/user-role-assignment.entity';
|
||||
import type { AdminUserListQueryDto } from './dto/user.dto';
|
||||
import { UsersRepository } from './repositories/users.repository';
|
||||
|
||||
@@ -22,6 +27,7 @@ interface AdminRoleSummary {
|
||||
name: string;
|
||||
description: string;
|
||||
system: boolean;
|
||||
sources: UserRoleAssignmentSource[];
|
||||
}
|
||||
|
||||
interface AdminUserListItem {
|
||||
@@ -63,7 +69,7 @@ export class UsersService {
|
||||
const [users, total] = await this.users.adminSearch(query);
|
||||
const items = await Promise.all(
|
||||
users.map(async (user) => ({
|
||||
...this.toAdminListItem(user),
|
||||
...this.toAdminListItem(user, await this.roleSources(user.id)),
|
||||
activeSessionCount: await this.sessions.countActiveForUser(user.id),
|
||||
})),
|
||||
);
|
||||
@@ -76,7 +82,7 @@ export class UsersService {
|
||||
): Promise<AdminUserDetail> {
|
||||
const user = await this.getWithRoles(id);
|
||||
return {
|
||||
...this.toAdminListItem(user),
|
||||
...this.toAdminListItem(user, await this.roleSources(user.id)),
|
||||
activeSessionCount: await this.sessions.countActiveForUser(user.id),
|
||||
effectivePermissions: this.effectivePermissions(user),
|
||||
sessions: await this.sessions.listForAdmin(user.id, currentSessionId),
|
||||
@@ -120,7 +126,7 @@ export class UsersService {
|
||||
409,
|
||||
);
|
||||
}
|
||||
if (!active && this.hasRole(user, 'admin')) {
|
||||
if (!active && this.hasRole(user, adminRoleKey)) {
|
||||
await this.assertAnotherActiveAdminRemains(userId, manager);
|
||||
}
|
||||
user.active = active;
|
||||
@@ -151,14 +157,8 @@ export class UsersService {
|
||||
userId: string,
|
||||
roleId: string,
|
||||
): Promise<UserEntity> {
|
||||
const user = await this.getWithRoles(userId);
|
||||
if (user.roles.some((role) => role.id === roleId)) {
|
||||
return user;
|
||||
}
|
||||
return this.replaceRoles(actor, userId, [
|
||||
...user.roles.map((role) => role.id),
|
||||
roleId,
|
||||
]);
|
||||
const roleIds = await this.manualRoleIds(userId);
|
||||
return this.replaceRoles(actor, userId, [...roleIds, roleId]);
|
||||
}
|
||||
|
||||
async removeRole(
|
||||
@@ -166,14 +166,11 @@ export class UsersService {
|
||||
userId: string,
|
||||
roleId: string,
|
||||
): Promise<UserEntity> {
|
||||
const user = await this.getWithRoles(userId);
|
||||
if (!user.roles.some((role) => role.id === roleId)) {
|
||||
return user;
|
||||
}
|
||||
const roleIds = await this.manualRoleIds(userId);
|
||||
return this.replaceRoles(
|
||||
actor,
|
||||
userId,
|
||||
user.roles.filter((role) => role.id !== roleId).map((role) => role.id),
|
||||
roleIds.filter((id) => id !== roleId),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -235,7 +232,7 @@ export class UsersService {
|
||||
.innerJoin('user.roles', 'role')
|
||||
.where('user.active = :active', { active: true })
|
||||
.andWhere('user.id <> :excludedUserId', { excludedUserId })
|
||||
.andWhere('role.name = :role', { role: 'admin' })
|
||||
.andWhere('role.roleKey = :roleKey', { roleKey: adminRoleKey })
|
||||
.getCount();
|
||||
if (result < 1) {
|
||||
throw new ApiError(
|
||||
@@ -254,16 +251,58 @@ export class UsersService {
|
||||
return this.withAdminLock(async (manager) => {
|
||||
const user = await this.getWithRoles(userId, manager);
|
||||
const previousRoleNames = new Set(user.roles.map((role) => role.name));
|
||||
const previousRoleIds = new Set(user.roles.map((role) => role.id));
|
||||
const roles = await Promise.all(
|
||||
roleIds.map((id) => this.roles.getRole(id, manager)),
|
||||
const previouslyAdmin = user.roles.some(
|
||||
(role) => role.roleKey === adminRoleKey,
|
||||
);
|
||||
const nextRoleNames = new Set(roles.map((role) => role.name));
|
||||
user.roles = roles;
|
||||
if (previousRoleNames.has('admin') && !nextRoleNames.has('admin')) {
|
||||
const assignmentRepo = manager.getRepository(UserRoleAssignmentEntity);
|
||||
const previousAssignments = await assignmentRepo.findBy({
|
||||
userId,
|
||||
source: UserRoleAssignmentSource.Manual,
|
||||
});
|
||||
const previousRoleIds = new Set(
|
||||
previousAssignments.map((entry) => entry.roleId),
|
||||
);
|
||||
const roles = await Promise.all(
|
||||
[...new Set(roleIds)].map((id) => this.roles.getRole(id, manager)),
|
||||
);
|
||||
const nextManualIds = new Set(roles.map((role) => role.id));
|
||||
for (const assignment of previousAssignments) {
|
||||
if (!nextManualIds.has(assignment.roleId))
|
||||
await assignmentRepo.remove(assignment);
|
||||
}
|
||||
for (const role of roles) {
|
||||
if (!previousRoleIds.has(role.id)) {
|
||||
await assignmentRepo.insert({
|
||||
userId,
|
||||
roleId: role.id,
|
||||
source: UserRoleAssignmentSource.Manual,
|
||||
lastSynchronizedAt: null,
|
||||
});
|
||||
}
|
||||
}
|
||||
const effectiveRows = await assignmentRepo
|
||||
.createQueryBuilder('assignment')
|
||||
.select('DISTINCT assignment.roleId', 'roleId')
|
||||
.where('assignment.userId = :userId', { userId })
|
||||
.getRawMany<{ roleId: string }>();
|
||||
const effectiveRoles = await Promise.all(
|
||||
effectiveRows.map((entry) => this.roles.getRole(entry.roleId, manager)),
|
||||
);
|
||||
const nextRoleNames = new Set(effectiveRoles.map((role) => role.name));
|
||||
if (
|
||||
previouslyAdmin &&
|
||||
!effectiveRoles.some((role) => role.roleKey === adminRoleKey)
|
||||
) {
|
||||
await this.assertAnotherActiveAdminRemains(userId, manager);
|
||||
}
|
||||
const saved = await this.users.save(user, manager);
|
||||
await manager.query('DELETE FROM user_roles WHERE user_id = ?', [userId]);
|
||||
for (const role of effectiveRoles) {
|
||||
await manager.query(
|
||||
'INSERT INTO user_roles (user_id, role_id) VALUES (?, ?)',
|
||||
[userId, role.id],
|
||||
);
|
||||
}
|
||||
const saved = await this.getWithRoles(user.id, manager);
|
||||
await this.recordRoleAudit(actor, userId, previousRoleIds, roles);
|
||||
await this.notifyRoleChanges(userId, previousRoleNames, nextRoleNames);
|
||||
return saved;
|
||||
@@ -333,7 +372,10 @@ export class UsersService {
|
||||
});
|
||||
}
|
||||
|
||||
private toAdminListItem(user: UserEntity): AdminUserListItem {
|
||||
private toAdminListItem(
|
||||
user: UserEntity,
|
||||
sources: Map<string, UserRoleAssignmentSource[]>,
|
||||
): AdminUserListItem {
|
||||
return {
|
||||
id: user.id,
|
||||
name: user.name,
|
||||
@@ -344,6 +386,7 @@ export class UsersService {
|
||||
name: role.name,
|
||||
description: role.description,
|
||||
system: role.protected,
|
||||
sources: sources.get(role.id) ?? [],
|
||||
})),
|
||||
lastLoginAt: user.lastLoginAt?.toISOString() ?? null,
|
||||
createdAt: user.createdAt.toISOString(),
|
||||
@@ -351,6 +394,34 @@ export class UsersService {
|
||||
};
|
||||
}
|
||||
|
||||
private async manualRoleIds(userId: string): Promise<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[] {
|
||||
return Array.from(
|
||||
new Set(
|
||||
@@ -361,8 +432,8 @@ export class UsersService {
|
||||
).sort();
|
||||
}
|
||||
|
||||
private hasRole(user: UserEntity, roleName: string): boolean {
|
||||
return user.roles.some((role) => role.name === roleName);
|
||||
private hasRole(user: UserEntity, roleKey: string): boolean {
|
||||
return user.roles.some((role) => role.roleKey === roleKey);
|
||||
}
|
||||
|
||||
private async notifyRoleChanges(
|
||||
|
||||
Reference in New Issue
Block a user