feat: Phase 5 – Berechtigungssystem (User-Module-Zuweisung, Gateway-Access-Control)

This commit is contained in:
MPM Dev
2026-10-07 16:10:15 +02:00
parent 5d775c317a
commit 4d7610328c
19 changed files with 938 additions and 29 deletions

View File

@@ -2,7 +2,7 @@
Zentrale, webbasierte Management-Plattform, über die eigenständige Web-Applikationen als **Module** integriert, verwaltet und Benutzern zugewiesen werden können. 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 ## Architektur-Überblick
@@ -103,6 +103,9 @@ Modul-Registry mit Manifest-Vertrag (`module.json`, Zod-validiert), ZIP-Installa
### Phase 4 – Gateway & Routing ### 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. 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 ## Annahme
„ChatCM" wurde als **shadcn-artige Komponentenbasis** interpretiert: Tailwind CSS plus zentral gepflegte, wiederverwendbare UI-Komponenten (`apps/platform-frontend/src/components/ui`). „ChatCM" wurde als **shadcn-artige Komponentenbasis** interpretiert: Tailwind CSS plus zentral gepflegte, wiederverwendbare UI-Komponenten (`apps/platform-frontend/src/components/ui`).

View File

@@ -21,6 +21,8 @@ export const AUDIT_ACTIONS = {
MODULE_RESTARTED: 'MODULE_RESTARTED', MODULE_RESTARTED: 'MODULE_RESTARTED',
MODULE_ENABLED: 'MODULE_ENABLED', MODULE_ENABLED: 'MODULE_ENABLED',
MODULE_DISABLED: 'MODULE_DISABLED', MODULE_DISABLED: 'MODULE_DISABLED',
PERMISSION_GRANTED: 'PERMISSION_GRANTED',
PERMISSION_REVOKED: 'PERMISSION_REVOKED',
} as const; } as const;
export type AuditAction = (typeof AUDIT_ACTIONS)[keyof typeof AUDIT_ACTIONS]; export type AuditAction = (typeof AUDIT_ACTIONS)[keyof typeof AUDIT_ACTIONS];

View File

@@ -1,5 +1,10 @@
import { migration001CoreSchema } from './001-core-schema'; import { migration001CoreSchema } from './001-core-schema';
import { migration002Modules } from '../../modules/migrations/002-modules'; import { migration002Modules } from '../../modules/migrations/002-modules';
import { migration003ModulePermissions } from '../../modules/migrations/003-module-permissions';
/** Registrierte Migrationen in aufsteigender Reihenfolge. */ /** Registrierte Migrationen in aufsteigender Reihenfolge. */
export const MIGRATIONS = [migration001CoreSchema, migration002Modules]; export const MIGRATIONS = [
migration001CoreSchema,
migration002Modules,
migration003ModulePermissions,
];

View File

@@ -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)',
);
},
};

View File

