feat: Phase 9 – Administration (Audit-UI, Systemeinstellungen, erweiterter Systemstatus)

This commit is contained in:
MPM Dev
2026-10-07 16:40:00 +02:00
parent e6219e8cf9
commit 564f7def1c
21 changed files with 891 additions and 71 deletions

View File

@@ -8,6 +8,7 @@ import { AuthModule } from './auth/auth.module';
import { UsersModule } from './users/users.module';
import { HealthModule } from './health/health.module';
import { ModulesModule } from './modules/modules.module';
import { SettingsModule } from './settings/settings.module';
import { SessionGuard } from './auth/guards/session.guard';
import { CsrfGuard } from './auth/guards/csrf.guard';
import { RolesGuard } from './common/guards/roles.guard';
@@ -25,6 +26,7 @@ import { RolesGuard } from './common/guards/roles.guard';
UsersModule,
HealthModule,
ModulesModule,
SettingsModule,
],
providers: [
MigrationRunner,

View File

@@ -0,0 +1,99 @@
import { Injectable } from '@nestjs/common';
import { DatabaseService } from '../database/database.service';
/** Audit-Log-Eintrag für API-Antworten. */
export interface AuditLogEntry {
readonly id: number;
readonly username: string;
readonly action: string;
readonly details: Record<string, unknown>;
readonly ipAddress: string | null;
readonly createdAt: string;
}
interface AuditRow {
id: number;
username: string;
action: string;
details: Record<string, unknown>;
ip_address: string | null;
created_at: Date;
}
/** Filter-Parameter für die Audit-Liste. */
export interface AuditListFilter {
readonly action?: string;
readonly username?: string;
readonly limit: number;
readonly offset: number;
}
/**
* Audit-Log-Abfragen (Infrastructure): Durchsuchen und Filtern des
* zentralen Audit-Logs (nur für Administratoren).
*/
@Injectable()
export class AuditQueryRepository {
constructor(private readonly database: DatabaseService) {}
/** Listet Audit-Einträge mit optionalen Filtern (neueste zuerst). */
async list(filter: AuditListFilter): Promise<AuditLogEntry[]> {
const conditions: string[] = [];
const params: unknown[] = [];
if (filter.action) {
params.push(filter.action);
conditions.push(`action = $${params.length}`);
}
if (filter.username) {
params.push(`%${filter.username}%`);
conditions.push(`username ILIKE $${params.length}`);
}
const whereClause = conditions.length > 0 ? `WHERE ${conditions.join(' AND ')}` : '';
params.push(filter.limit);
const limitClause = `LIMIT $${params.length}`;
params.push(filter.offset);
const offsetClause = `OFFSET $${params.length}`;
const result = await this.database.query<AuditRow>(
`SELECT id, username, action, details, ip_address, created_at
FROM audit_logs
${whereClause}
ORDER BY created_at DESC
${limitClause} ${offsetClause}`,
params,
);
return result.rows.map((row) => ({
id: row.id,
username: row.username,
action: row.action,
details: row.details ?? {},
ipAddress: row.ip_address,
createdAt: row.created_at.toISOString(),
}));
}
/** Gesamtzahl der Einträge (für Paginierung). */
async count(filter: Pick<AuditListFilter, 'action' | 'username'>): Promise<number> {
const conditions: string[] = [];
const params: unknown[] = [];
if (filter.action) {
params.push(filter.action);
conditions.push(`action = $${params.length}`);
}
if (filter.username) {
params.push(`%${filter.username}%`);
conditions.push(`username ILIKE $${params.length}`);
}
const whereClause = conditions.length > 0 ? `WHERE ${conditions.join(' AND ')}` : '';
const result = await this.database.query<{ count: number }>(
`SELECT count(*)::int AS count FROM audit_logs ${whereClause}`,
params,
);
return result.rows[0]?.count ?? 0;
}
}

View File

@@ -0,0 +1,38 @@
import { Controller, Get, Query } from '@nestjs/common';
import { ApiTags } from '@nestjs/swagger';
import { Roles } from '../common/decorators/roles.decorator';
import { ZodValidationPipe } from '../common/zod-validation.pipe';
import { z } from 'zod';
import type { AuditLogEntry } from './audit-query.repository';
import { AuditQueryRepository } from './audit-query.repository';
/** Abfrage-Parameter für die Audit-Liste (Zod-Validierung). */
export const auditListQuerySchema = z.object({
action: z.string().trim().max(100).optional(),
username: z.string().trim().max(100).optional(),
limit: z.coerce.number().int().min(1).max(100).default(50),
offset: z.coerce.number().int().min(0).default(0),
});
type AuditListQuery = z.infer<typeof auditListQuerySchema>;
/**
* Audit-Log (nur Administratoren): Durchsuchen und Filtern der
* sicherheitsrelevanten Ereignisse der Plattform.
*/
@ApiTags('Audit')
@Roles('ADMIN')
@Controller({ path: 'api/v1/audit' })
export class AuditController {
constructor(private readonly auditQueryRepository: AuditQueryRepository) {}
@Get()
async list(
@Query(new ZodValidationPipe(auditListQuerySchema)) query: AuditListQuery,
): Promise<{ entries: AuditLogEntry[]; total: number }> {
const [entries, total] = await Promise.all([
this.auditQueryRepository.list(query),
this.auditQueryRepository.count(query),
]);
return { entries, total };
}
}

View File

@@ -1,12 +1,15 @@
import { Global, Module } from '@nestjs/common';
import { DatabaseModule } from '../database/database.module';
import { AuditController } from './audit.controller';
import { AuditQueryRepository } from './audit-query.repository';
import { AuditService } from './audit.service';
/** Global verfügbares Audit-Logging. */
/** Global verfügbares Audit-Logging mit Admin-Abfragen. */
@Global()
@Module({
imports: [DatabaseModule],
providers: [AuditService],
exports: [AuditService],
controllers: [AuditController],
providers: [AuditService, AuditQueryRepository],
exports: [AuditService, AuditQueryRepository],
})
export class AuditModule {}

View File

@@ -23,6 +23,7 @@ export const AUDIT_ACTIONS = {
MODULE_DISABLED: 'MODULE_DISABLED',
PERMISSION_GRANTED: 'PERMISSION_GRANTED',
PERMISSION_REVOKED: 'PERMISSION_REVOKED',
SETTINGS_CHANGED: 'SETTINGS_CHANGED',
} as const;
export type AuditAction = (typeof AUDIT_ACTIONS)[keyof typeof AUDIT_ACTIONS];

View File

@@ -1,10 +1,12 @@
import { migration001CoreSchema } from './001-core-schema';
import { migration002Modules } from '../../modules/migrations/002-modules';
import { migration003ModulePermissions } from '../../modules/migrations/003-module-permissions';
import { migration004SystemSettings } from '../../settings/migrations/004-system-settings';
/** Registrierte Migrationen in aufsteigender Reihenfolge. */
export const MIGRATIONS = [
migration001CoreSchema,
migration002Modules,
migration003ModulePermissions,
migration004SystemSettings,
];

View File

@@ -1,10 +1,13 @@
import { Module } from '@nestjs/common';
import { DatabaseModule } from '../database/database.module';
import { AuditModule } from '../audit/audit.module';
import { ModulesModule } from '../modules/modules.module';
import { HealthController } from './health.controller';
import { SystemStatusController } from './system-status.controller';
/** Health-Endpoints (Monitoring). */
@Module({
imports: [DatabaseModule],
controllers: [HealthController],
imports: [DatabaseModule, AuditModule, ModulesModule],
controllers: [HealthController, SystemStatusController],
})
export class HealthModule {}

View File

@@ -0,0 +1,96 @@
import { Controller, Get } from '@nestjs/common';
import { ApiTags } from '@nestjs/swagger';
import { Roles } from '../common/decorators/roles.decorator';
import { DatabaseService } from '../database/database.service';
import { ModuleRepository } from '../modules/module.repository';
import { ModuleHealthChecker } from '../modules/module-health-checker';
import type { ModuleRecord } from '../modules/manifest.types';
/** Komponenten-Status für das Admin-Dashboard. */
export interface ComponentStatus {
readonly name: string;
readonly status: 'HEALTHY' | 'DEGRADED' | 'UNHEALTHY' | 'STOPPED';
readonly detail: string;
}
/** Erweiterter Systemstatus (Phase 9). */
export interface SystemStatusResponse {
readonly overall: 'HEALTHY' | 'DEGRADED' | 'UNHEALTHY';
readonly components: readonly ComponentStatus[];
}
/**
* Systemstatus (nur Administratoren): Zustand aller Plattform-Komponenten
* inklusive aller installierten Module.
*/
@ApiTags('System')
@Roles('ADMIN')
@Controller({ path: 'api/v1/system/status' })
export class SystemStatusController {
constructor(
private readonly database: DatabaseService,
private readonly moduleRepository: ModuleRepository,
private readonly healthChecker: ModuleHealthChecker,
) {}
@Get()
async getStatus(): Promise<SystemStatusResponse> {
const components: ComponentStatus[] = [];
// Management-Backend (dieser Prozess läuft, wenn die Antwort entsteht)
components.push({
name: 'Management',
status: 'HEALTHY',
detail: 'Backend erreichbar',
});
// PostgreSQL
try {
const latencyMs = Math.round(await this.database.ping());
components.push({
name: 'Datenbank',
status: latencyMs < 500 ? 'HEALTHY' : 'DEGRADED',
detail: `${latencyMs} ms`,
});
} catch {
components.push({
name: 'Datenbank',
status: 'UNHEALTHY',
detail: 'nicht erreichbar',
});
}
// Module: Healthchecks parallel, Fehler isoliert pro Modul
const modules = await this.moduleRepository.list();
const moduleHealths = await Promise.all(
modules.map(async (module): Promise<{ module: ModuleRecord; healthy: boolean; detail: string }> => {
if (module.status !== 'RUNNING') {
return { module, healthy: false, detail: module.status };
}
const health = await this.healthChecker.check(module);
return { module, healthy: health.healthy, detail: health.detail };
}),
);
for (const { module, healthy, detail } of moduleHealths) {
components.push({
name: module.name,
status: !module.enabled
? 'STOPPED'
: healthy
? 'HEALTHY'
: module.status === 'RUNNING'
? 'UNHEALTHY'
: 'STOPPED',
detail: `${module.status} · ${detail}`,
});
}
// Gesamtstatus: schlechtester Komponenten-Status
const hasUnhealthy = components.some((component) => component.status === 'UNHEALTHY');
const hasDegraded = components.some((component) => component.status === 'DEGRADED');
const overall = hasUnhealthy ? 'UNHEALTHY' : hasDegraded ? 'DEGRADED' : 'HEALTHY';
return { overall, components };
}
}

View File

@@ -40,7 +40,7 @@ import { ModulesService } from './modules.service';
ModulePermissionRepository,
ModulePermissionsService,
],
exports: [ModuleRepository, ModulesService, ModulePermissionsService],
exports: [ModuleRepository, ModulesService, ModulePermissionsService, ModuleHealthChecker],
})
export class ModulesModule implements NestModule {
/** Gateway-Middleware für alle /api/v1/gateway/* Pfade. */

View File

@@ -0,0 +1,17 @@
import type { Migration } from '../../database/migration.types';
/** Phase 9: Systemeinstellungen der Plattform. */
export const migration004SystemSettings: Migration = {
id: '004-system-settings',
description: 'Systemeinstellungen (system_settings) anlegen',
up: async (client) => {
await client.query(`
CREATE TABLE system_settings (
key TEXT PRIMARY KEY,
value TEXT NOT NULL,
updated_at TIMESTAMPTZ NOT NULL DEFAULT now(),
updated_by TEXT
)
`);
},
};

View File

@@ -0,0 +1,44 @@
import { Body, Controller, Get, Param, Patch, Req } from '@nestjs/common';
import type { Request } from 'express';
import { ApiTags } from '@nestjs/swagger';
import type { AuthenticatedRequest } from '../auth/authenticated-request';
import { CurrentUser } from '../common/decorators/current-user.decorator';
import { Roles } from '../common/decorators/roles.decorator';
import { ZodValidationPipe } from '../common/zod-validation.pipe';
import { z } from 'zod';
import type { AuthUser } from '../users/user.types';
import type { SettingResponse } from './settings.service';
import { SettingsService } from './settings.service';
/** Einstellung aktualisieren (Zod-Validierung). */
export const updateSettingSchema = z.object({
value: z.string().min(1, 'Wert ist erforderlich').max(500),
});
type UpdateSettingDto = z.infer<typeof updateSettingSchema>;
/**
* Systemeinstellungen (nur Administratoren).
*/
@ApiTags('Settings')
@Roles('ADMIN')
@Controller({ path: 'api/v1/settings' })
export class SettingsController {
constructor(private readonly settingsService: SettingsService) {}
@Get()
async list(): Promise<{ settings: SettingResponse[] }> {
const settings = await this.settingsService.list();
return { settings };
}
@Patch(':key')
async update(
@Param('key') key: string,
@Body(new ZodValidationPipe(updateSettingSchema)) body: UpdateSettingDto,
@CurrentUser() actor: AuthUser,
@Req() request: AuthenticatedRequest & Request,
): Promise<{ setting: SettingResponse }> {
const setting = await this.settingsService.set(key, body.value, actor, request.ip ?? null);
return { setting };
}
}

View File

@@ -0,0 +1,15 @@
import { Module } from '@nestjs/common';
import { DatabaseModule } from '../database/database.module';
import { AuditModule } from '../audit/audit.module';
import { SettingsController } from './settings.controller';
import { SettingsRepository } from './settings.repository';
import { SettingsService } from './settings.service';
/** Systemeinstellungen (Phase 9). */
@Module({
imports: [DatabaseModule, AuditModule],
controllers: [SettingsController],
providers: [SettingsRepository, SettingsService],
exports: [SettingsService],
})
export class SettingsModule {}

View File

@@ -0,0 +1,78 @@
import { Injectable, NotFoundException } from '@nestjs/common';
import { DatabaseService } from '../database/database.service';
/** Systemeinstellung-Datensatz. */
export interface SystemSettingRecord {
readonly key: string;
readonly value: string;
readonly updatedAt: Date;
readonly updatedBy: string | null;
}
interface SettingRow {
key: string;
value: string;
updated_at: Date;
updated_by: string | null;
}
/** Erlaubte Einstellungs-Schlüssel (Whitelist, verhindert Magic Strings). */
export const SETTING_KEYS = {
PLATFORM_NAME: 'platform.name',
PLATFORM_DESCRIPTION: 'platform.description',
MAINTENANCE_MODE: 'platform.maintenance_mode',
} as const;
export type SettingKey = (typeof SETTING_KEYS)[keyof typeof SETTING_KEYS];
/**
* Systemeinstellungen (Infrastructure): Schlüssel-Werte-Store für
* Plattform-Einstellungen. Nur whitelisted Schlüssel sind schreibbar.
*/
@Injectable()
export class SettingsRepository {
constructor(private readonly database: DatabaseService) {}
async list(): Promise<SystemSettingRecord[]> {
const result = await this.database.query<SettingRow>(
'SELECT key, value, updated_at, updated_by FROM system_settings ORDER BY key ASC',
);
return result.rows.map((row) => ({
key: row.key,
value: row.value,
updatedAt: row.updated_at,
updatedBy: row.updated_by,
}));
}
async get(key: string): Promise<SystemSettingRecord | null> {
const result = await this.database.query<SettingRow>(
'SELECT key, value, updated_at, updated_by FROM system_settings WHERE key = $1',
[key],
);
const row = result.rows[0];
return row ? { key: row.key, value: row.value, updatedAt: row.updated_at, updatedBy: row.updated_by } : null;
}
/** Setzt oder aktualisiert eine Einstellung (Upsert). */
async set(key: string, value: string, updatedBy: string): Promise<SystemSettingRecord> {
const result = await this.database.query<SettingRow>(
`INSERT INTO system_settings (key, value, updated_by)
VALUES ($1, $2, $3)
ON CONFLICT (key)
DO UPDATE SET value = $2, updated_at = now(), updated_by = $3
RETURNING key, value, updated_at, updated_by`,
[key, value, updatedBy],
);
const row = result.rows[0];
return { key: row.key, value: row.value, updatedAt: row.updated_at, updatedBy: row.updated_by };
}
/** Liest eine Einstellung oder wirft NotFoundException. */
async getRequired(key: string): Promise<SystemSettingRecord> {
const setting = await this.get(key);
if (!setting) {
throw new NotFoundException(`Einstellung "${key}" nicht gefunden`);
}
return setting;
}
}

View File

@@ -0,0 +1,60 @@
import { BadRequestException, Injectable } from '@nestjs/common';
import { AUDIT_ACTIONS, AuditService } from '../audit/audit.service';
import type { ActingUser } from '../users/users.service';
import { SETTING_KEYS, SettingsRepository, type SystemSettingRecord } from './settings.repository';
/** Öffentliche Einstellungs-Daten in API-Antworten. */
export interface SettingResponse {
readonly key: string;
readonly value: string;
readonly updatedAt: string;
readonly updatedBy: string | null;
}
function toSettingResponse(setting: SystemSettingRecord): SettingResponse {
return {
key: setting.key,
value: setting.value,
updatedAt: setting.updatedAt.toISOString(),
updatedBy: setting.updatedBy,
};
}
/**
* Systemeinstellungen (Application-Layer): Lesen und Schreiben von
* Plattform-Einstellungen mit Whitelist-Prüfung und Audit-Logging.
*/
@Injectable()
export class SettingsService {
constructor(
private readonly settingsRepository: SettingsRepository,
private readonly auditService: AuditService,
) {}
async list(): Promise<SettingResponse[]> {
const settings = await this.settingsRepository.list();
return settings.map(toSettingResponse);
}
/** Aktualisiert eine Einstellung (nur whitelisted Schlüssel). */
async set(
key: string,
value: string,
actor: ActingUser,
ipAddress: string | null,
): Promise<SettingResponse> {
if (!Object.values(SETTING_KEYS).includes(key as (typeof SETTING_KEYS)[keyof typeof SETTING_KEYS])) {
throw new BadRequestException(`Unbekannter Einstellungs-Schlüssel: ${key}`);
}
const setting = await this.settingsRepository.set(key, value, actor.username);
await this.auditService.record({
userId: actor.id,
username: actor.username,
action: AUDIT_ACTIONS.SETTINGS_CHANGED,
details: { key },
ipAddress,
});
return toSettingResponse(setting);
}
}