feat: Phase 4 – Modul-Gateway mit dynamischem Routing und Startup-Recovery
This commit is contained in:
@@ -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();
|
||||
});
|
||||
});
|
||||
128
apps/platform-backend/src/modules/module-gateway.middleware.ts
Normal file
128
apps/platform-backend/src/modules/module-gateway.middleware.ts
Normal 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}`,
|
||||
});
|
||||
}
|
||||
}
|
||||
55
apps/platform-backend/src/modules/module-startup-recovery.ts
Normal file
55
apps/platform-backend/src/modules/module-startup-recovery.ts
Normal 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),
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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 });
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user