@@ -5,6 +5,7 @@ import { UserRepository } from '../users/user.repository';
import type { UserRecord } from '../users/user.types'; import type { UserRecord } from '../users/user.types';
import { ModuleRepository } from './module.repository'; import { ModuleRepository } from './module.repository';
import type { ModuleRecord } from './manifest.types'; import type { ModuleRecord } from './manifest.types';
import { ModulePermissionsService } from './module-permissions.service';
import { ModuleGatewayMiddleware } from './module-gateway.middleware'; import { ModuleGatewayMiddleware } from './module-gateway.middleware';
/** Erzeugt einen Modul-Datensatz für Tests. */ /** Erzeugt einen Modul-Datensatz für Tests. */
@@ -99,10 +100,20 @@ class MockModuleRepository {
} }
} }
/** Mock des ModulePermissionsService. */
class MockPermissionsService {
public hasAccessResult = false;
async hasAccess(): Promise<boolean> {
return this.hasAccessResult;
}
}
describe('ModuleGatewayMiddleware', () => { describe('ModuleGatewayMiddleware', () => {
let sessionService: MockSessionService; let sessionService: MockSessionService;
let userRepository: MockUserRepository; let userRepository: MockUserRepository;
let moduleRepository: MockModuleRepository; let moduleRepository: MockModuleRepository;
let permissionsService: MockPermissionsService;
let middleware: ModuleGatewayMiddleware; let middleware: ModuleGatewayMiddleware;
const nextCalls: NextFunction[] = []; const nextCalls: NextFunction[] = [];
@@ -116,10 +127,12 @@ describe('ModuleGatewayMiddleware', () => {
sessionService = new MockSessionService(); sessionService = new MockSessionService();
userRepository = new MockUserRepository(); userRepository = new MockUserRepository();
moduleRepository = new MockModuleRepository(); moduleRepository = new MockModuleRepository();
permissionsService = new MockPermissionsService();
middleware = new ModuleGatewayMiddleware( middleware = new ModuleGatewayMiddleware(
moduleRepository as unknown as ModuleRepository, moduleRepository as unknown as ModuleRepository,
sessionService as unknown as SessionService, sessionService as unknown as SessionService,
userRepository as unknown as UserRepository, userRepository as unknown as UserRepository,
permissionsService as unknown as ModulePermissionsService,
); );
sessionService.session = { sessionService.session = {
id: 'session-1', id: 'session-1',
@@ -192,6 +205,7 @@ describe('ModuleGatewayMiddleware', () => {
}); });
it('antwortet 403 für USER ohne Berechtigung (fail-closed)', async () => { 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 request = createRequest('/api/v1/gateway/demo/', 'mpm_session=valid');
const response = createResponse(); const response = createResponse();
@@ -199,6 +213,24 @@ describe('ModuleGatewayMiddleware', () => {
expect(response.sentStatus).toBe(403); 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 () => { it('leitet ADMIN-Requests an den Modul-Proxy weiter', async () => {
userRepository.user = createUserRecord({ role: 'ADMIN' }); userRepository.user = createUserRecord({ role: 'ADMIN' });
const request = createRequest('/api/v1/gateway/demo/health', 'mpm_session=valid'); const request = createRequest('/api/v1/gateway/demo/health', 'mpm_session=valid');

View File

@@ -5,20 +5,20 @@ import { SessionService } from '../auth/session.service';
import { extractSessionToken } from '../auth/guards/session.guard'; import { extractSessionToken } from '../auth/guards/session.guard';
import { UserRepository } from '../users/user.repository'; import { UserRepository } from '../users/user.repository';
import { ModuleRepository } from './module.repository'; import { ModuleRepository } from './module.repository';
import { ModulePermissionsService } from './module-permissions.service';
/** Gateway-Pfad-Präfix für interne Nginx-Weiterleitung. */ /** Gateway-Pfad-Präfix für interne Nginx-Weiterleitung. */
const GATEWAY_PREFIX = '/api/v1/gateway/'; 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 * Sicherheitskritischer Request-Flow (Nginx leitet /slug intern auf
* /api/v1/gateway/slug/* um): * /api/v1/gateway/slug/* um):
* 1. Session prüfen (401 ohne Login) * 1. Session prüfen (401 ohne Login)
* 2. Modul anhand Slug suchen (404) * 2. Modul anhand Slug suchen (404)
* 3. Modul muss RUNNING und enabled sein (503) * 3. Modul muss RUNNING und enabled sein (503)
* 4. Permission-Check: ADMIN darf alles, USER nur freigegebene * 4. Permission-Check (403): ADMIN immer, USER nur mit GRANTED
* Module (403) – bis Phase 5 fail-closed für USERs
* 5. Proxy zum internen Port (nie öffentlich erreichbar) * 5. Proxy zum internen Port (nie öffentlich erreichbar)
* *
* Das Modul selbst vertraut nie allein auf die URL – die Plattform * 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 moduleRepository: ModuleRepository,
private readonly sessionService: SessionService, private readonly sessionService: SessionService,
private readonly userRepository: UserRepository, private readonly userRepository: UserRepository,
private readonly permissionsService: ModulePermissionsService,
) { ) {
this.proxy = httpProxy.createProxyServer({ this.proxy = httpProxy.createProxyServer({
proxyTimeout: 30_000, proxyTimeout: 30_000,
@@ -100,11 +101,11 @@ export class ModuleGatewayMiddleware implements NestMiddleware {
return; return;
} }
// 4. Permission-Check (Ebene 2 – Modul-Rechte) // 4. Permission-Check (Ebene 2 – Modul-Rechte):
// ADMIN: voller Zugriff. USER: nur mit GRANTED-Berechtigung // ADMIN hat immer Zugriff; USER nur mit GRANTED-Berechtigung.
// (user_module_permissions folgt in Phase 5; bis dahin const hasAccess =
// fail-closed – USERs erhalten keinen Zugriff). user.role === 'ADMIN' || (await this.permissionsService.hasAccess(user.id, module));
if (user.role !== 'ADMIN') { if (!hasAccess) {
response.status(403).json({ response.status(403).json({
statusCode: 403, statusCode: 403,
message: 'Keine Berechtigung für dieses Modul', message: 'Keine Berechtigung für dieses Modul',

View File

@@ -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<ModulePermissionRecord[]> {
const result = await this.database.query<PermissionRow>(
`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<ModulePermissionRecord | null> {
const result = await this.database.query<PermissionRow>(
`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<ModulePermissionRecord> {
const result = await this.database.query<PermissionRow>(
`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<void> {
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<void> {
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<ModuleRecord[]> {
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,
};
}
}

View File

@@ -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 };
}
}

View File

@@ -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> = {}): 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> = {},
): 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<ModulePermissionRecord[]> {
return this.permissions.filter((permission) => permission.userId === userId);
}
async findByUserAndModule(
userId: string,
moduleId: string,
): Promise<ModulePermissionRecord | null> {
return (
this.permissions.find(
(permission) => permission.userId === userId && permission.moduleId === moduleId,
) ?? null
);
}
async grant(userId: string, moduleId: string): Promise<ModulePermissionRecord> {
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<void> {
this.revoked.push({ userId, moduleId });
this.permissions = this.permissions.filter(
(p) => !(p.userId === userId && p.moduleId === moduleId),
);
}
async listGrantedModules(userId: string): Promise<ModuleRecord[]> {
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<ModuleRecord[]> {
return this.modules;
}
async findById(id: string): Promise<ModuleRecord | null> {
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<UserRecord | null> {
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<void> {
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');
});
});
});

View File

@@ -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<ModulePermissionResponse[]> {
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<ModulePermissionResponse> {
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<void> {
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<boolean> {
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<ModuleRecord[]> {
return this.permissionRepository.listGrantedModules(userId);
}
private async assertUserExists(userId: string): Promise<void> {
const user = await this.userRepository.findById(userId);
if (!user) {
throw new NotFoundException('Benutzer nicht gefunden');
}
}
private async assertModuleExists(moduleId: string): Promise<ModuleRecord> {
const module = await this.moduleRepository.findById(moduleId);
if (!module) {
throw new NotFoundException('Modul nicht gefunden');
}
return module;
}
}

View File

@@ -14,6 +14,9 @@ import { ModuleHealthChecker } from './module-health-checker';
import { ModuleInstaller } from './module-installer'; import { ModuleInstaller } from './module-installer';
import { ModuleProcessManager } from './module-process-manager'; import { ModuleProcessManager } from './module-process-manager';
import { ModuleGatewayMiddleware } from './module-gateway.middleware'; 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 { ModuleRepository } from './module.repository';
import { ModuleStartupRecovery } from './module-startup-recovery'; import { ModuleStartupRecovery } from './module-startup-recovery';
import { ModulesController } from './modules.controller'; import { ModulesController } from './modules.controller';
@@ -22,7 +25,7 @@ import { ModulesService } from './modules.service';
/** Modul-System: Installation, Lifecycle, Prozessverwaltung, Gateway. */ /** Modul-System: Installation, Lifecycle, Prozessverwaltung, Gateway. */
@Module({ @Module({
imports: [ConfigModule, DatabaseModule, AuditModule], imports: [ConfigModule, DatabaseModule, AuditModule],
controllers: [ModulesController], controllers: [ModulesController, ModulePermissionsController],
providers: [ providers: [
ModuleRepository, ModuleRepository,
ModuleInstaller, ModuleInstaller,
@@ -34,8 +37,10 @@ import { ModulesService } from './modules.service';
PasswordHasher, PasswordHasher,
ModuleGatewayMiddleware, ModuleGatewayMiddleware,
ModuleStartupRecovery, ModuleStartupRecovery,
ModulePermissionRepository,
ModulePermissionsService,
], ],
exports: [ModuleRepository, ModulesService], exports: [ModuleRepository, ModulesService, ModulePermissionsService],
}) })
export class ModulesModule implements NestModule { export class ModulesModule implements NestModule {
/** Gateway-Middleware für alle /api/v1/gateway/* Pfade. */ /** Gateway-Middleware für alle /api/v1/gateway/* Pfade. */

View File

@@ -4,6 +4,8 @@ import { ApiTags } from '@nestjs/swagger';
import type { AuthenticatedRequest } from '../auth/authenticated-request'; import type { AuthenticatedRequest } from '../auth/authenticated-request';
import { CurrentUser } from '../common/decorators/current-user.decorator'; import { CurrentUser } from '../common/decorators/current-user.decorator';
import { ZodValidationPipe } from '../common/zod-validation.pipe'; 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 type { AuthUser, RoleName, UserRecord } from './user.types';
import { changePasswordSchema, type ChangePasswordDto } from './user.types'; import { changePasswordSchema, type ChangePasswordDto } from './user.types';
import { ProfileService } from './profile.service'; 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). * Profil des angemeldeten Benutzers (jede Rolle).
* Passwort-Änderung erfordert das aktuelle Passwort. * Passwort-Änderung erfordert das aktuelle Passwort.
@@ -38,7 +50,11 @@ function toProfileResponse(user: UserRecord): ProfileResponse {
@ApiTags('Profile') @ApiTags('Profile')
@Controller({ path: 'api/v1/profile' }) @Controller({ path: 'api/v1/profile' })
export class ProfileController { export class ProfileController {
constructor(private readonly profileService: ProfileService) {} constructor(
private readonly profileService: ProfileService,
private readonly moduleRepository: ModuleRepository,
private readonly permissionsService: ModulePermissionsService,
) {}
@Get() @Get()
async getProfile(@CurrentUser() user: AuthUser): Promise<{ profile: ProfileResponse }> { async getProfile(@CurrentUser() user: AuthUser): Promise<{ profile: ProfileResponse }> {
@@ -46,6 +62,29 @@ export class ProfileController {
return { profile: toProfileResponse(profile) }; 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') @Patch('password')
async changePassword( async changePassword(
@CurrentUser() user: AuthUser, @CurrentUser() user: AuthUser,

View File

@@ -3,6 +3,8 @@ import { ConfigModule } from '../config/config.module';
import { DatabaseModule } from '../database/database.module'; import { DatabaseModule } from '../database/database.module';
import { AuditModule } from '../audit/audit.module'; import { AuditModule } from '../audit/audit.module';
import { SessionService } from '../auth/session.service'; 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 { PasswordHasher } from './password-hasher';
import { ProfileController } from './profile.controller'; import { ProfileController } from './profile.controller';
import { ProfileService } from './profile.service'; import { ProfileService } from './profile.service';
@@ -15,7 +17,16 @@ import { UsersService } from './users.service';
@Module({ @Module({
imports: [ConfigModule, DatabaseModule, AuditModule], imports: [ConfigModule, DatabaseModule, AuditModule],
controllers: [UsersController, ProfileController], controllers: [UsersController, ProfileController],
providers: [UserRepository, PasswordHasher, SeedService, UsersService, ProfileService, SessionService], providers: [
UserRepository,
PasswordHasher,
SeedService,
UsersService,
ProfileService,
SessionService,
ModuleRepository,
ModulePermissionsService,
],
exports: [UserRepository, PasswordHasher], exports: [UserRepository, PasswordHasher],
}) })
export class UsersModule {} export class UsersModule {}

View File

@@ -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<Set<string>>(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 (
<Modal
open
title="Modul-Berechtigungen"
description={`Zugriff von ${user.displayName} (@${user.username}) auf Module verwalten.`}
onClose={onClose}
>
{modulesQuery.isLoading || permissionsQuery.isLoading ? (
<p className="py-4 text-center text-sm text-slate-500">Wird geladen…</p>
) : modulesQuery.isError ? (
<p className="py-4 text-center text-sm text-red-600">
Module konnten nicht geladen werden.
</p>
) : modulesQuery.data && modulesQuery.data.length === 0 ? (
<p className="py-4 text-center text-sm text-slate-500">
Noch keine Module installiert.
</p>
) : (
<ul className="space-y-2">
{modulesQuery.data?.map((module) => {
const isGranted = grantedModuleIds.has(module.id);
return (
<li
key={module.id}
className="flex items-center justify-between rounded-lg border border-slate-200 px-4 py-3"
>
<div className="min-w-0">
<p className="text-sm font-medium text-slate-900">{module.name}</p>
<p className="text-xs text-slate-500">
/{module.slug} · Version {module.version}
</p>
</div>
<Button
size="sm"
variant={isGranted ? 'danger' : 'primary'}
loading={
toggleMutation.isPending &&
toggleMutation.variables?.module.id === module.id
}
onClick={() => handleToggle(module)}
>
{isGranted ? 'Entziehen' : 'Freigeben'}
</Button>
</li>
);
})}
</ul>
)}
</Modal>
);
}

View File

@@ -14,6 +14,7 @@ import {
resetUserPassword, resetUserPassword,
updateUser, updateUser,
} from '../../lib/users-api'; } from '../../lib/users-api';
import { UserPermissionsModal } from './user-permissions-modal';
import { import {
createUserSchema, createUserSchema,
resetPasswordSchema, resetPasswordSchema,
@@ -370,6 +371,7 @@ export function UsersPage(): ReactNode {
const [editUser, setEditUser] = useState<User | null>(null); const [editUser, setEditUser] = useState<User | null>(null);
const [resetPasswordUser, setResetPasswordUser] = useState<User | null>(null); const [resetPasswordUser, setResetPasswordUser] = useState<User | null>(null);
const [deleteTarget, setDeleteTarget] = useState<User | null>(null); const [deleteTarget, setDeleteTarget] = useState<User | null>(null);
const [permissionsUser, setPermissionsUser] = useState<User | null>(null);
const usersQuery = useQuery({ const usersQuery = useQuery({
queryKey: ['users'], queryKey: ['users'],
@@ -466,6 +468,9 @@ export function UsersPage(): ReactNode {
<Button size="sm" variant="ghost" onClick={() => setResetPasswordUser(user)}> <Button size="sm" variant="ghost" onClick={() => setResetPasswordUser(user)}>
Passwort Passwort
</Button> </Button>
<Button size="sm" variant="ghost" onClick={() => setPermissionsUser(user)}>
Module
</Button>
{user.id !== currentUser?.id && ( {user.id !== currentUser?.id && (
<> <>
<Button <Button
@@ -511,6 +516,9 @@ export function UsersPage(): ReactNode {
{deleteTarget && ( {deleteTarget && (
<DeleteUserModal user={deleteTarget} onClose={() => setDeleteTarget(null)} /> <DeleteUserModal user={deleteTarget} onClose={() => setDeleteTarget(null)} />
)} )}
{permissionsUser && (
<UserPermissionsModal user={permissionsUser} onClose={() => setPermissionsUser(null)} />
)}
</div> </div>
); );
} }

View File

@@ -2,16 +2,22 @@ import { type ReactNode } from 'react';
import { useQuery } from '@tanstack/react-query'; import { useQuery } from '@tanstack/react-query';
import { useAuth } from '../auth/auth-context'; import { useAuth } from '../auth/auth-context';
import { apiRequest } from '../../lib/api-client'; import { apiRequest } from '../../lib/api-client';
import { fetchAccessibleModules } from '../../lib/modules-api';
import { healthSchema, type Health } from '../../lib/schemas'; import { healthSchema, type Health } from '../../lib/schemas';
import { Card, CardBody, CardHeader } from '../../components/ui/card'; import { Card, CardBody, CardHeader } from '../../components/ui/card';
import { Badge } from '../../components/ui/badge'; import { Badge } from '../../components/ui/badge';
import { EmptyState, ErrorState, Spinner } from '../../components/ui/states'; import { EmptyState, ErrorState, Spinner } from '../../components/ui/states';
/** Dashboard: Begrüßung, eigene Anwendungen (ab Phase 3) und Systemstatus. */ /** Dashboard: Begrüßung, eigene Anwendungen (nach Berechtigungen) und Systemstatus. */
export function DashboardPage(): ReactNode { export function DashboardPage(): ReactNode {
const { user } = useAuth(); const { user } = useAuth();
const isAdmin = user?.role === 'ADMIN'; const isAdmin = user?.role === 'ADMIN';
const modulesQuery = useQuery({
queryKey: ['accessible-modules'],
queryFn: fetchAccessibleModules,
});
const healthQuery = useQuery({ const healthQuery = useQuery({
queryKey: ['health'], queryKey: ['health'],
queryFn: async (): Promise<Health> => healthSchema.parse( queryFn: async (): Promise<Health> => healthSchema.parse(
@@ -32,18 +38,55 @@ export function DashboardPage(): ReactNode {
</p> </p>
</div> </div>
{/* Meine Anwendungen (Modul-Kacheln; ab Phase 3 aus Berechtigungen) */} {/* Meine Anwendungen: Kacheln ausschließlich nach tatsächlichen Berechtigungen */}
<Card> <Card>
<CardHeader <CardHeader
title="Meine Anwendungen" title="Meine Anwendungen"
description="Freigegebene Module der Plattform" description="Freigegebene Module der Plattform"
/> />
<CardBody> <CardBody>
<EmptyState {modulesQuery.isLoading && <Spinner label="Anwendungen werden geladen…" />}
title="Noch keine Module installiert" {modulesQuery.isError && (
description="Sobald der Administrator Module installiert und Ihnen zugewiesen hat, erscheinen diese hier als Kacheln." <ErrorState
icon={<span className="text-3xl" aria-hidden="true">🧩</span>} title="Anwendungen nicht verfügbar"
/> message="Die Module konnten nicht geladen werden."
/>
)}
{modulesQuery.data && modulesQuery.data.length === 0 && (
<EmptyState
title="Noch keine Module freigegeben"
description="Sobald der Administrator Ihnen Module zugewiesen hat, erscheinen diese hier als Kacheln."
icon={<span className="text-3xl" aria-hidden="true">🧩</span>}
/>
)}
{modulesQuery.data && modulesQuery.data.length > 0 && (
<div className="grid gap-4 sm:grid-cols-2 lg:grid-cols-3">
{modulesQuery.data.map((module) => (
<a
key={module.id}
href={`/${module.slug}`}
className="group rounded-xl border border-slate-200 p-5 transition-colors hover:border-brand-300 hover:bg-brand-50/50"
>
<div className="flex items-start justify-between">
<span className="text-2xl" aria-hidden="true">🧩</span>
<Badge variant={module.status === 'RUNNING' ? 'success' : 'neutral'}>
{module.status === 'RUNNING' ? 'Verfügbar' : module.status}
</Badge>
</div>
<h3 className="mt-3 text-base font-semibold text-slate-900 group-hover:text-brand-700">
{module.name}
</h3>
{module.description && (
<p className="mt-1 text-sm text-slate-500">{module.description}</p>
)}
<span className="mt-4 inline-flex items-center gap-1 text-sm font-medium text-brand-600">
Öffnen
<span aria-hidden="true" className="transition-transform group-hover:translate-x-0.5">→</span>
</span>
</a>
))}
</div>
)}
</CardBody> </CardBody>
</Card> </Card>

View File

@@ -1,5 +1,12 @@
import { apiRequest, ApiError } from './api-client'; 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. */ /** Typsichere API-Funktionen für die Modul-Verwaltung. */
@@ -88,6 +95,30 @@ export async function checkModuleHealth(
return apiRequest(`/api/v1/modules/${id}/health`); return apiRequest(`/api/v1/modules/${id}/health`);
} }
/** Module, die der angemeldete Benutzer sehen darf (Dashboard). */
export async function fetchAccessibleModules(): Promise<AccessibleModule[]> {
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<ModulePermission[]> {
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<void> {
await apiRequest(`/api/v1/users/${userId}/modules/${moduleId}`, { method: 'POST' });
}
/** Modul-Zugriff entziehen (Admin). */
export async function revokeModuleAccess(userId: string, moduleId: string): Promise<void> {
await apiRequest(`/api/v1/users/${userId}/modules/${moduleId}`, { method: 'DELETE' });
}
/** Liest das CSRF-Token aus dem nicht-HttpOnly-Cookie. */ /** Liest das CSRF-Token aus dem nicht-HttpOnly-Cookie. */
function readCsrfToken(): string | null { function readCsrfToken(): string | null {
for (const part of document.cookie.split(';')) { for (const part of document.cookie.split(';')) {

View File

@@ -136,3 +136,23 @@ export const moduleSchema = z.object({
createdAt: z.string(), createdAt: z.string(),
}); });
export type Module = z.infer<typeof moduleSchema>; export type Module = z.infer<typeof moduleSchema>;
/** 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<typeof accessibleModuleSchema>;
/** 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<typeof modulePermissionSchema>;

View File

@@ -8,7 +8,7 @@ Der Arbeitsplan sieht zehn inkrementelle Phasen vor. Nach jeder Phase muss das S
| 2 | Benutzerverwaltung | ✅ Abgeschlossen | | 2 | Benutzerverwaltung | ✅ Abgeschlossen |
| 3 | Modul-System (Manifest, Installation, Lifecycle) | ✅ Abgeschlossen | | 3 | Modul-System (Manifest, Installation, Lifecycle) | ✅ Abgeschlossen |
| 4 | Gateway & dynamisches Routing (`/slug`) | ✅ 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 | | 6 | Modul-API (`/health`, `/api/manifest`, `/api/me`) | ⏳ Geplant |
| 7 | Referenzmodul Kalender | ⏳ Geplant | | 7 | Referenzmodul Kalender | ⏳ Geplant |
| 8 | Modul-SDK (`platform-module-sdk`) | ⏳ 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] 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 - [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) Definition of Done: User ↔ Module-Zuweisung, Access Control, Permission Middleware, 401/403-Handling.
- Admin-API: Benutzer ↔ Module zuweisen (`POST /api/v1/users/:id/modules/:moduleId`)
- Gateway-Permission-Check an die Tabelle anbinden (USER mit GRANTED → Zugriff) - [x] `user_module_permissions`-Tabelle (Migration 003, GRANTED/DENIED, UNIQUE user+module, CASCADE)
- Dashboard: Modul-Kacheln anhand der tatsächlichen Berechtigungen - [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