Files
tessera-ctl/apps/api/src/module-registry/module.guard.ts
T
schalli a222711ad9 feat(module-grants): Freigabestufe Verwalten – Datenbank, Zugriffsprüfung und Kantinen-Einstellungen
- Migration: ModuleGrant.level (USE/MANAGE), Bestand bleibt USE
- ModuleAccessService.getModuleAccessLevels als einzige Auflösung, MANAGE gewinnt
- @ModuleManage(slug) am ModuleGuard, GET /modules/active liefert canManage
- Kantinenabrechnung: Einstellungen für Benutzer mit Verwalten, Web-Hook useCanManageModule

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-02 13:28:24 +02:00

166 lines
5.3 KiB
TypeScript

import {
applyDecorators,
CanActivate,
ExecutionContext,
ForbiddenException,
Injectable,
SetMetadata,
UseGuards,
} from '@nestjs/common';
import { Reflector } from '@nestjs/core';
import { ModuleGrantLevel } from '@prisma/client';
import { ModuleAccessService } from './module-access.service';
import { ModuleRegistryService } from './module-registry.service';
/**
* Metadata key for the module slug attached by @UseModule().
*/
export const MODULE_SLUG_KEY = 'moduleSlug';
/**
* Metadata key set by @ModuleManage(): the route needs the Freigabestufe
* Verwalten (MANAGE) for the module, not just access (261002-icv).
*/
export const MODULE_MANAGE_KEY = 'moduleManage';
/**
* Guard that checks whether the requesting user has access to the module
* identified by its slug — Aktivierung UND (Rolle ODER Direkt-Grant ODER
* Gruppen-Grant), D-01.
*
* Per T-03-04/T-15-10: tenantId, userId und role stammen ausschließlich
* aus dem validierten JWT (via TenantGuard/JwtAuthGuard), nie aus
* Body oder Params — verhindert Elevation of Privilege.
*
* T-15-03: Ohne `@UseModule(slug)`-Metadaten gibt der Guard bewusst
* `true` zurück (Durchsetzung hängt am Dekorator) — jeder neue
* Modul-Controller MUSS `@UseModule` tragen (Projektregel seit Phase 3).
*
* 261002-icv: Trägt die Route zusätzlich `@ModuleManage(slug)`, genügt
* Zugriff allein nicht — die wirksame Freigabestufe muss Verwalten sein
* (Administratoren erfüllen das über den Kurzschluss in
* `getModuleAccessLevels`). Die Stufe wird serverseitig aus den ModuleGrant-
* Zeilen aufgelöst, nie aus Body/Query (T-icv-02), und nur für das Modul
* der Route (T-icv-04).
*/
@Injectable()
export class ModuleGuard implements CanActivate {
constructor(
private readonly reflector: Reflector,
private readonly moduleRegistryService: ModuleRegistryService,
private readonly moduleAccessService: ModuleAccessService,
) {}
async canActivate(context: ExecutionContext): Promise<boolean> {
// Get moduleSlug from metadata (set by @UseModule decorator)
const moduleSlug = this.reflector.getAllAndOverride<string>(
MODULE_SLUG_KEY,
[context.getHandler(), context.getClass()],
);
// If no module slug is set, allow (guard is not applicable)
if (!moduleSlug) {
return true;
}
const request = context.switchToHttp().getRequest();
const tenantId = request.tenantId ?? request.user?.tenantId;
if (!tenantId) {
throw new ForbiddenException('No tenant context');
}
const userId = request.user?.id;
const role = request.user?.role;
if (!userId || !role) {
throw new ForbiddenException('No user context');
}
const module = await this.moduleRegistryService.findBySlug(moduleSlug);
if (!module) {
throw new ForbiddenException(
`Module '${moduleSlug}' is not activated for this tenant`,
);
}
const requireManage =
this.reflector.getAllAndOverride<boolean>(MODULE_MANAGE_KEY, [
context.getHandler(),
context.getClass(),
]) === true;
// Per-Request-Memoisierung (D-09): ein Klassen-@UseModule plus ein
// Handler-@ModuleManage lassen diesen Guard zweimal pro Request laufen;
// die Aufloesung bezahlt nur der erste Lauf. Ueber Request-Grenzen
// hinweg wird nichts zwischengespeichert.
const levels: Map<string, ModuleGrantLevel> =
request.moduleAccessLevels instanceof Map
? request.moduleAccessLevels
: await this.moduleAccessService.getModuleAccessLevels(tenantId, userId, role);
const level = levels.get(module.id);
if (!level) {
throw new ForbiddenException(
`Module '${moduleSlug}' is not accessible for this user`,
);
}
if (requireManage && level !== ModuleGrantLevel.MANAGE) {
throw new ForbiddenException(
`Module '${moduleSlug}' requires manage permission`,
);
}
request.moduleAccessLevels = levels;
request.moduleAccessIds = new Set(levels.keys());
return true;
}
}
/**
* Decorator that protects a controller or route handler with the ModuleGuard.
* Ensures the specified module is accessible for the requesting user.
*
* Usage:
* @UseModule('domaincheck')
* @Controller('domaincheck')
* export class DomaincheckController { ... }
*/
export function UseModule(slug: string) {
return applyDecorators(
SetMetadata(MODULE_SLUG_KEY, slug),
UseGuards(ModuleGuard),
);
}
/**
* Decorator fuer modul-eigene Konfiguration: verlangt Zugriff auf das Modul
* UND die Freigabestufe Verwalten (261002-icv). Ersetzt
* `@Roles(ADMIN, SUPER_ADMIN)` fuer Handler, die nur dieses eine Modul
* konfigurieren.
*
* Verwendbar auf einem Handler innerhalb eines `@UseModule`-Controllers oder
* auf einem ganzen Controller. Administratoren bestehen ueber den
* D-03-Kurzschluss (sie loesen auf allen aktiven Modulen zu MANAGE auf).
*
* Niemals zusammen mit `@Roles` am selben Handler: der globale RolesGuard
* wuerde Verwalter trotzdem sperren. Mandant, Benutzer und Rolle stammen
* ausschliesslich aus dem JWT (T-15-10).
*
* Usage:
* @ModuleManage('kantine-datev')
* @Put('settings')
* saveSettings(...) { ... }
*/
export function ModuleManage(slug: string) {
return applyDecorators(
SetMetadata(MODULE_SLUG_KEY, slug),
SetMetadata(MODULE_MANAGE_KEY, true),
UseGuards(ModuleGuard),
);
}