feat: Phase 4 – Modul-Gateway mit dynamischem Routing und Startup-Recovery

This commit is contained in:
MPM Dev
2026-10-07 15:59:56 +02:00
parent 4bbca21fd6
commit 5d775c317a
9 changed files with 537 additions and 10 deletions

View File

@@ -0,0 +1,224 @@
import type { Request, Response, NextFunction } from 'express';
import { SessionService } from '../auth/session.service';
import type { SessionData } from '../auth/session.service';
import { UserRepository } from '../users/user.repository';
import type { UserRecord } from '../users/user.types';
import { ModuleRepository } from './module.repository';
import type { ModuleRecord } from './manifest.types';
import { ModuleGatewayMiddleware } from './module-gateway.middleware';
/** Erzeugt einen Modul-Datensatz für Tests. */
function createModuleRecord(overrides: Partial<ModuleRecord> = {}): ModuleRecord {
return {
id: 'module-1',
moduleId: 'demo',
name: 'Demo-Modul',
slug: 'demo',
version: '1.0.0',
description: '',
author: '',
path: '/data/modules/demo',
status: 'RUNNING',
internalPort: 41001,
healthcheckUrl: '/health',
enabled: true,
createdAt: new Date(),
updatedAt: new Date(),
...overrides,
};
}
/** Erzeugt einen Benutzer-Datensatz für Tests. */
function createUserRecord(overrides: Partial<UserRecord> = {}): UserRecord {
return {
id: 'user-1',
username: 'max',
email: 'max@example.com',
passwordHash: 'not-a-real-hash',
displayName: 'Max Mustermann',
role: 'USER',
isActive: true,
failedLoginAttempts: 0,
lockedUntil: null,
lastLoginAt: null,
createdAt: new Date(),
updatedAt: new Date(),
...overrides,
};
}
/** Mock-Request für Gateway-Tests. */
function createRequest(url: string, cookie?: string): Request {
return {
url,
headers: cookie ? { cookie } : {},
} as unknown as Request;
}
/** Mock-Response, der Status und JSON-Antworten aufzeichnet. */
function createResponse(): Response & { sentStatus: number; sentBody: unknown } {
const response = {
sentStatus: 0,
sentBody: undefined as unknown,
status(code: number) {
this.sentStatus = code;
return this;
},
json(body: unknown) {
this.sentBody = body;
return this;
},
};
return response as Response & { sentStatus: number; sentBody: unknown };
}
/** Mock des SessionService. */
class MockSessionService {
public session: SessionData | null = null;
async findValid(): Promise<SessionData | null> {
return this.session;
}
}
/** Mock des UserRepository. */
class MockUserRepository {
public user: UserRecord | null = createUserRecord();
async findById(): Promise<UserRecord | null> {
return this.user;
}
}
/** Mock des ModuleRepository. */
class MockModuleRepository {
public module: ModuleRecord | null = createModuleRecord();
async findBySlug(): Promise<ModuleRecord | null> {
return this.module;
}
}
describe('ModuleGatewayMiddleware', () => {
let sessionService: MockSessionService;
let userRepository: MockUserRepository;
let moduleRepository: MockModuleRepository;
let middleware: ModuleGatewayMiddleware;
const nextCalls: NextFunction[] = [];
function makeNext(): NextFunction {
const next = jest.fn() as unknown as NextFunction;
nextCalls.push(next);
return next;
}
beforeEach(() => {
sessionService = new MockSessionService();
userRepository = new MockUserRepository();
moduleRepository = new MockModuleRepository();
middleware = new ModuleGatewayMiddleware(
moduleRepository as unknown as ModuleRepository,
sessionService as unknown as SessionService,
userRepository as unknown as UserRepository,
);
sessionService.session = {
id: 'session-1',
userId: 'user-1',
csrfToken: 'csrf-token',
expiresAt: new Date(Date.now() + 60_000),
};
});
it('reicht Nicht-Gateway-Pfade an next() weiter', async () => {
const request = createRequest('/api/v1/users');
const response = createResponse();
const next = makeNext();
await middleware.use(request, response, next);
expect(next).toHaveBeenCalled();
});
it('antwortet 401 ohne Session-Cookie', async () => {
const request = createRequest('/api/v1/gateway/demo/');
const response = createResponse();
await middleware.use(request, response, makeNext());
expect(response.sentStatus).toBe(401);
});
it('antwortet 401 bei ungültiger Session', async () => {
sessionService.session = null;
const request = createRequest('/api/v1/gateway/demo/', 'mpm_session=invalid');
const response = createResponse();
await middleware.use(request, response, makeNext());
expect(response.sentStatus).toBe(401);
});
it('antwortet 401 bei deaktiviertem Benutzer', async () => {
userRepository.user = createUserRecord({ isActive: false });
const request = createRequest('/api/v1/gateway/demo/', 'mpm_session=valid');
const response = createResponse();
await middleware.use(request, response, makeNext());
expect(response.sentStatus).toBe(401);
});
it('antwortet 404 bei unbekanntem Modul-Slug', async () => {
moduleRepository.module = null;
const request = createRequest('/api/v1/gateway/demo/', 'mpm_session=valid');
const response = createResponse();
await middleware.use(request, response, makeNext());
expect(response.sentStatus).toBe(404);
});
it('antwortet 503 bei gestopptem Modul', async () => {
moduleRepository.module = createModuleRecord({ status: 'STOPPED' });
const request = createRequest('/api/v1/gateway/demo/', 'mpm_session=valid');
const response = createResponse();
await middleware.use(request, response, makeNext());
expect(response.sentStatus).toBe(503);
});
it('antwortet 503 bei deaktiviertem Modul', async () => {
moduleRepository.module = createModuleRecord({ enabled: false });
const request = createRequest('/api/v1/gateway/demo/', 'mpm_session=valid');
const response = createResponse();
await middleware.use(request, response, makeNext());
expect(response.sentStatus).toBe(503);
});
it('antwortet 403 für USER ohne Berechtigung (fail-closed)', async () => {
const request = createRequest('/api/v1/gateway/demo/', 'mpm_session=valid');
const response = createResponse();
await middleware.use(request, response, makeNext());
expect(response.sentStatus).toBe(403);
});
it('leitet ADMIN-Requests an den Modul-Proxy weiter', async () => {
userRepository.user = createUserRecord({ role: 'ADMIN' });
const request = createRequest('/api/v1/gateway/demo/health', 'mpm_session=valid');
const response = createResponse();
// proxy.web würde einen echten Request starten – hier nur prüfen,
// dass die Identitäts-Header gesetzt und das Cookie entfernt wurde.
const proxySpy = jest
.spyOn(middleware['proxy'], 'web')
.mockImplementation(() => undefined);
await middleware.use(request, response, makeNext());
expect(proxySpy).toHaveBeenCalled();
expect(request.headers['x-user-id']).toBe('user-1');
expect(request.headers['x-user-username']).toBe('max');
expect(request.headers['x-user-role']).toBe('ADMIN');
expect(request.headers.cookie).toBeUndefined();
expect(request.url).toBe('/health');
proxySpy.mockRestore();
});
});

