From 4d7610328c82722dcbeded54b8a38d2ea182dbd4 Mon Sep 17 00:00:00 2001 From: MPM Dev Date: Wed, 7 Oct 2026 16:10:15 +0200 Subject: [PATCH] =?UTF-8?q?feat:=20Phase=205=20=E2=80=93=20Berechtigungssy?= =?UTF-8?q?stem=20(User-Module-Zuweisung,=20Gateway-Access-Control)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- README.md | 5 +- .../src/audit/audit.service.ts | 2 + .../src/database/migrations/index.ts | 7 +- .../migrations/003-module-permissions.ts | 27 +++ .../modules/module-gateway.middleware.spec.ts | 32 +++ .../src/modules/module-gateway.middleware.ts | 17 +- .../modules/module-permission.repository.ts | 126 ++++++++++ .../modules/module-permissions.controller.ts | 63 +++++ .../module-permissions.service.spec.ts | 222 ++++++++++++++++++ .../src/modules/module-permissions.service.ts | 126 ++++++++++ .../src/modules/modules.module.ts | 9 +- .../src/users/profile.controller.ts | 41 +++- .../src/users/users.module.ts | 13 +- .../features/admin/user-permissions-modal.tsx | 133 +++++++++++ .../src/features/admin/users-page.tsx | 8 + .../src/features/dashboard/dashboard-page.tsx | 57 ++++- apps/platform-frontend/src/lib/modules-api.ts | 33 ++- apps/platform-frontend/src/lib/schemas.ts | 22 +- docs/PHASES.md | 24 +- 19 files changed, 938 insertions(+), 29 deletions(-) create mode 100644 apps/platform-backend/src/modules/migrations/003-module-permissions.ts create mode 100644 apps/platform-backend/src/modules/module-permission.repository.ts create mode 100644 apps/platform-backend/src/modules/module-permissions.controller.ts create mode 100644 apps/platform-backend/src/modules/module-permissions.service.spec.ts create mode 100644 apps/platform-backend/src/modules/module-permissions.service.ts create mode 100644 apps/platform-frontend/src/features/admin/user-permissions-modal.tsx diff --git a/README.md b/README.md index 823978c..ad37f84 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 4 – Gateway & dynamisches Routing (abgeschlossen)** +**Status: Phase 5 – Berechtigungssystem (abgeschlossen)** ## Architektur-Überblick @@ -103,6 +103,9 @@ Modul-Registry mit Manifest-Vertrag (`module.json`, Zod-validiert), ZIP-Installa ### 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. +### Phase 5 – Berechtigungssystem +Zweistufiges Rechtekonzept: Plattform-Rollen (ADMIN/USER) + Modul-Berechtigungen (`user_module_permissions`, GRANTED/DENIED). Admin-API für Zuweisungen, Gateway prüft Berechtigungen fail-closed, Dashboard zeigt nur freigegebene Module als Kacheln. + ## 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/src/audit/audit.service.ts b/apps/platform-backend/src/audit/audit.service.ts index 5f1ce52..d0eccab 100644 --- a/apps/platform-backend/src/audit/audit.service.ts +++ b/apps/platform-backend/src/audit/audit.service.ts @@ -21,6 +21,8 @@ export const AUDIT_ACTIONS = { MODULE_RESTARTED: 'MODULE_RESTARTED', MODULE_ENABLED: 'MODULE_ENABLED', MODULE_DISABLED: 'MODULE_DISABLED', + PERMISSION_GRANTED: 'PERMISSION_GRANTED', + PERMISSION_REVOKED: 'PERMISSION_REVOKED', } as const; export type AuditAction = (typeof AUDIT_ACTIONS)[keyof typeof AUDIT_ACTIONS]; diff --git a/apps/platform-backend/src/database/migrations/index.ts b/apps/platform-backend/src/database/migrations/index.ts index 791c5ab..82ad414 100644 --- a/apps/platform-backend/src/database/migrations/index.ts +++ b/apps/platform-backend/src/database/migrations/index.ts @@ -1,5 +1,10 @@ import { migration001CoreSchema } from './001-core-schema'; import { migration002Modules } from '../../modules/migrations/002-modules'; +import { migration003ModulePermissions } from '../../modules/migrations/003-module-permissions'; /** Registrierte Migrationen in aufsteigender Reihenfolge. */ -export const MIGRATIONS = [migration001CoreSchema, migration002Modules]; \ No newline at end of file +export const MIGRATIONS = [ + migration001CoreSchema, + migration002Modules, + migration003ModulePermissions, +]; \ No newline at end of file diff --git a/apps/platform-backend/src/modules/migrations/003-module-permissions.ts b/apps/platform-backend/src/modules/migrations/003-module-permissions.ts new file mode 100644 index 0000000..58c7f91 --- /dev/null +++ b/apps/platform-backend/src/modules/migrations/003-module-permissions.ts @@ -0,0 +1,27 @@ +import type { Migration } from '../../database/migration.types'; + +/** Phase 5: Modul-Berechtigungen (User ↔ Module, GRANTED/DENIED). */ +export const migration003ModulePermissions: Migration = { + id: '003-module-permissions', + description: 'Modul-Berechtigungen (user_module_permissions) anlegen', + up: async (client) => { + await client.query(` + CREATE TABLE user_module_permissions ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE, + module_id UUID NOT NULL REFERENCES modules(id) ON DELETE CASCADE, + permission TEXT NOT NULL DEFAULT 'GRANTED' + CHECK (permission IN ('GRANTED', 'DENIED')), + created_at TIMESTAMPTZ NOT NULL DEFAULT now(), + UNIQUE (user_id, module_id) + ) + `); + + await client.query( + 'CREATE INDEX idx_user_module_permissions_user ON user_module_permissions(user_id)', + ); + await client.query( + 'CREATE INDEX idx_user_module_permissions_module ON user_module_permissions(module_id)', + ); + }, +}; \ No newline at end of file diff --git a/apps/platform-backend/src/modules/module-gateway.middleware.spec.ts b/apps/platform-backend/src/modules/module-gateway.middleware.spec.ts index 19bca7c..2fbb538 100644 --- a/apps/platform-backend/src/modules/module-gateway.middleware.spec.ts +++ b/apps/platform-backend/src/modules/module-gateway.middleware.spec.ts @@ -5,6 +5,7 @@ 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 { ModulePermissionsService } from './module-permissions.service'; import { ModuleGatewayMiddleware } from './module-gateway.middleware'; /** Erzeugt einen Modul-Datensatz für Tests. */ @@ -99,10 +100,20 @@ class MockModuleRepository { } } +/** Mock des ModulePermissionsService. */ +class MockPermissionsService { + public hasAccessResult = false; + + async hasAccess(): Promise { + return this.hasAccessResult; + } +} + describe('ModuleGatewayMiddleware', () => { let sessionService: MockSessionService; let userRepository: MockUserRepository; let moduleRepository: MockModuleRepository; + let permissionsService: MockPermissionsService; let middleware: ModuleGatewayMiddleware; const nextCalls: NextFunction[] = []; @@ -116,10 +127,12 @@ describe('ModuleGatewayMiddleware', () => { sessionService = new MockSessionService(); userRepository = new MockUserRepository(); moduleRepository = new MockModuleRepository(); + permissionsService = new MockPermissionsService(); middleware = new ModuleGatewayMiddleware( moduleRepository as unknown as ModuleRepository, sessionService as unknown as SessionService, userRepository as unknown as UserRepository, + permissionsService as unknown as ModulePermissionsService, ); sessionService.session = { id: 'session-1', @@ -192,6 +205,7 @@ describe('ModuleGatewayMiddleware', () => { }); it('antwortet 403 für USER ohne Berechtigung (fail-closed)', async () => { + permissionsService.hasAccessResult = false; const request = createRequest('/api/v1/gateway/demo/', 'mpm_session=valid'); const response = createResponse(); @@ -199,6 +213,24 @@ describe('ModuleGatewayMiddleware', () => { expect(response.sentStatus).toBe(403); }); + it('leitet USER-Requests mit GRANTED-Berechtigung an den Proxy weiter', async () => { + permissionsService.hasAccessResult = true; + const request = createRequest('/api/v1/gateway/demo/health', 'mpm_session=valid'); + const response = createResponse(); + + const proxySpy = jest + .spyOn(middleware['proxy'], 'web') + .mockImplementation(() => undefined); + + await middleware.use(request, response, makeNext()); + + expect(proxySpy).toHaveBeenCalled(); + expect(request.headers['x-user-role']).toBe('USER'); + expect(request.url).toBe('/health'); + + proxySpy.mockRestore(); + }); + 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'); diff --git a/apps/platform-backend/src/modules/module-gateway.middleware.ts b/apps/platform-backend/src/modules/module-gateway.middleware.ts index b3d1c5e..787ae1e 100644 --- a/apps/platform-backend/src/modules/module-gateway.middleware.ts +++ b/apps/platform-backend/src/modules/module-gateway.middleware.ts @@ -5,20 +5,20 @@ import { SessionService } from '../auth/session.service'; import { extractSessionToken } from '../auth/guards/session.guard'; import { UserRepository } from '../users/user.repository'; import { ModuleRepository } from './module.repository'; +import { ModulePermissionsService } from './module-permissions.service'; /** Gateway-Pfad-Präfix für interne Nginx-Weiterleitung. */ const GATEWAY_PREFIX = '/api/v1/gateway/'; /** - * Modul-Gateway (Phase 4): Dynamisches Routing /slug → Modul-Prozess. + * Modul-Gateway (Phase 4/5): 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 + * 4. Permission-Check (403): ADMIN immer, USER nur mit GRANTED * 5. Proxy zum internen Port (nie öffentlich erreichbar) * * Das Modul selbst vertraut nie allein auf die URL – die Plattform @@ -33,6 +33,7 @@ export class ModuleGatewayMiddleware implements NestMiddleware { private readonly moduleRepository: ModuleRepository, private readonly sessionService: SessionService, private readonly userRepository: UserRepository, + private readonly permissionsService: ModulePermissionsService, ) { this.proxy = httpProxy.createProxyServer({ proxyTimeout: 30_000, @@ -100,11 +101,11 @@ export class ModuleGatewayMiddleware implements NestMiddleware { 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') { + // 4. Permission-Check (Ebene 2 – Modul-Rechte): + // ADMIN hat immer Zugriff; USER nur mit GRANTED-Berechtigung. + const hasAccess = + user.role === 'ADMIN' || (await this.permissionsService.hasAccess(user.id, module)); + if (!hasAccess) { response.status(403).json({ statusCode: 403, message: 'Keine Berechtigung für dieses Modul', diff --git a/apps/platform-backend/src/modules/module-permission.repository.ts b/apps/platform-backend/src/modules/module-permission.repository.ts new file mode 100644 index 0000000..77ee7d5 --- /dev/null +++ b/apps/platform-backend/src/modules/module-permission.repository.ts @@ -0,0 +1,126 @@ +import { Injectable } from '@nestjs/common'; +import { DatabaseService } from '../database/database.service'; +import type { ModuleRecord } from './manifest.types'; + +/** Modul-Berechtigung eines Benutzers. */ +export interface ModulePermissionRecord { + readonly id: string; + readonly userId: string; + readonly moduleId: string; + readonly permission: 'GRANTED' | 'DENIED'; + readonly createdAt: Date; +} + +interface PermissionRow { + id: string; + user_id: string; + module_id: string; + permission: 'GRANTED' | 'DENIED'; + created_at: Date; +} + +/** + * Berechtigungs-Repository (Infrastructure): Datenbankzugriffe für + * Modul-Berechtigungen (Ebene 2 – Modul-Rechte). + */ +@Injectable() +export class ModulePermissionRepository { + constructor(private readonly database: DatabaseService) {} + + /** Alle Berechtigungen eines Benutzers. */ + async listByUser(userId: string): Promise { + const result = await this.database.query( + `SELECT id, user_id, module_id, permission, created_at + FROM user_module_permissions + WHERE user_id = $1 + ORDER BY created_at ASC`, + [userId], + ); + return result.rows.map((row) => this.mapRow(row)); + } + + /** Berechtigung eines Benutzers für ein bestimmtes Modul. */ + async findByUserAndModule( + userId: string, + moduleId: string, + ): Promise { + const result = await this.database.query( + `SELECT id, user_id, module_id, permission, created_at + FROM user_module_permissions + WHERE user_id = $1 AND module_id = $2`, + [userId, moduleId], + ); + return result.rows[0] ? this.mapRow(result.rows[0]) : null; + } + + /** Gewährt einem Benutzer Zugriff auf ein Modul (Upsert). */ + async grant(userId: string, moduleId: string): Promise { + const result = await this.database.query( + `INSERT INTO user_module_permissions (user_id, module_id, permission) + VALUES ($1, $2, 'GRANTED') + ON CONFLICT (user_id, module_id) + DO UPDATE SET permission = 'GRANTED' + RETURNING id, user_id, module_id, permission, created_at`, + [userId, moduleId], + ); + return this.mapRow(result.rows[0]); + } + + /** Entfernt die Berechtigung eines Benutzers für ein Modul. */ + async revoke(userId: string, moduleId: string): Promise { + await this.database.query( + 'DELETE FROM user_module_permissions WHERE user_id = $1 AND module_id = $2', + [userId, moduleId], + ); + } + + /** Alle Berechtigungen für ein Modul (z. B. beim Entfernen). */ + async deleteByModule(moduleId: string): Promise { + await this.database.query('DELETE FROM user_module_permissions WHERE module_id = $1', [ + moduleId, + ]); + } + + /** + * Alle Module, auf die ein Benutzer Zugriff hat (GRANTED). + * Wird für die Dashboard-Kacheln und den Gateway-Check verwendet. + */ + async listGrantedModules(userId: string): Promise { + const result = await this.database.query( + `SELECT m.id, m.module_id, m.name, m.slug, m.version, m.description, m.author, + m.path, m.status, m.internal_port, m.healthcheck_url, m.enabled, + m.created_at, m.updated_at + FROM user_module_permissions p + JOIN modules m ON m.id = p.module_id + WHERE p.user_id = $1 AND p.permission = 'GRANTED' AND m.enabled + ORDER BY m.created_at ASC`, + [userId], + ); + return result.rows.map((row) => ({ + id: row.id, + moduleId: row.module_id, + name: row.name, + slug: row.slug, + version: row.version, + description: row.description, + author: row.author, + path: row.path, + status: row.status, + internalPort: row.internal_port, + healthcheckUrl: row.healthcheck_url, + enabled: row.enabled, + createdAt: row.created_at, + updatedAt: row.updated_at, + })); + } + + private mapRow(row: PermissionRow): ModulePermissionRecord { + return { + id: row.id, + userId: row.user_id, + moduleId: row.module_id, + permission: row.permission, + createdAt: row.created_at, + }; + } +} \ No newline at end of file diff --git a/apps/platform-backend/src/modules/module-permissions.controller.ts b/apps/platform-backend/src/modules/module-permissions.controller.ts new file mode 100644 index 0000000..f3df75c --- /dev/null +++ b/apps/platform-backend/src/modules/module-permissions.controller.ts @@ -0,0 +1,63 @@ +import { + Controller, + Delete, + Get, + Param, + ParseUUIDPipe, + Post, + 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 type { AuthUser } from '../users/user.types'; +import type { ModulePermissionResponse } from './module-permissions.service'; +import { ModulePermissionsService } from './module-permissions.service'; + +/** + * Berechtigungsverwaltung (nur Administratoren): Ebene 2 – Modul-Rechte. + * Ordnet Benutzer Module zu (GRANTED) und entzieht Zugriffe. + */ +@ApiTags('Permissions') +@Roles('ADMIN') +@Controller({ path: 'api/v1/users/:userId/modules' }) +export class ModulePermissionsController { + constructor(private readonly permissionsService: ModulePermissionsService) {} + + @Get() + async list( + @Param('userId', ParseUUIDPipe) userId: string, + ): Promise<{ permissions: ModulePermissionResponse[] }> { + const permissions = await this.permissionsService.listForUser(userId); + return { permissions }; + } + + @Post(':moduleId') + async grant( + @Param('userId', ParseUUIDPipe) userId: string, + @Param('moduleId', ParseUUIDPipe) moduleId: string, + @CurrentUser() actor: AuthUser, + @Req() request: AuthenticatedRequest & Request, + ): Promise<{ permission: ModulePermissionResponse }> { + const permission = await this.permissionsService.grant( + userId, + moduleId, + actor, + request.ip ?? null, + ); + return { permission }; + } + + @Delete(':moduleId') + async revoke( + @Param('userId', ParseUUIDPipe) userId: string, + @Param('moduleId', ParseUUIDPipe) moduleId: string, + @CurrentUser() actor: AuthUser, + @Req() request: AuthenticatedRequest & Request, + ): Promise<{ success: true }> { + await this.permissionsService.revoke(userId, moduleId, actor, request.ip ?? null); + return { success: true }; + } +} \ No newline at end of file diff --git a/apps/platform-backend/src/modules/module-permissions.service.spec.ts b/apps/platform-backend/src/modules/module-permissions.service.spec.ts new file mode 100644 index 0000000..953728f --- /dev/null +++ b/apps/platform-backend/src/modules/module-permissions.service.spec.ts @@ -0,0 +1,222 @@ +import { NotFoundException } from '@nestjs/common'; +import { AUDIT_ACTIONS, AuditService } from '../audit/audit.service'; +import type { ActingUser } from '../users/users.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 { + ModulePermissionRepository, + type ModulePermissionRecord, +} from './module-permission.repository'; +import { ModulePermissionsService } from './module-permissions.service'; + +/** 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 Berechtigungs-Datensatz für Tests. */ +function createPermissionRecord( + overrides: Partial = {}, +): ModulePermissionRecord { + return { + id: 'permission-1', + userId: 'user-1', + moduleId: 'module-1', + permission: 'GRANTED', + createdAt: new Date(), + ...overrides, + }; +} + +const ACTOR: ActingUser = { id: 'admin-1', username: 'admin' }; + +/** Mock des ModulePermissionRepository. */ +class MockPermissionRepository { + public permissions: ModulePermissionRecord[] = []; + public granted: Array<{ userId: string; moduleId: string }> = []; + public revoked: Array<{ userId: string; moduleId: string }> = []; + + async listByUser(userId: string): Promise { + return this.permissions.filter((permission) => permission.userId === userId); + } + + async findByUserAndModule( + userId: string, + moduleId: string, + ): Promise { + return ( + this.permissions.find( + (permission) => permission.userId === userId && permission.moduleId === moduleId, + ) ?? null + ); + } + + async grant(userId: string, moduleId: string): Promise { + this.granted.push({ userId, moduleId }); + const permission = createPermissionRecord({ userId, moduleId }); + this.permissions = [ + ...this.permissions.filter((p) => !(p.userId === userId && p.moduleId === moduleId)), + permission, + ]; + return permission; + } + + async revoke(userId: string, moduleId: string): Promise { + this.revoked.push({ userId, moduleId }); + this.permissions = this.permissions.filter( + (p) => !(p.userId === userId && p.moduleId === moduleId), + ); + } + + async listGrantedModules(userId: string): Promise { + const grantedIds = this.permissions + .filter((p) => p.userId === userId && p.permission === 'GRANTED') + .map((p) => p.moduleId); + return grantedIds.map((id) => createModuleRecord({ id })); + } +} + +/** Mock des ModuleRepository. */ +class MockModuleRepository { + public modules: ModuleRecord[] = [createModuleRecord()]; + + async list(): Promise { + return this.modules; + } + + async findById(id: string): Promise { + return this.modules.find((module) => module.id === id) ?? null; + } +} + +/** Mock des UserRepository. */ +class MockUserRepository { + public user: UserRecord | null = { + 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(), + }; + + async findById(id: string): Promise { + return this.user && this.user.id === id ? this.user : null; + } +} + +/** Mock des AuditService. */ +class MockAuditService { + public records: Array<{ action: string }> = []; + + async record(entry: { action: string }): Promise { + this.records.push(entry); + } +} + +describe('ModulePermissionsService', () => { + let permissionRepository: MockPermissionRepository; + let moduleRepository: MockModuleRepository; + let userRepository: MockUserRepository; + let auditService: MockAuditService; + let permissionsService: ModulePermissionsService; + + beforeEach(() => { + permissionRepository = new MockPermissionRepository(); + moduleRepository = new MockModuleRepository(); + userRepository = new MockUserRepository(); + auditService = new MockAuditService(); + permissionsService = new ModulePermissionsService( + permissionRepository as unknown as ModulePermissionRepository, + moduleRepository as unknown as ModuleRepository, + userRepository as unknown as UserRepository, + auditService as unknown as AuditService, + ); + }); + + describe('grant', () => { + it('gewährt Zugriff und schreibt Audit', async () => { + const permission = await permissionsService.grant('user-1', 'module-1', ACTOR, null); + expect(permission.permission).toBe('GRANTED'); + expect(permission.moduleSlug).toBe('demo'); + expect(auditService.records.at(-1)?.action).toBe(AUDIT_ACTIONS.PERMISSION_GRANTED); + }); + + it('wirft NotFoundException bei unbekanntem Benutzer', async () => { + await expect( + permissionsService.grant('unbekannt', 'module-1', ACTOR, null), + ).rejects.toThrow(NotFoundException); + }); + + it('wirft NotFoundException bei unbekanntem Modul', async () => { + await expect( + permissionsService.grant('user-1', 'unbekannt', ACTOR, null), + ).rejects.toThrow(NotFoundException); + }); + }); + + describe('revoke', () => { + it('entzieht Zugriff und schreibt Audit', async () => { + await permissionsService.grant('user-1', 'module-1', ACTOR, null); + await permissionsService.revoke('user-1', 'module-1', ACTOR, null); + expect(permissionRepository.revoked).toEqual([{ userId: 'user-1', moduleId: 'module-1' }]); + expect(auditService.records.at(-1)?.action).toBe(AUDIT_ACTIONS.PERMISSION_REVOKED); + }); + }); + + describe('hasAccess', () => { + it('gibt true mit GRANTED-Berechtigung zurück', async () => { + await permissionsService.grant('user-1', 'module-1', ACTOR, null); + const module = createModuleRecord(); + await expect(permissionsService.hasAccess('user-1', module)).resolves.toBe(true); + }); + + it('gibt false ohne Berechtigung zurück (fail-closed)', async () => { + const module = createModuleRecord(); + await expect(permissionsService.hasAccess('user-1', module)).resolves.toBe(false); + }); + }); + + describe('listForUser', () => { + it('listet Berechtigungen mit Modul-Details auf', async () => { + await permissionsService.grant('user-1', 'module-1', ACTOR, null); + const permissions = await permissionsService.listForUser('user-1'); + expect(permissions).toHaveLength(1); + expect(permissions[0].moduleName).toBe('Demo-Modul'); + expect(permissions[0].moduleSlug).toBe('demo'); + }); + }); + + describe('listAccessibleModules', () => { + it('gibt nur Module mit GRANTED zurück', async () => { + await permissionsService.grant('user-1', 'module-1', ACTOR, null); + const modules = await permissionsService.listAccessibleModules('user-1'); + expect(modules).toHaveLength(1); + expect(modules[0].moduleId).toBe('demo'); + }); + }); +}); \ No newline at end of file diff --git a/apps/platform-backend/src/modules/module-permissions.service.ts b/apps/platform-backend/src/modules/module-permissions.service.ts new file mode 100644 index 0000000..cd54a1c --- /dev/null +++ b/apps/platform-backend/src/modules/module-permissions.service.ts @@ -0,0 +1,126 @@ +import { Injectable, NotFoundException } from '@nestjs/common'; +import { AUDIT_ACTIONS, AuditService } from '../audit/audit.service'; +import type { ActingUser } from '../users/users.service'; +import { UserRepository } from '../users/user.repository'; +import { ModuleRepository } from './module.repository'; +import { ModulePermissionRepository } from './module-permission.repository'; +import type { ModuleRecord } from './manifest.types'; + +/** Öffentliche Berechtigungs-Daten in API-Antworten. */ +export interface ModulePermissionResponse { + readonly moduleId: string; + readonly moduleSlug: string; + readonly moduleName: string; + readonly permission: 'GRANTED' | 'DENIED'; +} + +/** + * Berechtigungsverwaltung (Application-Layer): Ebene 2 – Modul-Rechte. + * Ordnet Benutzer Module zu (GRANTED/DENIED) und prüft den Zugriff + * im Modul-Gateway. + */ +@Injectable() +export class ModulePermissionsService { + constructor( + private readonly permissionRepository: ModulePermissionRepository, + private readonly moduleRepository: ModuleRepository, + private readonly userRepository: UserRepository, + private readonly auditService: AuditService, + ) {} + + /** Alle Modul-Berechtigungen eines Benutzers. */ + async listForUser(userId: string): Promise { + await this.assertUserExists(userId); + const permissions = await this.permissionRepository.listByUser(userId); + const modules = await this.moduleRepository.list(); + + return permissions.map((permission) => { + const module = modules.find((m) => m.id === permission.moduleId); + return { + moduleId: permission.moduleId, + moduleSlug: module?.slug ?? 'unbekannt', + moduleName: module?.name ?? 'Unbekanntes Modul', + permission: permission.permission, + }; + }); + } + + /** Gewährt einem Benutzer Zugriff auf ein Modul. */ + async grant( + userId: string, + moduleId: string, + actor: ActingUser, + ipAddress: string | null, + ): Promise { + await this.assertUserExists(userId); + const module = await this.assertModuleExists(moduleId); + + await this.permissionRepository.grant(userId, moduleId); + await this.auditService.record({ + userId: actor.id, + username: actor.username, + action: AUDIT_ACTIONS.PERMISSION_GRANTED, + details: { targetUserId: userId, moduleId: module.moduleId }, + ipAddress, + }); + + return { + moduleId: module.id, + moduleSlug: module.slug, + moduleName: module.name, + permission: 'GRANTED', + }; + } + + /** Entzieht einem Benutzer den Zugriff auf ein Modul. */ + async revoke( + userId: string, + moduleId: string, + actor: ActingUser, + ipAddress: string | null, + ): Promise { + await this.assertUserExists(userId); + const module = await this.assertModuleExists(moduleId); + + await this.permissionRepository.revoke(userId, moduleId); + await this.auditService.record({ + userId: actor.id, + username: actor.username, + action: AUDIT_ACTIONS.PERMISSION_REVOKED, + details: { targetUserId: userId, moduleId: module.moduleId }, + ipAddress, + }); + } + + /** + * Zugriffsprüfung für den Modul-Gateway: + * ADMIN hat immer Zugriff; USER nur mit GRANTED-Berechtigung. + */ + async hasAccess(userId: string, module: ModuleRecord): Promise { + const permission = await this.permissionRepository.findByUserAndModule( + userId, + module.id, + ); + return permission?.permission === 'GRANTED'; + } + + /** Alle Module, die ein Benutzer sehen darf (Dashboard-Kacheln). */ + async listAccessibleModules(userId: string): Promise { + return this.permissionRepository.listGrantedModules(userId); + } + + private async assertUserExists(userId: string): Promise { + const user = await this.userRepository.findById(userId); + if (!user) { + throw new NotFoundException('Benutzer nicht gefunden'); + } + } + + private async assertModuleExists(moduleId: string): Promise { + const module = await this.moduleRepository.findById(moduleId); + if (!module) { + throw new NotFoundException('Modul nicht gefunden'); + } + return module; + } +} \ 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 fa7e7c2..29635f9 100644 --- a/apps/platform-backend/src/modules/modules.module.ts +++ b/apps/platform-backend/src/modules/modules.module.ts @@ -14,6 +14,9 @@ import { ModuleHealthChecker } from './module-health-checker'; import { ModuleInstaller } from './module-installer'; import { ModuleProcessManager } from './module-process-manager'; import { ModuleGatewayMiddleware } from './module-gateway.middleware'; +import { ModulePermissionRepository } from './module-permission.repository'; +import { ModulePermissionsController } from './module-permissions.controller'; +import { ModulePermissionsService } from './module-permissions.service'; import { ModuleRepository } from './module.repository'; import { ModuleStartupRecovery } from './module-startup-recovery'; import { ModulesController } from './modules.controller'; @@ -22,7 +25,7 @@ import { ModulesService } from './modules.service'; /** Modul-System: Installation, Lifecycle, Prozessverwaltung, Gateway. */ @Module({ imports: [ConfigModule, DatabaseModule, AuditModule], - controllers: [ModulesController], + controllers: [ModulesController, ModulePermissionsController], providers: [ ModuleRepository, ModuleInstaller, @@ -34,8 +37,10 @@ import { ModulesService } from './modules.service'; PasswordHasher, ModuleGatewayMiddleware, ModuleStartupRecovery, + ModulePermissionRepository, + ModulePermissionsService, ], - exports: [ModuleRepository, ModulesService], + exports: [ModuleRepository, ModulesService, ModulePermissionsService], }) export class ModulesModule implements NestModule { /** Gateway-Middleware für alle /api/v1/gateway/* Pfade. */ diff --git a/apps/platform-backend/src/users/profile.controller.ts b/apps/platform-backend/src/users/profile.controller.ts index 1da323a..a2636c5 100644 --- a/apps/platform-backend/src/users/profile.controller.ts +++ b/apps/platform-backend/src/users/profile.controller.ts @@ -4,6 +4,8 @@ import { ApiTags } from '@nestjs/swagger'; import type { AuthenticatedRequest } from '../auth/authenticated-request'; import { CurrentUser } from '../common/decorators/current-user.decorator'; import { ZodValidationPipe } from '../common/zod-validation.pipe'; +import { ModulePermissionsService } from '../modules/module-permissions.service'; +import { ModuleRepository } from '../modules/module.repository'; import type { AuthUser, RoleName, UserRecord } from './user.types'; import { changePasswordSchema, type ChangePasswordDto } from './user.types'; import { ProfileService } from './profile.service'; @@ -31,6 +33,16 @@ function toProfileResponse(user: UserRecord): ProfileResponse { }; } +/** Modul-Kachel-Daten für das Dashboard. */ +interface AccessibleModuleResponse { + id: string; + moduleId: string; + name: string; + slug: string; + description: string; + status: string; +} + /** * Profil des angemeldeten Benutzers (jede Rolle). * Passwort-Änderung erfordert das aktuelle Passwort. @@ -38,7 +50,11 @@ function toProfileResponse(user: UserRecord): ProfileResponse { @ApiTags('Profile') @Controller({ path: 'api/v1/profile' }) export class ProfileController { - constructor(private readonly profileService: ProfileService) {} + constructor( + private readonly profileService: ProfileService, + private readonly moduleRepository: ModuleRepository, + private readonly permissionsService: ModulePermissionsService, + ) {} @Get() async getProfile(@CurrentUser() user: AuthUser): Promise<{ profile: ProfileResponse }> { @@ -46,6 +62,29 @@ export class ProfileController { return { profile: toProfileResponse(profile) }; } + /** Module, die der Benutzer auf dem Dashboard sehen darf. */ + @Get('modules') + async getAccessibleModules( + @CurrentUser() user: AuthUser, + ): Promise<{ modules: AccessibleModuleResponse[] }> { + // ADMIN sieht alle aktivierten Module; USER nur freigegebene. + const modules = + user.role === 'ADMIN' + ? (await this.moduleRepository.list()).filter((module) => module.enabled) + : await this.permissionsService.listAccessibleModules(user.id); + + return { + modules: modules.map((module) => ({ + id: module.id, + moduleId: module.moduleId, + name: module.name, + slug: module.slug, + description: module.description, + status: module.status, + })), + }; + } + @Patch('password') async changePassword( @CurrentUser() user: AuthUser, diff --git a/apps/platform-backend/src/users/users.module.ts b/apps/platform-backend/src/users/users.module.ts index 63a056a..29ea6e7 100644 --- a/apps/platform-backend/src/users/users.module.ts +++ b/apps/platform-backend/src/users/users.module.ts @@ -3,6 +3,8 @@ 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 { ModulePermissionsService } from '../modules/module-permissions.service'; +import { ModuleRepository } from '../modules/module.repository'; import { PasswordHasher } from './password-hasher'; import { ProfileController } from './profile.controller'; import { ProfileService } from './profile.service'; @@ -15,7 +17,16 @@ import { UsersService } from './users.service'; @Module({ imports: [ConfigModule, DatabaseModule, AuditModule], controllers: [UsersController, ProfileController], - providers: [UserRepository, PasswordHasher, SeedService, UsersService, ProfileService, SessionService], + providers: [ + UserRepository, + PasswordHasher, + SeedService, + UsersService, + ProfileService, + SessionService, + ModuleRepository, + ModulePermissionsService, + ], exports: [UserRepository, PasswordHasher], }) export class UsersModule {} \ No newline at end of file diff --git a/apps/platform-frontend/src/features/admin/user-permissions-modal.tsx b/apps/platform-frontend/src/features/admin/user-permissions-modal.tsx new file mode 100644 index 0000000..b6ad1db --- /dev/null +++ b/apps/platform-frontend/src/features/admin/user-permissions-modal.tsx @@ -0,0 +1,133 @@ +import { type ReactNode, useEffect, useState } from 'react'; +import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'; +import { Button } from '../../components/ui/button'; +import { Modal } from '../../components/ui/modal'; +import { useToast } from '../../components/ui/toast'; +import { ApiError } from '../../lib/api-client'; +import { + fetchModules, + fetchUserModulePermissions, + grantModuleAccess, + revokeModuleAccess, +} from '../../lib/modules-api'; +import type { Module, User } from '../../lib/schemas'; + +/** + * Dialog: Modul-Berechtigungen eines Benutzers verwalten. + * Zeigt alle installierten Module mit GRANTED/DENIED-Schaltern. + */ +export function UserPermissionsModal({ + user, + onClose, +}: { + user: User; + onClose: () => void; +}): ReactNode { + const { showToast } = useToast(); + const queryClient = useQueryClient(); + + const modulesQuery = useQuery({ + queryKey: ['modules'], + queryFn: fetchModules, + }); + + const permissionsQuery = useQuery({ + queryKey: ['user-module-permissions', user.id], + queryFn: () => fetchUserModulePermissions(user.id), + }); + + const [grantedModuleIds, setGrantedModuleIds] = useState>(new Set()); + + useEffect(() => { + if (permissionsQuery.data) { + setGrantedModuleIds( + new Set( + permissionsQuery.data + .filter((permission) => permission.permission === 'GRANTED') + .map((permission) => permission.moduleId), + ), + ); + } + }, [permissionsQuery.data]); + + const invalidate = (): void => { + void queryClient.invalidateQueries({ queryKey: ['user-module-permissions', user.id] }); + }; + + const toggleMutation = useMutation({ + mutationFn: async (input: { module: Module; grant: boolean }) => { + if (input.grant) { + return grantModuleAccess(user.id, input.module.id); + } + return revokeModuleAccess(user.id, input.module.id); + }, + onSuccess: (_result, variables) => { + showToast( + 'success', + variables.grant + ? `"${variables.module.name}" für ${user.username} freigegeben` + : `"${variables.module.name}" für ${user.username} entzogen`, + ); + invalidate(); + }, + onError: (error) => { + showToast('error', error instanceof ApiError ? error.message : 'Aktion fehlgeschlagen'); + }, + }); + + function handleToggle(module: Module): void { + const isGranted = grantedModuleIds.has(module.id); + toggleMutation.mutate({ module, grant: !isGranted }); + } + + return ( + + {modulesQuery.isLoading || permissionsQuery.isLoading ? ( +

