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.
|
||||
|
||||
**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`).
|
||||
@@ -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];
|
||||
|
||||
@@ -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,
|
||||
];
|
||||
@@ -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 { 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');
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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 { 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. */
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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 {}
|
||||
@@ -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,
|
||||
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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
|
||||
|
||||
@@ -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(';')) {
|
||||
|
||||
@@ -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>;
|
||||
@@ -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
|
||||
Reference in New Issue
Block a user