View File

@@ -0,0 +1,128 @@
import { Injectable, type NestMiddleware } from '@nestjs/common';
import type { Request, Response, NextFunction } from 'express';
import httpProxy from 'http-proxy';
import { SessionService } from '../auth/session.service';
import { extractSessionToken } from '../auth/guards/session.guard';
import { UserRepository } from '../users/user.repository';
import { ModuleRepository } from './module.repository';
/** Gateway-Pfad-Präfix für interne Nginx-Weiterleitung. */
const GATEWAY_PREFIX = '/api/v1/gateway/';
/**
* Modul-Gateway (Phase 4): Dynamisches Routing /slug → Modul-Prozess.
*
* Sicherheitskritischer Request-Flow (Nginx leitet /slug intern auf
* /api/v1/gateway/slug/* um):
* 1. Session prüfen (401 ohne Login)
* 2. Modul anhand Slug suchen (404)
* 3. Modul muss RUNNING und enabled sein (503)
* 4. Permission-Check: ADMIN darf alles, USER nur freigegebene
* Module (403) – bis Phase 5 fail-closed für USERs
* 5. Proxy zum internen Port (nie öffentlich erreichbar)
*
* Das Modul selbst vertraut nie allein auf die URL – die Plattform
* identifiziert den Benutzer zentral und übergibt die Identität
* über interne Header (Session-Cookie wird nicht weitergereicht).
*/
@Injectable()
export class ModuleGatewayMiddleware implements NestMiddleware {
private readonly proxy: httpProxy;
constructor(
private readonly moduleRepository: ModuleRepository,
private readonly sessionService: SessionService,
private readonly userRepository: UserRepository,
) {
this.proxy = httpProxy.createProxyServer({
proxyTimeout: 30_000,
timeout: 30_000,
});
this.proxy.on('error', (error, _req, res) => {
console.error(`[ModuleGateway] Proxy-Fehler: ${error.message}`);
const response = res as Response;
if (!response.headersSent) {
response.status(502).json({
statusCode: 502,
message: 'Modul ist nicht erreichbar',
});
} else {
response.end();
}
});
}
async use(request: Request, response: Response, next: NextFunction): Promise<void> {
if (!request.url.startsWith(GATEWAY_PREFIX)) {
next();
return;
}
// Slug aus dem Gateway-Pfad extrahieren: /api/v1/gateway/<slug>/<rest>
const pathAfterPrefix = request.url.slice(GATEWAY_PREFIX.length);
const slashIndex = pathAfterPrefix.indexOf('/');
const slug = slashIndex === -1 ? pathAfterPrefix : pathAfterPrefix.slice(0, slashIndex);
const modulePath = slashIndex === -1 ? '/' : pathAfterPrefix.slice(slashIndex);
// 1. Authentifizierung: Session aus Cookie laden
const token = extractSessionToken(request);
if (!token) {
response.status(401).json({ statusCode: 401, message: 'Nicht authentifiziert' });
return;
}
const session = await this.sessionService.findValid(token);
if (!session) {
response.status(401).json({ statusCode: 401, message: 'Nicht authentifiziert' });
return;
}
const user = await this.userRepository.findById(session.userId);
if (!user || !user.isActive) {
response.status(401).json({ statusCode: 401, message: 'Nicht authentifiziert' });
return;
}
// 2. Modul anhand Slug suchen
const module = await this.moduleRepository.findBySlug(slug);
if (!module) {
response.status(404).json({ statusCode: 404, message: 'Modul nicht gefunden' });
return;
}
// 3. Modul-Zustand prüfen
if (!module.enabled || module.status !== 'RUNNING') {
response.status(503).json({
statusCode: 503,
message: 'Modul ist nicht verfügbar',
});
return;
}
// 4. Permission-Check (Ebene 2 – Modul-Rechte)
// ADMIN: voller Zugriff. USER: nur mit GRANTED-Berechtigung
// (user_module_permissions folgt in Phase 5; bis dahin
// fail-closed – USERs erhalten keinen Zugriff).
if (user.role !== 'ADMIN') {
response.status(403).json({
statusCode: 403,
message: 'Keine Berechtigung für dieses Modul',
});
return;
}
// 5. Identität sicher an das Modul übergeben (Header, nicht URL)
request.headers['x-user-id'] = user.id;
request.headers['x-user-username'] = user.username;
request.headers['x-user-display-name'] = user.displayName;
request.headers['x-user-role'] = user.role;
// Session-Cookie niemals an das Modul weiterleiten
delete request.headers.cookie;
request.url = modulePath;
this.proxy.web(request, response, {
target: `http://127.0.0.1:${module.internalPort}`,
});
}
}

