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 && (
<>
- {/* Meine Anwendungen (Modul-Kacheln; ab Phase 3 aus Berechtigungen) */}
+ {/* Meine Anwendungen: Kacheln ausschließlich nach tatsächlichen Berechtigungen */}
- 🧩}
- />
+ {modulesQuery.isLoading && }
+ {modulesQuery.isError && (
+
+ )}
+ {modulesQuery.data && modulesQuery.data.length === 0 && (
+ 🧩}
+ />
+ )}
+ {modulesQuery.data && modulesQuery.data.length > 0 && (
+
+ )}
diff --git a/apps/platform-frontend/src/lib/modules-api.ts b/apps/platform-frontend/src/lib/modules-api.ts
index 3b5b17a..a1a043f 100644
--- a/apps/platform-frontend/src/lib/modules-api.ts
+++ b/apps/platform-frontend/src/lib/modules-api.ts
@@ -1,5 +1,12 @@
import { apiRequest, ApiError } from './api-client';
-import { moduleSchema, type Module } from './schemas';
+import {
+ accessibleModuleSchema,
+ modulePermissionSchema,
+ moduleSchema,
+ type AccessibleModule,
+ type Module,
+ type ModulePermission,
+} from './schemas';
/** Typsichere API-Funktionen für die Modul-Verwaltung. */
@@ -88,6 +95,30 @@ export async function checkModuleHealth(
return apiRequest(`/api/v1/modules/${id}/health`);
}
+/** Module, die der angemeldete Benutzer sehen darf (Dashboard). */
+export async function fetchAccessibleModules(): Promise {
+ const response = await apiRequest<{ modules: unknown[] }>('/api/v1/profile/modules');
+ return response.modules.map((module) => accessibleModuleSchema.parse(module));
+}
+
+/** Modul-Berechtigungen eines Benutzers (Admin). */
+export async function fetchUserModulePermissions(userId: string): Promise {
+ const response = await apiRequest<{ permissions: unknown[]}>(
+ `/api/v1/users/${userId}/modules`,
+ );
+ return response.permissions.map((permission) => modulePermissionSchema.parse(permission));
+}
+
+/** Modul-Zugriff gewähren (Admin). */
+export async function grantModuleAccess(userId: string, moduleId: string): Promise {
+ await apiRequest(`/api/v1/users/${userId}/modules/${moduleId}`, { method: 'POST' });
+}
+
+/** Modul-Zugriff entziehen (Admin). */
+export async function revokeModuleAccess(userId: string, moduleId: string): Promise {
+ await apiRequest(`/api/v1/users/${userId}/modules/${moduleId}`, { method: 'DELETE' });
+}
+
/** Liest das CSRF-Token aus dem nicht-HttpOnly-Cookie. */
function readCsrfToken(): string | null {
for (const part of document.cookie.split(';')) {
diff --git a/apps/platform-frontend/src/lib/schemas.ts b/apps/platform-frontend/src/lib/schemas.ts
index 6b657af..af9e2e2 100644
--- a/apps/platform-frontend/src/lib/schemas.ts
+++ b/apps/platform-frontend/src/lib/schemas.ts
@@ -135,4 +135,24 @@ export const moduleSchema = z.object({
enabled: z.boolean(),
createdAt: z.string(),
});
-export type Module = z.infer;
\ No newline at end of file
+export type Module = z.infer;
+
+/** Modul-Kachel für das Dashboard (/api/v1/profile/modules). */
+export const accessibleModuleSchema = z.object({
+ id: z.string(),
+ moduleId: z.string(),
+ name: z.string(),
+ slug: z.string(),
+ description: z.string(),
+ status: z.enum(MODULE_STATUSES),
+});
+export type AccessibleModule = z.infer;
+
+/** Modul-Berechtigung eines Benutzers. */
+export const modulePermissionSchema = z.object({
+ moduleId: z.string(),
+ moduleSlug: z.string(),
+ moduleName: z.string(),
+ permission: z.enum(['GRANTED', 'DENIED']),
+});
+export type ModulePermission = z.infer;
\ No newline at end of file
diff --git a/docs/PHASES.md b/docs/PHASES.md
index 422b5a7..d6e715d 100644
--- a/docs/PHASES.md
+++ b/docs/PHASES.md
@@ -8,7 +8,7 @@ Der Arbeitsplan sieht zehn inkrementelle Phasen vor. Nach jeder Phase muss das S
| 2 | Benutzerverwaltung | ✅ Abgeschlossen |
| 3 | Modul-System (Manifest, Installation, Lifecycle) | ✅ Abgeschlossen |
| 4 | Gateway & dynamisches Routing (`/slug`) | ✅ Abgeschlossen |
-| 5 | Berechtigungssystem (User ↔ Module) | ⏳ Geplant |
+| 5 | Berechtigungssystem (User ↔ Module) | ✅ Abgeschlossen |
| 6 | Modul-API (`/health`, `/api/manifest`, `/api/me`) | ⏳ Geplant |
| 7 | Referenzmodul Kalender | ⏳ Geplant |
| 8 | Modul-SDK (`platform-module-sdk`) | ⏳ Geplant |
@@ -81,9 +81,21 @@ Definition of Done: Module sind über `/slug` erreichbar, Zugriff nur mit gülti
- [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)
+## Phase 5 – Berechtigungssystem (abgeschlossen)
-- `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
+Definition of Done: User ↔ Module-Zuweisung, Access Control, Permission Middleware, 401/403-Handling.
+
+- [x] `user_module_permissions`-Tabelle (Migration 003, GRANTED/DENIED, UNIQUE user+module, CASCADE)
+- [x] Admin-API: `GET/POST/DELETE /api/v1/users/:userId/modules(/:moduleId)` (nur ADMIN, auditiert)
+- [x] Gateway-Permission-Check an Tabelle angebunden: ADMIN immer, USER nur mit GRANTED (fail-closed)
+- [x] `GET /api/v1/profile/modules`: Dashboard-Module (ADMIN: alle aktivierten, USER: nur freigegebene)
+- [x] Frontend: Dashboard-Modul-Kacheln ausschließlich nach tatsächlichen Berechtigungen
+- [x] Frontend: Berechtigungs-Modal in der Benutzerverwaltung (Freigeben/Entziehen pro Modul)
+- [x] Backend-Tests: 90 bestanden (inkl. ModulePermissionsService, Gateway GRANTED-Fall)
+- [x] Testszenarien aus Arbeitsplan abgedeckt: Admin → alle Module; User ohne Berechtigung → 403; User mit GRANTED → Proxy weitergeleitet
+
+## Nächste Schritte (Phase 6 – Modul-API)
+
+- `GET /health`, `GET /api/manifest`, `GET /api/me` als verbindlicher Modul-Vertrag
+- Zentrale Authentifizierung zwischen Plattform und Modul (Identitäts-Header validieren)
+- Referenzmodul um den vollständigen API-Vertrag erweitern
\ No newline at end of file