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.
**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`).

View File

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

View File

@@ -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];
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 { 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<boolean> {
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');

View File

@@ -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',

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 { 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. */

View File

@@ -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,

View File

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

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,
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<User | null>(null);
const [resetPasswordUser, setResetPasswordUser] = useState<User | null>(null);
const [deleteTarget, setDeleteTarget] = useState<User | null>(null);
const [permissionsUser, setPermissionsUser] = useState<User | null>(null);
const usersQuery = useQuery({
queryKey: ['users'],
@@ -466,6 +468,9 @@ export function UsersPage(): ReactNode {
<Button size="sm" variant="ghost" onClick={() => setResetPasswordUser(user)}>
Passwort
</Button>
<Button size="sm" variant="ghost" onClick={() => setPermissionsUser(user)}>
Module
</Button>
{user.id !== currentUser?.id && (
<>
<Button
@@ -511,6 +516,9 @@ export function UsersPage(): ReactNode {
{deleteTarget && (
<DeleteUserModal user={deleteTarget} onClose={() => setDeleteTarget(null)} />
)}
{permissionsUser && (
<UserPermissionsModal user={permissionsUser} onClose={() => setPermissionsUser(null)} />
)}
</div>
);
}

View File

@@ -2,16 +2,22 @@ import { type ReactNode } from 'react';
import { useQuery } from '@tanstack/react-query';
import { useAuth } from '../auth/auth-context';
import { apiRequest } from '../../lib/api-client';
import { fetchAccessibleModules } from '../../lib/modules-api';
import { healthSchema, type Health } from '../../lib/schemas';
import { Card, CardBody, CardHeader } from '../../components/ui/card';
import { Badge } from '../../components/ui/badge';
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 {
const { user } = useAuth();
const isAdmin = user?.role === 'ADMIN';
const modulesQuery = useQuery({
queryKey: ['accessible-modules'],
queryFn: fetchAccessibleModules,
});
const healthQuery = useQuery({
queryKey: ['health'],
queryFn: async (): Promise<Health> => healthSchema.parse(
@@ -32,18 +38,55 @@ export function DashboardPage(): ReactNode {
</p>
</div>
{/* Meine Anwendungen (Modul-Kacheln; ab Phase 3 aus Berechtigungen) */}
{/* Meine Anwendungen: Kacheln ausschließlich nach tatsächlichen Berechtigungen */}
<Card>
<CardHeader
title="Meine Anwendungen"
description="Freigegebene Module der Plattform"
/>
<CardBody>
<EmptyState
title="Noch keine Module installiert"
description="Sobald der Administrator Module installiert und Ihnen zugewiesen hat, erscheinen diese hier als Kacheln."
icon={<span className="text-3xl" aria-hidden="true">🧩</span>}
/>
{modulesQuery.isLoading && <Spinner label="Anwendungen werden geladen…" />}
{modulesQuery.isError && (
<ErrorState
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>
</Card>

View File

@@ -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<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. */
function readCsrfToken(): string | null {
for (const part of document.cookie.split(';')) {

View File

@@ -136,3 +136,23 @@ export const moduleSchema = z.object({
createdAt: z.string(),
});
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 |
| 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
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