View File

@@ -0,0 +1,55 @@
import { Injectable, Logger, type OnApplicationBootstrap } from '@nestjs/common';
import { ModuleProcessManager } from './module-process-manager';
import { ModuleRepository } from './module.repository';
/**
* Start-Wiederherstellung (Phase 4):
* Nach einem Container-Neustart sind alle Modul-Prozesse weg, während
* die Datenbank noch alte Status (z. B. RUNNING) enthält.
*
* Diese Komponente synchronisiert beim Backend-Start:
* - Module mit Status RUNNING/STARTING/STOPPING werden auf STOPPED
* zurückgesetzt (Prozesse existieren nach Neustart nicht mehr).
* - Aktivierte Module, die vorher liefen, werden automatisch neu
* gestartet (Autostart).
*/
@Injectable()
export class ModuleStartupRecovery implements OnApplicationBootstrap {
private readonly logger = new Logger('ModuleRecovery');
constructor(
private readonly moduleRepository: ModuleRepository,
private readonly processManager: ModuleProcessManager,
) {}
async onApplicationBootstrap(): Promise<void> {
await this.recover();
}
/** Setzt veraltete Status zurück und startet Module ggf. neu. */
async recover(): Promise<void> {
const staleStatuses = ['RUNNING', 'STARTING', 'STOPPING'] as const;
const staleModules = await this.moduleRepository.findByStatus([...staleStatuses]);
for (const module of staleModules) {
await this.moduleRepository.updateStatus(module.id, 'STOPPED');
this.logger.log(`Status von "${module.moduleId}" nach Neustart auf STOPPED zurückgesetzt`);
}
// Autostart: Aktivierte Module, die vor dem Neustart liefen, neu starten.
const restartable = staleModules.filter((module) => module.enabled);
for (const module of restartable) {
try {
await this.processManager.start(module);
await this.moduleRepository.updateStatus(module.id, 'RUNNING');
this.logger.log(`Modul "${module.moduleId}" automatisch neu gestartet`);
} catch (error) {
await this.moduleRepository.updateStatus(module.id, 'ERROR');
this.logger.error(
`Autostart von "${module.moduleId}" fehlgeschlagen`,
error instanceof Error ? error.stack : String(error),
);
}
}
}
}

