feat: Phase 5 – Berechtigungssystem (User-Module-Zuweisung, Gateway-Access-Control)
This commit is contained in:
@@ -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`).
|
||||||
@@ -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];
|
||||||
|
|||||||
@@ -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,
|
||||||
|
];
|
||||||
@@ -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)',
|
||||||
|
);
|
||||||
|
},
|
||||||
|
};
|
||||||
@@ -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');
|
||||||
|
|||||||
@@ -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',
|
||||||
|
|||||||
@@ -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,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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 };
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
126
apps/platform-backend/src/modules/module-permissions.service.ts
Normal file
126
apps/platform-backend/src/modules/module-permissions.service.ts
Normal 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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. */
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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 {}
|
||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|
||||||
|
|||||||
@@ -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(';')) {
|
||||||
|
|||||||
@@ -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>;
|
||||||
@@ -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
|
||||||
Reference in New Issue
Block a user