Wird geladen…

+ ) : modulesQuery.isError ? ( +

+ Module konnten nicht geladen werden. +

+ ) : modulesQuery.data && modulesQuery.data.length === 0 ? ( +

+ Noch keine Module installiert. +

+ ) : ( +
    + {modulesQuery.data?.map((module) => { + const isGranted = grantedModuleIds.has(module.id); + return ( +
  • +
    +

    {module.name}

    +

    + /{module.slug} · Version {module.version} +

    +
    + +
  • + ); + })} +
+ )} +
+ ); +} \ No newline at end of file diff --git a/apps/platform-frontend/src/features/admin/users-page.tsx b/apps/platform-frontend/src/features/admin/users-page.tsx index e5ad916..6a37af5 100644 --- a/apps/platform-frontend/src/features/admin/users-page.tsx +++ b/apps/platform-frontend/src/features/admin/users-page.tsx @@ -14,6 +14,7 @@ import { resetUserPassword, updateUser, } from '../../lib/users-api'; +import { UserPermissionsModal } from './user-permissions-modal'; import { createUserSchema, resetPasswordSchema, @@ -370,6 +371,7 @@ export function UsersPage(): ReactNode { const [editUser, setEditUser] = useState(null); const [resetPasswordUser, setResetPasswordUser] = useState(null); const [deleteTarget, setDeleteTarget] = useState(null); + const [permissionsUser, setPermissionsUser] = useState(null); const usersQuery = useQuery({ queryKey: ['users'], @@ -466,6 +468,9 @@ export function UsersPage(): ReactNode { + {user.id !== currentUser?.id && ( <>