9a4ba8a33c
- ModuleAccessService.getAccessibleModuleIds(tenantId, userId, role): ADMIN/SUPER_ADMIN bypass (D-03) via one query, otherwise a single Promise.all of direct + group ModuleGrant lookups intersected against active TenantModuleActivation (D-02) — no N+1 over the user's groups - findAccessibleModules() adds the name-asc sort for stable sidebar order - ModuleGuard now resolves userId/role from request.user (JWT-sourced, never body/params) and calls getAccessibleModuleIds instead of the tenant-only isModuleActive check; caches the result on request.moduleAccessIds for same-request reuse (D-09, no cross-request caching) - ModuleRegistryController.findActive delegates to ModuleAccessService.findAccessibleModules instead of findActiveForTenant, which stays untouched for Plan 15-03's tenant-wide marketplace catalog - ModuleRegistryModule exports ModuleAccessService for Plan 15-03/15-05 - module-access.service.spec.ts / module.guard.spec.ts cover every case in the plan's <behavior> list with a hand-rolled Prisma mock - End-to-end verified against the running local API: a USER without a grant gets 403 on a @UseModule-protected endpoint and an empty /modules/active list; the same USER with a direct grant gets 200 plus the slug in the list; an ADMIN without any grant also gets 200 (D-03)
110 lines
3.2 KiB
TypeScript
110 lines
3.2 KiB
TypeScript
import {
|
|
applyDecorators,
|
|
CanActivate,
|
|
ExecutionContext,
|
|
ForbiddenException,
|
|
Injectable,
|
|
SetMetadata,
|
|
UseGuards,
|
|
} from '@nestjs/common';
|
|
import { Reflector } from '@nestjs/core';
|
|
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';
|
|
|
|
/**
|
|
* 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 TenantMiddleware/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).
|
|
*/
|
|
@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 accessibleModuleIds = await this.moduleAccessService.getAccessibleModuleIds(
|
|
tenantId,
|
|
userId,
|
|
role,
|
|
);
|
|
|
|
if (!accessibleModuleIds.has(module.id)) {
|
|
throw new ForbiddenException(
|
|
`Module '${moduleSlug}' is not accessible for this user`,
|
|
);
|
|
}
|
|
|
|
// Per-Request-Memoisierung (D-09): ein nachfolgender Handler im
|
|
// selben Request bezahlt die Auflösung nicht ein zweites Mal. Über
|
|
// Request-Grenzen hinweg wird nichts zwischengespeichert.
|
|
request.moduleAccessIds = accessibleModuleIds;
|
|
|
|
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),
|
|
);
|
|
}
|