feat: Phase 5 – Berechtigungssystem (User-Module-Zuweisung, Gateway-Access-Control)
This commit is contained in:
@@ -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 {}
|
||||
Reference in New Issue
Block a user