a222711ad9
- 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>
166 lines
5.3 KiB
TypeScript
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),
|
|
);
|
|
}
|