View File

@@ -1,19 +1,47 @@
import { Module } from '@nestjs/common';
import {
MiddlewareConsumer,
Module,
NestModule,
RequestMethod,
} from '@nestjs/common';
import { ConfigModule } from '../config/config.module';
import { DatabaseModule } from '../database/database.module';
import { AuditModule } from '../audit/audit.module';
import { SessionService } from '../auth/session.service';
import { PasswordHasher } from '../users/password-hasher';
import { UserRepository } from '../users/user.repository';
import { ModuleHealthChecker } from './module-health-checker';
import { ModuleInstaller } from './module-installer';
import { ModuleProcessManager } from './module-process-manager';
import { ModuleGatewayMiddleware } from './module-gateway.middleware';
import { ModuleRepository } from './module.repository';
import { ModuleStartupRecovery } from './module-startup-recovery';
import { ModulesController } from './modules.controller';
import { ModulesService } from './modules.service';
/** Modul-System: Installation, Lifecycle, Prozessverwaltung, Healthchecks. */
/** Modul-System: Installation, Lifecycle, Prozessverwaltung, Gateway. */
@Module({
imports: [ConfigModule, DatabaseModule, AuditModule],
controllers: [ModulesController],
providers: [ModuleRepository, ModuleInstaller, ModuleProcessManager, ModuleHealthChecker, ModulesService],
providers: [
ModuleRepository,
ModuleInstaller,
ModuleProcessManager,
ModuleHealthChecker,
ModulesService,
SessionService,
UserRepository,
PasswordHasher,
ModuleGatewayMiddleware,
ModuleStartupRecovery,
],
exports: [ModuleRepository, ModulesService],
})
export class ModulesModule {}
export class ModulesModule implements NestModule {
/** Gateway-Middleware für alle /api/v1/gateway/* Pfade. */
configure(consumer: MiddlewareConsumer): void {
consumer
.apply(ModuleGatewayMiddleware)
.forRoutes({ path: '/api/v1/gateway/(.*)', method: RequestMethod.ALL });
}
}