diff --git a/README.md b/README.md index 7213882..823978c 100644 --- a/README.md +++ b/README.md @@ -2,7 +2,7 @@ Zentrale, webbasierte Management-Plattform, über die eigenständige Web-Applikationen als **Module** integriert, verwaltet und Benutzern zugewiesen werden können. -**Status: Phase 3 – Modul-System (abgeschlossen)** +**Status: Phase 4 – Gateway & dynamisches Routing (abgeschlossen)** ## Architektur-Überblick @@ -100,6 +100,9 @@ Vollständige Benutzer-CRUD-API (nur Admin) mit Duplikat-Schutz, Schutz des letz ### Phase 3 – Modul-System Modul-Registry mit Manifest-Vertrag (`module.json`, Zod-validiert), ZIP-Installation mit Zip-Slip-Schutz, Prozess-Manager (Kindprozesse mit minimaler ENV, eigene Logs), Lifecycle (INSTALLED/STARTING/RUNNING/STOPPED/ERROR/DISABLED), Healthchecks mit Startup-Grace, Modulverwaltungs-UI und persistente Volumes für Modul-Dateien. +### Phase 4 – Gateway & Routing +Dynamisches Routing `/slug` über Nginx → Modul-Gateway (Middleware): Session-Check, Modul-Status-Check, Permission-Check (fail-closed), Proxy zu internen Ports. Sichere Identitätsübergabe über Header, Startup-Recovery mit Autostart nach Container-Neustarts. + ## Annahme „ChatCM" wurde als **shadcn-artige Komponentenbasis** interpretiert: Tailwind CSS plus zentral gepflegte, wiederverwendbare UI-Komponenten (`apps/platform-frontend/src/components/ui`). \ No newline at end of file diff --git a/apps/platform-backend/package-lock.json b/apps/platform-backend/package-lock.json index 065676b..6cc3b6b 100644 --- a/apps/platform-backend/package-lock.json +++ b/apps/platform-backend/package-lock.json @@ -18,6 +18,7 @@ "cookie-parser": "^1.4.7", "express-rate-limit": "^7.5.0", "helmet": "^8.0.0", + "http-proxy": "^1.18.1", "pg": "^8.13.0", "reflect-metadata": "^0.2.2", "rxjs": "^7.8.1", @@ -29,6 +30,7 @@ "@types/adm-zip": "^0.5.8", "@types/cookie-parser": "^1.4.8", "@types/express": "^5.0.0", + "@types/http-proxy": "^1.17.17", "@types/jest": "^29.5.14", "@types/multer": "^2.3.0", "@types/node": "^24.0.0", @@ -2612,6 +2614,16 @@ "dev": true, "license": "MIT" }, + "node_modules/@types/http-proxy": { + "version": "1.17.17", + "resolved": "https://registry.npmjs.org/@types/http-proxy/-/http-proxy-1.17.17.tgz", + "integrity": "sha512-ED6LB+Z1AVylNTu7hdzuBqOgMnvG/ld6wGCG8wFnAzKX5uyW2K3WD52v0gnLCTK/VLpXtKckgWuyScYK6cSPaw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/node": "*" + } + }, "node_modules/@types/istanbul-lib-coverage": { "version": "2.0.6", "resolved": "https://registry.npmjs.org/@types/istanbul-lib-coverage/-/istanbul-lib-coverage-2.0.6.tgz", @@ -4797,6 +4809,12 @@ "node": ">= 0.6" } }, + "node_modules/eventemitter3": { + "version": "4.0.7", + "resolved": "https://registry.npmjs.org/eventemitter3/-/eventemitter3-4.0.7.tgz", + "integrity": "sha512-8guHBZCwKnFhYdHr2ysuRWErTwhoN2X8XELRlrRwpmfeY2jjuUN4taQMsULKUVo1K4DvZl+0pgfyoysHxvmvEw==", + "license": "MIT" + }, "node_modules/events": { "version": "3.3.0", "resolved": "https://registry.npmjs.org/events/-/events-3.3.0.tgz", @@ -5106,6 +5124,26 @@ "dev": true, "license": "ISC" }, + "node_modules/follow-redirects": { + "version": "1.16.1", + "resolved": "https://registry.npmjs.org/follow-redirects/-/follow-redirects-1.16.1.tgz", + "integrity": "sha512-FNvFGzoMLWmE6Yj9spb/zjd7yiNCHiAW9/Tg9CXrQ8wuu32HtlJOwWO11OJafl5FfY3DxTdQ0vj42zU1kvv5jg==", + "funding": [ + { + "type": "individual", + "url": "https://github.com/sponsors/RubenVerborgh" + } + ], + "license": "MIT", + "engines": { + "node": ">=4.0" + }, + "peerDependenciesMeta": { + "debug": { + "optional": true + } + } + }, "node_modules/fork-ts-checker-webpack-plugin": { "version": "9.1.0", "resolved": "https://registry.npmjs.org/fork-ts-checker-webpack-plugin/-/fork-ts-checker-webpack-plugin-9.1.0.tgz", @@ -5573,6 +5611,20 @@ "url": "https://opencollective.com/express" } }, + "node_modules/http-proxy": { + "version": "1.18.1", + "resolved": "https://registry.npmjs.org/http-proxy/-/http-proxy-1.18.1.tgz", + "integrity": "sha512-7mz/721AbnJwIVbnaSv1Cz3Am0ZLT/UBwkC92VlxhXv/k/BBQfM2fXElQNC27BVGr0uwUpplYPQM9LnaBMR5NQ==", + "license": "MIT", + "dependencies": { + "eventemitter3": "^4.0.0", + "follow-redirects": "^1.0.0", + "requires-port": "^1.0.0" + }, + "engines": { + "node": ">=8.0.0" + } + }, "node_modules/human-signals": { "version": "2.1.0", "resolved": "https://registry.npmjs.org/human-signals/-/human-signals-2.1.0.tgz", @@ -7906,6 +7958,12 @@ "node": ">=0.10.0" } }, + "node_modules/requires-port": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/requires-port/-/requires-port-1.0.0.tgz", + "integrity": "sha512-KigOCHcocU3XODJxsu8i/j8T9tzT4adHiecwORRQ0ZZFcp7ahwXuRU1m+yuO90C5ZUyGeGfocHDI14M3L3yDAQ==", + "license": "MIT" + }, "node_modules/resolve": { "version": "1.22.12", "resolved": "https://registry.npmjs.org/resolve/-/resolve-1.22.12.tgz", diff --git a/apps/platform-backend/package.json b/apps/platform-backend/package.json index 1ef905e..c771378 100644 --- a/apps/platform-backend/package.json +++ b/apps/platform-backend/package.json @@ -24,6 +24,7 @@ "cookie-parser": "^1.4.7", "express-rate-limit": "^7.5.0", "helmet": "^8.0.0", + "http-proxy": "^1.18.1", "pg": "^8.13.0", "reflect-metadata": "^0.2.2", "rxjs": "^7.8.1", @@ -35,6 +36,7 @@ "@types/adm-zip": "^0.5.8", "@types/cookie-parser": "^1.4.8", "@types/express": "^5.0.0", + "@types/http-proxy": "^1.17.17", "@types/jest": "^29.5.14", "@types/multer": "^2.3.0", "@types/node": "^24.0.0", diff --git a/apps/platform-backend/src/modules/module-gateway.middleware.spec.ts b/apps/platform-backend/src/modules/module-gateway.middleware.spec.ts new file mode 100644 index 0000000..19bca7c --- /dev/null +++ b/apps/platform-backend/src/modules/module-gateway.middleware.spec.ts @@ -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 { + 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 { + 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 { + return this.session; + } +} + +/** Mock des UserRepository. */ +class MockUserRepository { + public user: UserRecord | null = createUserRecord(); + + async findById(): Promise { + return this.user; + } +} + +/** Mock des ModuleRepository. */ +class MockModuleRepository { + public module: ModuleRecord | null = createModuleRecord(); + + async findBySlug(): Promise { + 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(); + }); +}); \ No newline at end of file diff --git a/apps/platform-backend/src/modules/module-gateway.middleware.ts b/apps/platform-backend/src/modules/module-gateway.middleware.ts new file mode 100644 index 0000000..b3d1c5e --- /dev/null +++ b/apps/platform-backend/src/modules/module-gateway.middleware.ts @@ -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 { + if (!request.url.startsWith(GATEWAY_PREFIX)) { + next(); + return; + } + + // Slug aus dem Gateway-Pfad extrahieren: /api/v1/gateway// + 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}`, + }); + } +} \ No newline at end of file diff --git a/apps/platform-backend/src/modules/module-startup-recovery.ts b/apps/platform-backend/src/modules/module-startup-recovery.ts new file mode 100644 index 0000000..f465803 --- /dev/null +++ b/apps/platform-backend/src/modules/module-startup-recovery.ts @@ -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 { + await this.recover(); + } + + /** Setzt veraltete Status zurück und startet Module ggf. neu. */ + async recover(): Promise { + 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), + ); + } + } + } +} \ No newline at end of file diff --git a/apps/platform-backend/src/modules/modules.module.ts b/apps/platform-backend/src/modules/modules.module.ts index 1868945..fa7e7c2 100644 --- a/apps/platform-backend/src/modules/modules.module.ts +++ b/apps/platform-backend/src/modules/modules.module.ts @@ -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 {} \ No newline at end of file +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 }); + } +} \ No newline at end of file diff --git a/docker/nginx/nginx.conf b/docker/nginx/nginx.conf index 2d23c32..b64701d 100644 --- a/docker/nginx/nginx.conf +++ b/docker/nginx/nginx.conf @@ -71,6 +71,22 @@ http { proxy_read_timeout 30s; } + # Modul-Routing (Phase 4): /slug/* wird intern an den + # Modul-Gateway des Backends übergeben (/api/v1/gateway/slug/*). + # Der Gateway prüft Session, Modul-Status und Berechtigung, + # bevor der Request an den Modul-Prozess proxied wird. + # WICHTIG: Plattform-Pfade (api, assets, login, …) sind ausgeschlossen, + # damit nur echte Modul-Slugs (3–100 Zeichen) weitergeleitet werden. + location ~ "^/(?!api/|assets/|login|profile|admin|403|404)(?[a-z0-9][a-z0-9-]{2,100})(?/.*)?$" { + proxy_pass http://platform_backend/api/v1/gateway/$module_slug$module_path; + proxy_http_version 1.1; + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-Proto $scheme; + proxy_read_timeout 60s; + } + # SPA-Fallback für React Router location / { try_files $uri $uri/ /index.html; diff --git a/docs/PHASES.md b/docs/PHASES.md index 9643eab..422b5a7 100644 --- a/docs/PHASES.md +++ b/docs/PHASES.md @@ -7,7 +7,7 @@ Der Arbeitsplan sieht zehn inkrementelle Phasen vor. Nach jeder Phase muss das S | 1 | Grundgerüst (Docker, DB, Login, Rollen) | ✅ Abgeschlossen | | 2 | Benutzerverwaltung | ✅ Abgeschlossen | | 3 | Modul-System (Manifest, Installation, Lifecycle) | ✅ Abgeschlossen | -| 4 | Gateway & dynamisches Routing (`/slug`) | ⏳ Geplant | +| 4 | Gateway & dynamisches Routing (`/slug`) | ✅ Abgeschlossen | | 5 | Berechtigungssystem (User ↔ Module) | ⏳ Geplant | | 6 | Modul-API (`/health`, `/api/manifest`, `/api/me`) | ⏳ Geplant | | 7 | Referenzmodul Kalender | ⏳ Geplant | @@ -69,8 +69,21 @@ Definition of Done: Modul-Datenmodell, Manifest, Installation, Registrierung, St - [x] Backend-Tests: 72 bestanden (inkl. ModulesService-Lifecycle, Manifest-Validierung, Installer) - [x] E2E verifiziert: Installation (201), Start → RUNNING, Healthcheck (healthy, 2 ms), Stop, Restart, Disable (stoppt Prozess), Start-Sperre bei Disable (400), Remove, RBAC (USER → 403) -## Nächste Schritte (Phase 4 – Gateway & Routing) +## Phase 4 – Gateway & dynamisches Routing (abgeschlossen) -- Dynamisches Routing: `/slug` → Modul-Prozess (Nginx-Konfiguration zur Laufzeit) -- Zugriffskontrolle beim Routing (Session → Permission Check → 403/Proxy) -- Modul-Gateway mit sicherer Identitätsübergabe an das Modul \ No newline at end of file +Definition of Done: Module sind über `/slug` erreichbar, Zugriff nur mit gültiger Session und Berechtigung. + +- [x] Nginx-Routing: `/slug/*` → Backend-Gateway (`/api/v1/gateway/slug/*`) mit Ausschluss-Regex für Plattform-Pfade (api, assets, login, …) +- [x] Modul-Gateway als Middleware: Session-Check (401), Slug-Suche (404), Status-Check (503), Permission-Check (403), Proxy zum internen Port +- [x] Sichere Identitätsübergabe über interne Header (`x-user-id`, `x-user-username`, `x-user-display-name`, `x-user-role`); Session-Cookie wird nie an Module weitergereicht +- [x] Fail-closed: USERs erhalten bis Phase 5 grundsätzlich 403 +- [x] Startup-Recovery: Nach Container-Neustarts werden veraltete Status zurückgesetzt und aktivierte Module automatisch neu gestartet +- [x] Backend-Tests: 81 bestanden (inkl. 9 Gateway-Tests) +- [x] E2E verifiziert: `/demo` → 200 (Modul-Inhalt), `/demo/health` → 200, unbekanntes Modul → 404, ohne Login → 401, USER → 403, gestopptes Modul → 503 + +## Nächste Schritte (Phase 5 – Berechtigungssystem) + +- `user_module_permissions`-Tabelle (GRANTED/DENIED) +- Admin-API: Benutzer ↔ Module zuweisen (`POST /api/v1/users/:id/modules/:moduleId`) +- Gateway-Permission-Check an die Tabelle anbinden (USER mit GRANTED → Zugriff) +- Dashboard: Modul-Kacheln anhand der tatsächlichen Berechtigungen \ No newline at end of file