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

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