From a222711ad9d436f2375af865e3e5079c70dc6d62 Mon Sep 17 00:00:00 2001 From: Schalli Date: Fri, 2 Oct 2026 13:28:24 +0200 Subject: [PATCH] =?UTF-8?q?feat(module-grants):=20Freigabestufe=20Verwalte?= =?UTF-8?q?n=20=E2=80=93=20Datenbank,=20Zugriffspr=C3=BCfung=20und=20Kanti?= =?UTF-8?q?nen-Einstellungen?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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) --- .../migration.sql | 28 +++++ apps/api/prisma/schema.prisma | 14 ++- .../dto/create-module-grant.dto.spec.ts | 25 +++++ .../src/groups/dto/create-module-grant.dto.ts | 11 +- apps/api/src/groups/migration-sql.spec.ts | 14 +++ .../src/groups/module-grants.service.spec.ts | 83 ++++++++++++++ apps/api/src/groups/module-grants.service.ts | 58 +++++++--- .../kantine-datev.controller.spec.ts | 22 ++-- .../kantine-datev/kantine-datev.controller.ts | 9 +- .../module-access.service.spec.ts | 106 +++++++++++++++++- .../module-registry/module-access.service.ts | 105 ++++++++++++----- .../src/module-registry/module.guard.spec.ts | 106 +++++++++++++++--- apps/api/src/module-registry/module.guard.ts | 76 +++++++++++-- .../kantine-datev/kantine-datev.test.tsx | 32 ++++++ .../(portal)/modules/kantine-datev/page.tsx | 20 ++-- apps/web/src/lib/api.ts | 2 + apps/web/src/lib/use-module-capability.ts | 56 +++++++++ 17 files changed, 664 insertions(+), 103 deletions(-) create mode 100644 apps/api/prisma/migrations/20261002140000_module_grant_level/migration.sql create mode 100644 apps/api/src/groups/dto/create-module-grant.dto.spec.ts create mode 100644 apps/web/src/lib/use-module-capability.ts diff --git a/apps/api/prisma/migrations/20261002140000_module_grant_level/migration.sql b/apps/api/prisma/migrations/20261002140000_module_grant_level/migration.sql new file mode 100644 index 0000000..f1f2dd1 --- /dev/null +++ b/apps/api/prisma/migrations/20261002140000_module_grant_level/migration.sql @@ -0,0 +1,28 @@ +-- 261002-icv — Freigabestufe fuer Modul-Freigaben: Benutzen (USE) und +-- Verwalten (MANAGE). +-- +-- Zweck: jede Zeile in "ModuleGrant" bekommt eine Stufe. USE ist der Bestand +-- und der Standard (Modul oeffnen und benutzen). MANAGE erlaubt zusaetzlich, +-- die eigenen Einstellungen dieses einen Moduls zu aendern. Freigaben +-- erteilen, Module aktivieren und die uebrige Verwaltung bleiben +-- Administratoren vorbehalten (das erzwingt die Anwendung, nicht diese +-- Migration). +-- +-- Bestandsdaten: durch den DEFAULT 'USE' werden alle vorhandenen Freigaben zu +-- USE — niemand gewinnt durch die Migration Rechte. +-- +-- Von Hand geschrieben (Vorbild 20261002120000_kantine_datev_config). +-- +-- Zeilenschutz: keine neue Tabelle. Die vorhandenen Regeln auf "ModuleGrant" +-- filtern Zeilen, nicht Spalten, und bleiben unveraendert — rls-coverage +-- braucht nichts. PostgreSQL gewaehrt USAGE auf neue Typen automatisch an +-- PUBLIC, die Anwendungsrolle tessera_app kann den Aufzaehlungstyp also +-- verwenden. +-- +-- WICHTIG: wie alle bisherigen RLS-Migrationen wirken die Zeilenregeln erst, +-- wenn die Anwendung als Rolle ohne Umgehungsrecht verbindet (Schalter +-- heute AUS, siehe docs/mandantentrennung-datenbankrolle.md). + +CREATE TYPE "ModuleGrantLevel" AS ENUM ('USE', 'MANAGE'); + +ALTER TABLE "ModuleGrant" ADD COLUMN "level" "ModuleGrantLevel" NOT NULL DEFAULT 'USE'; diff --git a/apps/api/prisma/schema.prisma b/apps/api/prisma/schema.prisma index 981e2e6..58b067e 100644 --- a/apps/api/prisma/schema.prisma +++ b/apps/api/prisma/schema.prisma @@ -140,12 +140,20 @@ model TenantModuleActivation { // darf höchstens eine Gruppe die Standard-Markierung tragen, DB-erzwungen // über einen partiellen Unique-Index in der Hand-SQL-Ergänzung dieser // Migration (Prisma 6.19 kennt keine partiellen Indizes ohne Preview-Flag). -// D-04: ModuleGrant trägt bewusst KEIN Rechtestufen-Feld — nur Zugriff an/aus. +// D-04 (überholt durch 261002-icv): ModuleGrant trägt seit 261002-icv die Freigabestufe `level`. enum MembershipSource { MANUAL LDAP } +// 261002-icv: Freigabestufe einer Modul-Freigabe. USE = Benutzen (Standard und +// Bestand), MANAGE = Verwalten (Modul benutzen UND dessen eigene Einstellungen +// ändern). Freigaben erteilen bleibt Administratoren vorbehalten. +enum ModuleGrantLevel { + USE + MANAGE +} + model Group { id String @id @default(uuid()) tenantId String @@ -191,6 +199,10 @@ model ModuleGrant { userId String? user User? @relation(fields: [userId], references: [id], onDelete: Cascade) createdAt DateTime @default(now()) + // 261002-icv: Freigabestufe; USE = Benutzen (Standard und Bestand), + // MANAGE = Verwalten — Modul benutzen und dessen eigene Einstellungen + // ändern; Freigaben erteilen bleibt Administratoren vorbehalten. + level ModuleGrantLevel @default(USE) // Entweder-oder (Gruppe XOR Benutzer, D-04) + Duplikat-Schutz je Variante // werden per hand-editierter migration.sql ergänzt — Prisma 6.19 hat kein diff --git a/apps/api/src/groups/dto/create-module-grant.dto.spec.ts b/apps/api/src/groups/dto/create-module-grant.dto.spec.ts new file mode 100644 index 0000000..36c069e --- /dev/null +++ b/apps/api/src/groups/dto/create-module-grant.dto.spec.ts @@ -0,0 +1,25 @@ +import 'reflect-metadata'; +import { plainToInstance } from 'class-transformer'; +import { validate } from 'class-validator'; +import { describe, expect, it } from 'vitest'; +import { CreateModuleGrantDto } from './create-module-grant.dto'; + +async function errorsFor(plain: Record) { + const dto = plainToInstance(CreateModuleGrantDto, plain); + const errors = await validate(dto as object); + return errors.map((e) => e.property); +} + +describe('CreateModuleGrantDto — Freigabestufe (261002-icv)', () => { + it('ohne level ist gültig', async () => { + expect(await errorsFor({ moduleId: 'm1', groupId: 'g1' })).toEqual([]); + }); + + it.each(['USE', 'MANAGE'])('level %s ist gültig', async (level) => { + expect(await errorsFor({ moduleId: 'm1', userId: 'u1', level })).toEqual([]); + }); + + it.each(['ADMIN', 'manage', 'use', '', 1])('level %j wird abgelehnt', async (level) => { + expect(await errorsFor({ moduleId: 'm1', userId: 'u1', level })).toContain('level'); + }); +}); diff --git a/apps/api/src/groups/dto/create-module-grant.dto.ts b/apps/api/src/groups/dto/create-module-grant.dto.ts index de5945d..96c25b8 100644 --- a/apps/api/src/groups/dto/create-module-grant.dto.ts +++ b/apps/api/src/groups/dto/create-module-grant.dto.ts @@ -1,4 +1,5 @@ -import { IsNotEmpty, IsOptional, IsString } from 'class-validator'; +import { ModuleGrantLevel } from '@prisma/client'; +import { IsEnum, IsNotEmpty, IsOptional, IsString } from 'class-validator'; /** * DTO für Grant-Erstellung und -Entzug (PERM-03). @@ -21,4 +22,12 @@ export class CreateModuleGrantDto { @IsString() @IsOptional() userId?: string; + + /** + * Freigabestufe (261002-icv): 'USE' (Benutzen, Standard) oder 'MANAGE' + * (Verwalten). Beim Entzug (DELETE) wird das Feld ignoriert. + */ + @IsOptional() + @IsEnum(ModuleGrantLevel) + level?: ModuleGrantLevel; } diff --git a/apps/api/src/groups/migration-sql.spec.ts b/apps/api/src/groups/migration-sql.spec.ts index 082c406..f64c2d5 100644 --- a/apps/api/src/groups/migration-sql.spec.ts +++ b/apps/api/src/groups/migration-sql.spec.ts @@ -335,3 +335,17 @@ describe('add_group_internal_name_and_object_guid migration.sql (D-04)', () => { expect(sql).not.toMatch(/ALTER TABLE .* (ENABLE|FORCE) ROW LEVEL SECURITY/); }); }); + +describe('module_grant_level migration.sql (261002-icv)', () => { + const sql = readMigrationSql('_module_grant_level'); + + it('legt den Aufzählungstyp ModuleGrantLevel mit USE und MANAGE an', () => { + expect(sql).toContain(`CREATE TYPE "ModuleGrantLevel" AS ENUM ('USE', 'MANAGE');`); + }); + + it('fügt die Spalte level mit Standard USE hinzu (Bestand wird USE)', () => { + expect(sql).toContain( + `ALTER TABLE "ModuleGrant" ADD COLUMN "level" "ModuleGrantLevel" NOT NULL DEFAULT 'USE';`, + ); + }); +}); diff --git a/apps/api/src/groups/module-grants.service.spec.ts b/apps/api/src/groups/module-grants.service.spec.ts index e7c4b16..86d3e07 100644 --- a/apps/api/src/groups/module-grants.service.spec.ts +++ b/apps/api/src/groups/module-grants.service.spec.ts @@ -124,6 +124,12 @@ function makeFakePrisma() { findFirst: async ({ where }: any) => { return findGrant(where.tenantId, where.moduleId, where.groupId, where.userId) ?? null; }, + update: async ({ where, data }: any) => { + const record = grants.get(where.id); + if (!record) throw new Error('not found'); + Object.assign(record, data); + return record; + }, findMany: async ({ where }: any) => { let rows = Array.from(grants.values()).filter((g) => g.tenantId === where.tenantId); @@ -351,6 +357,83 @@ describe('ModuleGrantsService.grant', () => { }); }); +describe('ModuleGrantsService.grant — Freigabestufe (261002-icv)', () => { + it('ohne Stufe wird mit USE angelegt', async () => { + const prisma = makeFakePrisma(); + seedBase(prisma); + const service = new ModuleGrantsService(prisma as any); + + const result = await service.grant('t1', { moduleId: 'mod-1', groupId: 'g1' }); + + expect(result.level).toBe('USE'); + }); + + it('mit Stufe MANAGE wird mit MANAGE angelegt', async () => { + const prisma = makeFakePrisma(); + seedBase(prisma); + const service = new ModuleGrantsService(prisma as any); + + const result = await service.grant('t1', { moduleId: 'mod-1', userId: 'u1', level: 'MANAGE' }); + + expect(result.level).toBe('MANAGE'); + }); + + it('bestehende USE-Freigabe plus Stufe MANAGE wird auf MANAGE angehoben und protokolliert', async () => { + const logSpy = vi.spyOn(Logger.prototype, 'log').mockImplementation(() => undefined); + const prisma = makeFakePrisma(); + seedBase(prisma); + const service = new ModuleGrantsService(prisma as any); + const first = await service.grant('t1', { moduleId: 'mod-1', groupId: 'g1' }); + + const second = await service.grant('t1', { moduleId: 'mod-1', groupId: 'g1', level: 'MANAGE' }); + + expect(second.id).toBe(first.id); + expect(second.level).toBe('MANAGE'); + expect(prisma.__grantCount()).toBe(1); + expect(logSpy.mock.calls.map((c) => String(c[0])).join('\n')).toContain( + 'Grant-Stufe geändert: tenant=t1 module=mod-1 group=g1 level=MANAGE', + ); + logSpy.mockRestore(); + }); + + it('bestehende MANAGE-Freigabe ohne Stufenangabe bleibt MANAGE (Wiederholungsklick stuft nie herab)', async () => { + const prisma = makeFakePrisma(); + seedBase(prisma); + const service = new ModuleGrantsService(prisma as any); + await service.grant('t1', { moduleId: 'mod-1', groupId: 'g1', level: 'MANAGE' }); + + const again = await service.grant('t1', { moduleId: 'mod-1', groupId: 'g1' }); + + expect(again.level).toBe('MANAGE'); + }); + + it('bestehende MANAGE-Freigabe kann ausdrücklich auf USE gesetzt werden', async () => { + const prisma = makeFakePrisma(); + seedBase(prisma); + const service = new ModuleGrantsService(prisma as any); + await service.grant('t1', { moduleId: 'mod-1', groupId: 'g1', level: 'MANAGE' }); + + const down = await service.grant('t1', { moduleId: 'mod-1', groupId: 'g1', level: 'USE' }); + + expect(down.level).toBe('USE'); + }); + + it('P2002-Wettlauf mit Stufe: die Stufe wird angewendet, es bleibt eine Zeile', async () => { + const prisma = makeFakePrisma(); + seedBase(prisma); + const service = new ModuleGrantsService(prisma as any); + + const [a, b] = await Promise.all([ + service.grant('t1', { moduleId: 'mod-1', groupId: 'g1' }), + service.grant('t1', { moduleId: 'mod-1', groupId: 'g1', level: 'MANAGE' }), + ]); + + expect(a.groupId).toBe('g1'); + expect(b.level).toBe('MANAGE'); + expect(prisma.__grantCount()).toBe(1); + }); +}); + describe('ModuleGrantsService.revoke', () => { it('entfernt einen bestehenden Grant', async () => { const prisma = makeFakePrisma(); diff --git a/apps/api/src/groups/module-grants.service.ts b/apps/api/src/groups/module-grants.service.ts index d6a1b85..19dcba3 100644 --- a/apps/api/src/groups/module-grants.service.ts +++ b/apps/api/src/groups/module-grants.service.ts @@ -4,6 +4,7 @@ import { Logger, NotFoundException, } from '@nestjs/common'; +import { type ModuleGrant, ModuleGrantLevel } from '@prisma/client'; import { PrismaService } from '../prisma/prisma.service'; import { forTenant } from '../prisma/prisma-tenant.extension'; import { prismaErrorCode } from '../prisma/prisma-error'; @@ -21,8 +22,9 @@ import { prismaErrorCode } from '../prisma/prisma-error'; * über `this.logger`. Es entsteht bewusst keine Audit-Tabelle und keine * Ansicht im Admin-UI. * - * D-04: der Datensatz trägt keine Rechtestufe, und dieser Service bietet - * keine Methode, die eine solche setzen könnte. + * Seit 261002-icv trägt der Datensatz eine Freigabestufe `level` (Benutzen / + * Verwalten); nur dieser ausschließlich Administratoren zugängliche Service + * setzt sie. */ @Injectable() export class ModuleGrantsService { @@ -82,9 +84,9 @@ export class ModuleGrantsService { */ async grant( tenantId: string, - data: { moduleId: string; groupId?: string; userId?: string }, + data: { moduleId: string; groupId?: string; userId?: string; level?: ModuleGrantLevel }, ) { - const { moduleId, groupId, userId } = data; + const { moduleId, groupId, userId, level } = data; if ((groupId && userId) || (!groupId && !userId)) { throw new BadRequestException( 'Ein Grant muss entweder eine groupId oder eine userId tragen, nicht beides und nicht keines', @@ -119,6 +121,37 @@ export class ModuleGrantsService { } const target = groupId ? `group=${groupId}` : `user=${userId}`; + const targetWhere = { + tenantId, + moduleId, + groupId: groupId ?? null, + userId: userId ?? null, + }; + + // Besteht der Grant schon: nur eine ausdruecklich andere Stufe aendert + // ihn. Ohne Stufenangabe (erneuter Klick auf die Zelle) bleibt die + // vorhandene Stufe — ein Wiederholungsklick stuft nie herab (T-icv-11). + const applyToExisting = async (existing: ModuleGrant): Promise => { + if (level && existing.level !== level) { + const updated = await tenantPrisma.moduleGrant.update({ + where: { id: existing.id }, + data: { level }, + }); + this.logger.log( + `Grant-Stufe geändert: tenant=${tenantId} module=${moduleId} ${target} level=${level}`, + ); + return updated; + } + this.logger.log( + `Grant bereits vorhanden (Doppelklick abgefangen): tenant=${tenantId} module=${moduleId} ${target}`, + ); + return existing; + }; + + const found = await tenantPrisma.moduleGrant.findFirst({ where: targetWhere }); + if (found) { + return applyToExisting(found); + } try { const created = await tenantPrisma.moduleGrant.create({ @@ -127,27 +160,18 @@ export class ModuleGrantsService { moduleId, groupId: groupId ?? null, userId: userId ?? null, + level: level ?? ModuleGrantLevel.USE, }, }); this.logger.log( - `Grant erteilt: tenant=${tenantId} module=${moduleId} ${target}`, + `Grant erteilt: tenant=${tenantId} module=${moduleId} ${target} level=${created.level ?? level ?? ModuleGrantLevel.USE}`, ); return created; } catch (err: unknown) { if (prismaErrorCode(err) === 'P2002') { - const existing = await tenantPrisma.moduleGrant.findFirst({ - where: { - tenantId, - moduleId, - groupId: groupId ?? null, - userId: userId ?? null, - }, - }); + const existing = await tenantPrisma.moduleGrant.findFirst({ where: targetWhere }); if (existing) { - this.logger.log( - `Grant bereits vorhanden (Doppelklick abgefangen): tenant=${tenantId} module=${moduleId} ${target}`, - ); - return existing; + return applyToExisting(existing); } } throw err; diff --git a/apps/api/src/kantine-datev/kantine-datev.controller.spec.ts b/apps/api/src/kantine-datev/kantine-datev.controller.spec.ts index fa48b0c..fecf4f0 100644 --- a/apps/api/src/kantine-datev/kantine-datev.controller.spec.ts +++ b/apps/api/src/kantine-datev/kantine-datev.controller.spec.ts @@ -1,9 +1,8 @@ import 'reflect-metadata'; import { BadRequestException, ForbiddenException, ValidationPipe } from '@nestjs/common'; -import { Role } from '@prisma/client'; import { describe, expect, it, vi } from 'vitest'; import { ROLES_KEY } from '../auth/decorators/roles.decorator'; -import { MODULE_SLUG_KEY } from '../module-registry/module.guard'; +import { MODULE_MANAGE_KEY, MODULE_SLUG_KEY } from '../module-registry/module.guard'; import { KantineDatevSettingsDto } from './dto/kantine-datev-settings.dto'; import { KantineDatevController } from './kantine-datev.controller'; @@ -25,16 +24,19 @@ describe('KantineDatevController — Metadaten', () => { expect(Reflect.getMetadata(MODULE_SLUG_KEY, KantineDatevController)).toBe('kantine-datev'); }); - it('PUT settings verlangt ADMIN/SUPER_ADMIN', () => { - expect(Reflect.getMetadata(ROLES_KEY, proto.saveSettings)).toEqual([ - Role.ADMIN, - Role.SUPER_ADMIN, - ]); + it('PUT settings verlangt die Freigabestufe Verwalten und trägt keine Routen-Rolle (261002-icv)', () => { + expect(Reflect.getMetadata(MODULE_MANAGE_KEY, proto.saveSettings)).toBe(true); + expect(Reflect.getMetadata(MODULE_SLUG_KEY, proto.saveSettings)).toBe('kantine-datev'); + expect(Reflect.getMetadata(ROLES_KEY, proto.saveSettings)).toBeUndefined(); }); - it.each(['getSettings', 'preview', 'export'])('%s traegt keine Routen-Rolle', (name) => { - expect(Reflect.getMetadata(ROLES_KEY, proto[name])).toBeUndefined(); - }); + it.each(['getSettings', 'preview', 'export'])( + '%s traegt weder Routen-Rolle noch Verwalten-Pflicht', + (name) => { + expect(Reflect.getMetadata(ROLES_KEY, proto[name])).toBeUndefined(); + expect(Reflect.getMetadata(MODULE_MANAGE_KEY, proto[name])).toBeUndefined(); + }, + ); }); describe('KantineDatevController — Verhalten', () => { diff --git a/apps/api/src/kantine-datev/kantine-datev.controller.ts b/apps/api/src/kantine-datev/kantine-datev.controller.ts index aa69836..8b1dd64 100644 --- a/apps/api/src/kantine-datev/kantine-datev.controller.ts +++ b/apps/api/src/kantine-datev/kantine-datev.controller.ts @@ -11,10 +11,8 @@ import { UseInterceptors, } from '@nestjs/common'; import { FileInterceptor } from '@nestjs/platform-express'; -import { Role } from '@prisma/client'; -import { Roles } from '../auth/decorators/roles.decorator'; import type { AuthenticatedRequest, UploadedFileLike } from '../auth/types/auth-user'; -import { UseModule } from '../module-registry/module.guard'; +import { ModuleManage, UseModule } from '../module-registry/module.guard'; import { KantineDatevSettingsDto } from './dto/kantine-datev-settings.dto'; import { KantineDatevService } from './kantine-datev.service'; @@ -22,7 +20,8 @@ import { KantineDatevService } from './kantine-datev.service'; * `@UseModule('kantine-datev')` auf Klassenebene — Aktivierung UND Freigabe. * `tenantId` kommt ausschliesslich aus `req.tenantId` (TenantGuard). Lesen, * Vorschau und Export stehen jedem Benutzer mit Modulzugriff offen; die - * Einstellungen aendern nur Administratoren (T-FM5-02). Hochgeladene Dateien + * Einstellungen aendern Administratoren und Benutzer mit der Freigabestufe + * Verwalten (`@ModuleManage`, 261002-icv; T-FM5-02). Hochgeladene Dateien * bleiben im Arbeitsspeicher (multer-Standard), 5 MB Grenze (T-FM5-04). * Keine `:id`-Routen in diesem Controller. */ @@ -45,7 +44,7 @@ export class KantineDatevController { } @Put('settings') - @Roles(Role.ADMIN, Role.SUPER_ADMIN) + @ModuleManage('kantine-datev') async saveSettings(@Req() req: AuthenticatedRequest, @Body() dto: KantineDatevSettingsDto) { return this.service.saveSettings(this.requireTenantId(req), dto); } diff --git a/apps/api/src/module-registry/module-access.service.spec.ts b/apps/api/src/module-registry/module-access.service.spec.ts index d88c0d7..b3194ce 100644 --- a/apps/api/src/module-registry/module-access.service.spec.ts +++ b/apps/api/src/module-registry/module-access.service.spec.ts @@ -30,8 +30,8 @@ const BOUND_MODEL_NAMES = ['tenantModuleActivation', 'moduleGrant']; function makeFakePrisma(opts: { activations?: { moduleId: string }[]; - directGrants?: { moduleId: string }[]; - groupGrants?: { moduleId: string }[]; + directGrants?: { moduleId: string; level?: string }[]; + groupGrants?: { moduleId: string; level?: string }[]; } = {}) { const activations = opts.activations ?? []; const directGrants = opts.directGrants ?? []; @@ -250,6 +250,108 @@ describe('ModuleAccessService.getAccessibleModuleIds — USER (Grant-Auflösung, }); }); +describe('ModuleAccessService.getModuleAccessLevels — Freigabestufe (261002-icv)', () => { + it('Direkt-Grant USE: Map {mod-1: USE}', async () => { + const prisma = makeFakePrisma({ + activations: [{ moduleId: 'mod-1' }], + directGrants: [{ moduleId: 'mod-1', level: 'USE' }], + }); + const service = new ModuleAccessService(prisma as any); + + const result = await service.getModuleAccessLevels('t1', 'user-1', 'USER'); + + expect(result).toEqual(new Map([['mod-1', 'USE']])); + }); + + it('Direkt-Grant USE plus Gruppen-Grant MANAGE auf dasselbe Modul: MANAGE gewinnt (L-02)', async () => { + const prisma = makeFakePrisma({ + activations: [{ moduleId: 'mod-1' }], + directGrants: [{ moduleId: 'mod-1', level: 'USE' }], + groupGrants: [{ moduleId: 'mod-1', level: 'MANAGE' }], + }); + const service = new ModuleAccessService(prisma as any); + + expect((await service.getModuleAccessLevels('t1', 'user-1', 'USER')).get('mod-1')).toBe('MANAGE'); + }); + + it('Gruppen-Grant USE plus Direkt-Grant MANAGE: ebenfalls MANAGE (Reihenfolge egal)', async () => { + const prisma = makeFakePrisma({ + activations: [{ moduleId: 'mod-1' }], + directGrants: [{ moduleId: 'mod-1', level: 'MANAGE' }], + groupGrants: [{ moduleId: 'mod-1', level: 'USE' }], + }); + const service = new ModuleAccessService(prisma as any); + + expect((await service.getModuleAccessLevels('t1', 'user-1', 'USER')).get('mod-1')).toBe('MANAGE'); + }); + + it('MANAGE nur über eine Gruppe: MANAGE', async () => { + const prisma = makeFakePrisma({ + activations: [{ moduleId: 'mod-1' }], + groupGrants: [{ moduleId: 'mod-1', level: 'MANAGE' }], + }); + const service = new ModuleAccessService(prisma as any); + + expect((await service.getModuleAccessLevels('t1', 'user-1', 'USER')).get('mod-1')).toBe('MANAGE'); + }); + + it('MANAGE-Grant auf ein deaktiviertes Modul: fehlt in der Map (T-icv-05)', async () => { + const prisma = makeFakePrisma({ + activations: [], + directGrants: [{ moduleId: 'mod-1', level: 'MANAGE' }], + }); + const service = new ModuleAccessService(prisma as any); + + expect((await service.getModuleAccessLevels('t1', 'user-1', 'USER')).has('mod-1')).toBe(false); + }); + + it.each(['ADMIN', 'SUPER_ADMIN'] as const)( + '%s: jedes aktive Modul mit MANAGE, ohne Grant-Abfragen (L-03)', + async (role) => { + const prisma = makeFakePrisma({ activations: [{ moduleId: 'mod-1' }, { moduleId: 'mod-2' }] }); + const service = new ModuleAccessService(prisma as any); + + const result = await service.getModuleAccessLevels('t1', 'a-1', role); + + expect(result).toEqual(new Map([['mod-1', 'MANAGE'], ['mod-2', 'MANAGE']])); + expect(prisma.moduleGrant.findMany).not.toHaveBeenCalled(); + }, + ); + + it('ohne Grants: leere Map', async () => { + const prisma = makeFakePrisma({ activations: [{ moduleId: 'mod-1' }] }); + const service = new ModuleAccessService(prisma as any); + + expect(await service.getModuleAccessLevels('t1', 'user-1', 'USER')).toEqual(new Map()); + }); + + it('Zeilen ohne level-Feld (alte Mocks) zählen als USE', async () => { + const prisma = makeFakePrisma({ + activations: [{ moduleId: 'mod-1' }], + directGrants: [{ moduleId: 'mod-1' }], + }); + const service = new ModuleAccessService(prisma as any); + + expect((await service.getModuleAccessLevels('t1', 'user-1', 'USER')).get('mod-1')).toBe('USE'); + }); + + it('findAccessibleModules liefert canManage je Zeile', async () => { + const prisma = makeFakePrisma({ + activations: [{ moduleId: 'mod-1' }, { moduleId: 'mod-2' }], + directGrants: [ + { moduleId: 'mod-1', level: 'MANAGE' }, + { moduleId: 'mod-2', level: 'USE' }, + ], + }); + const service = new ModuleAccessService(prisma as any); + + const rows = await service.findAccessibleModules('t1', 'user-1', 'USER'); + + expect(rows.find((r: any) => r.id === 'mod-1')?.canManage).toBe(true); + expect(rows.find((r: any) => r.id === 'mod-2')?.canManage).toBe(false); + }); +}); + describe('ModuleAccessService.findAccessibleModules — ordering (PERM-04)', () => { it('sortiert die zugänglichen Module deterministisch nach Namen aufsteigend', async () => { const prisma = makeFakePrisma({ diff --git a/apps/api/src/module-registry/module-access.service.ts b/apps/api/src/module-registry/module-access.service.ts index e30d33e..8e89cf8 100644 --- a/apps/api/src/module-registry/module-access.service.ts +++ b/apps/api/src/module-registry/module-access.service.ts @@ -1,5 +1,5 @@ import { Injectable } from '@nestjs/common'; -import { Role } from '@prisma/client'; +import { ModuleGrantLevel, Role } from '@prisma/client'; import { PrismaService } from '../prisma/prisma.service'; import { forTenant } from '../prisma/prisma-tenant.extension'; @@ -10,37 +10,44 @@ import { forTenant } from '../prisma/prisma-tenant.extension'; * `getAccessibleModuleIds` auf, damit Sidebar, Modulseiten und API * niemals auseinanderdriften können — genau das Sicherheitsloch, das * D-01 strukturell verhindert. + * + * Seit 261002-icv traegt jede Freigabe eine Stufe (Benutzen / Verwalten). + * `getModuleAccessLevels` ist die einzige Aufloesung fuer Zugriff UND Stufe; + * `getAccessibleModuleIds` ist nur noch deren Schluesselmenge. */ @Injectable() export class ModuleAccessService { constructor(private readonly prisma: PrismaService) {} /** - * Berechnet die Menge der moduleIds, auf die dieser Benutzer Zugriff hat. + * Berechnet je Modul, auf das dieser Benutzer Zugriff hat, die wirksame + * Freigabestufe (261002-icv). Einzige Aufloesung fuer Zugriff UND Stufe. * - * D-03: ADMIN/SUPER_ADMIN umgehen jede Grant-Prüfung — sie erhalten alle - * mandantenweit aktiven Module ihres eigenen Mandanten. Diese Rolle - * kommt ausschließlich aus dem JWT (Aufrufer), nie aus Body/Params — + * D-03/L-03: ADMIN/SUPER_ADMIN umgehen jede Grant-Pruefung — sie erhalten + * alle aktiven Module ihres eigenen Mandanten mit Stufe MANAGE. Diese Rolle + * kommt ausschliesslich aus dem JWT (Aufrufer), nie aus Body/Params — * der Kurzschluss kann daher keine Module eines fremden Mandanten * liefern, weil `tenantId` ebenfalls aus dem JWT stammt (T-15-10). * - * Für alle anderen Rollen (USER): Vereinigungsmenge aus Direkt-Grants - * und Grants über Gruppenmitgliedschaften, geschnitten mit den - * mandantenweit aktiven Modulen (D-02 — ein Grant auf ein deaktiviertes - * Modul gewährt keinen Zugriff). Die Gruppen-Query ist eine einzige - * verschachtelte Prisma-Query (`group: { memberships: { some: { userId } } }`) - * statt einer Schleife über die Gruppen des Benutzers — sonst entsteht - * ein N+1 pro geschütztem Endpoint. + * Fuer alle anderen Rollen (USER): Vereinigungsmenge aus Direkt-Grants + * und Grants ueber Gruppenmitgliedschaften, geschnitten mit den + * aktiven Modulen (D-02 — ein Grant auf ein deaktiviertes Modul gewaehrt + * keinen Zugriff, auch keine Verwaltungsstufe). Besteht Zugriff ueber + * mehrere Wege, gilt die hoehere Stufe (MANAGE gewinnt, L-02); ein Wert + * ausser 'MANAGE' (z. B. eine Zeile ohne `level`) zaehlt als USE. Die + * Gruppen-Query ist eine einzige verschachtelte Prisma-Query statt einer + * Schleife ueber die Gruppen des Benutzers — sonst entsteht ein N+1 pro + * geschuetztem Endpoint. * - * Rein lesend, kein Caching über Request-Grenzen hinweg (D-09) — ein - * Freigabe-Entzug wirkt bei der nächsten Anfrage. + * Rein lesend, kein Caching ueber Request-Grenzen hinweg (D-09) — ein + * Freigabe-Entzug wirkt bei der naechsten Anfrage. */ - async getAccessibleModuleIds( + async getModuleAccessLevels( tenantId: string, userId: string, role: Role, - ): Promise> { - // EIN gebundener Klient fuer alle vier mandantengebundenen Zugriffe + ): Promise> { + // EIN gebundener Klient fuer alle mandantengebundenen Zugriffe // dieser Methode (Kurzschlusszweig, Direktweg, Gruppenweg, Schnittmenge) // — nicht ein Klient je Modellzugriff (260910-exd, Aufgabe 2). Die // bestehenden `where`-Filter mit tenantId bleiben ZUSAETZLICH stehen: @@ -61,46 +68,80 @@ export class ModuleAccessService { where: { tenantId, isActive: true }, select: { moduleId: true }, }); - return new Set(activations.map((a: { moduleId: string }) => a.moduleId)); + return new Map( + activations.map((a: { moduleId: string }) => [a.moduleId, ModuleGrantLevel.MANAGE]), + ); } const [direct, viaGroup] = await Promise.all([ tenantPrisma.moduleGrant.findMany({ where: { tenantId, userId }, - select: { moduleId: true }, + select: { moduleId: true, level: true }, }), tenantPrisma.moduleGrant.findMany({ where: { tenantId, group: { memberships: { some: { userId } } } }, - select: { moduleId: true }, + select: { moduleId: true, level: true }, }), ]); - const grantedIds = [...direct, ...viaGroup].map((g: { moduleId: string }) => g.moduleId); - if (grantedIds.length === 0) { - return new Set(); + const granted = new Map(); + for (const g of [...direct, ...viaGroup] as Array<{ + moduleId: string; + level?: ModuleGrantLevel; + }>) { + const level = + g.level === ModuleGrantLevel.MANAGE ? ModuleGrantLevel.MANAGE : ModuleGrantLevel.USE; + if (level === ModuleGrantLevel.MANAGE || !granted.has(g.moduleId)) { + granted.set(g.moduleId, level); + } + } + + if (granted.size === 0) { + return new Map(); } const activations = await tenantPrisma.tenantModuleActivation.findMany({ where: { tenantId, isActive: true, - moduleId: { in: grantedIds }, + moduleId: { in: [...granted.keys()] }, }, select: { moduleId: true }, }); - return new Set(activations.map((a: { moduleId: string }) => a.moduleId)); + const result = new Map(); + for (const a of activations as Array<{ moduleId: string }>) { + const level = granted.get(a.moduleId); + if (level) result.set(a.moduleId, level); + } + return result; + } + + /** + * Menge der moduleIds, auf die dieser Benutzer Zugriff hat — die + * Schluesselmenge von `getModuleAccessLevels` (Signatur unveraendert, + * Verbraucher: Guard-Altpfade, Katalog, Dashboard). + */ + async getAccessibleModuleIds( + tenantId: string, + userId: string, + role: Role, + ): Promise> { + const levels = await this.getModuleAccessLevels(tenantId, userId, role); + return new Set(levels.keys()); } /** * Lädt die vollständigen Module-Datensätze, auf die dieser Benutzer * Zugriff hat, sortiert nach Name. Bedient `GET /modules/active` — die * explizite Sortierung hält die Sidebar-Reihenfolge über Aufrufe hinweg - * stabil. + * stabil. Jede Zeile traegt `canManage` (261002-icv): wahr bei Freigabestufe + * Verwalten (Administratoren: immer) — nur zur Anzeige, bindend bleibt der + * ModuleGuard. */ async findAccessibleModules(tenantId: string, userId: string, role: Role) { - const accessibleIds = await this.getAccessibleModuleIds(tenantId, userId, role); + const levels = await this.getModuleAccessLevels(tenantId, userId, role); - if (accessibleIds.size === 0) { + if (levels.size === 0) { return []; } @@ -111,10 +152,14 @@ export class ModuleAccessService { // Tabelle eine Regel gibt — dann verschwaende der gesamte Katalog fuer // jeden Mandanten. Diese Bedingung steht hier als Bedingung, nicht als // heute beobachtbare Tatsache. - return this.prisma.module.findMany({ - where: { id: { in: [...accessibleIds] } }, + const rows = await this.prisma.module.findMany({ + where: { id: { in: [...levels.keys()] } }, orderBy: { name: 'asc' }, }); + return rows.map((row) => ({ + ...row, + canManage: levels.get(row.id) === ModuleGrantLevel.MANAGE, + })); } /** diff --git a/apps/api/src/module-registry/module.guard.spec.ts b/apps/api/src/module-registry/module.guard.spec.ts index 54f8e13..25316cd 100644 --- a/apps/api/src/module-registry/module.guard.spec.ts +++ b/apps/api/src/module-registry/module.guard.spec.ts @@ -1,6 +1,7 @@ import { ForbiddenException } from '@nestjs/common'; +import { GUARDS_METADATA } from '@nestjs/common/constants'; import { describe, expect, it, vi } from 'vitest'; -import { ModuleGuard } from './module.guard'; +import { MODULE_MANAGE_KEY, MODULE_SLUG_KEY, ModuleGuard, ModuleManage } from './module.guard'; /** * ModuleGuard.canActivate — deckt die vollständige Behavior-Liste aus @@ -18,26 +19,28 @@ function makeContext(request: any) { } as any; } -function makeReflector(slug: string | undefined) { - return { getAllAndOverride: vi.fn(() => slug) } as any; +function makeReflector(slug: string | undefined, manage = false) { + return { + getAllAndOverride: vi.fn((key: string) => (key === MODULE_MANAGE_KEY ? manage : slug)), + } as any; } describe('ModuleGuard.canActivate', () => { it('gibt true zurück und ruft keinen Service auf, wenn kein @UseModule-Slug in den Metadaten steht', async () => { const moduleRegistryService = { findBySlug: vi.fn() } as any; - const moduleAccessService = { getAccessibleModuleIds: vi.fn() } as any; + const moduleAccessService = { getModuleAccessLevels: vi.fn() } as any; const guard = new ModuleGuard(makeReflector(undefined), moduleRegistryService, moduleAccessService); const result = await guard.canActivate(makeContext({})); expect(result).toBe(true); expect(moduleRegistryService.findBySlug).not.toHaveBeenCalled(); - expect(moduleAccessService.getAccessibleModuleIds).not.toHaveBeenCalled(); + expect(moduleAccessService.getModuleAccessLevels).not.toHaveBeenCalled(); }); it('wirft ForbiddenException("No tenant context"), wenn weder request.tenantId noch request.user.tenantId gesetzt sind', async () => { const moduleRegistryService = { findBySlug: vi.fn() } as any; - const moduleAccessService = { getAccessibleModuleIds: vi.fn() } as any; + const moduleAccessService = { getModuleAccessLevels: vi.fn() } as any; const guard = new ModuleGuard(makeReflector('domaincheck'), moduleRegistryService, moduleAccessService); await expect(guard.canActivate(makeContext({ user: {} }))).rejects.toThrow( @@ -47,7 +50,7 @@ describe('ModuleGuard.canActivate', () => { it('wirft ForbiddenException("No user context"), wenn tenantId gesetzt ist, aber userId/role fehlen', async () => { const moduleRegistryService = { findBySlug: vi.fn() } as any; - const moduleAccessService = { getAccessibleModuleIds: vi.fn() } as any; + const moduleAccessService = { getModuleAccessLevels: vi.fn() } as any; const guard = new ModuleGuard(makeReflector('domaincheck'), moduleRegistryService, moduleAccessService); await expect( @@ -57,7 +60,7 @@ describe('ModuleGuard.canActivate', () => { it('wirft ForbiddenException bei unbekanntem Slug (findBySlug liefert null)', async () => { const moduleRegistryService = { findBySlug: vi.fn().mockResolvedValue(null) } as any; - const moduleAccessService = { getAccessibleModuleIds: vi.fn() } as any; + const moduleAccessService = { getModuleAccessLevels: vi.fn() } as any; const guard = new ModuleGuard(makeReflector('unknown-slug'), moduleRegistryService, moduleAccessService); await expect( @@ -65,7 +68,7 @@ describe('ModuleGuard.canActivate', () => { makeContext({ tenantId: 't1', user: { id: 'user-1', role: 'USER' } }), ), ).rejects.toThrow(ForbiddenException); - expect(moduleAccessService.getAccessibleModuleIds).not.toHaveBeenCalled(); + expect(moduleAccessService.getModuleAccessLevels).not.toHaveBeenCalled(); }); it('USER ohne Grant auf ein aktives Modul: wirft ForbiddenException', async () => { @@ -73,7 +76,7 @@ describe('ModuleGuard.canActivate', () => { findBySlug: vi.fn().mockResolvedValue({ id: 'mod-1', slug: 'domaincheck' }), } as any; const moduleAccessService = { - getAccessibleModuleIds: vi.fn().mockResolvedValue(new Set()), + getModuleAccessLevels: vi.fn().mockResolvedValue(new Map()), } as any; const guard = new ModuleGuard(makeReflector('domaincheck'), moduleRegistryService, moduleAccessService); @@ -89,7 +92,7 @@ describe('ModuleGuard.canActivate', () => { findBySlug: vi.fn().mockResolvedValue({ id: 'mod-1', slug: 'domaincheck' }), } as any; const moduleAccessService = { - getAccessibleModuleIds: vi.fn().mockResolvedValue(new Set(['mod-1'])), + getModuleAccessLevels: vi.fn().mockResolvedValue(new Map([['mod-1', 'MANAGE']])), } as any; const guard = new ModuleGuard(makeReflector('domaincheck'), moduleRegistryService, moduleAccessService); const request = { tenantId: 't1', user: { id: 'admin-1', role: 'ADMIN' } }; @@ -97,16 +100,16 @@ describe('ModuleGuard.canActivate', () => { const result = await guard.canActivate(makeContext(request)); expect(result).toBe(true); - expect(moduleAccessService.getAccessibleModuleIds).toHaveBeenCalledWith('t1', 'admin-1', 'ADMIN'); + expect(moduleAccessService.getModuleAccessLevels).toHaveBeenCalledWith('t1', 'admin-1', 'ADMIN'); }); it('USER mit Zugriff: gibt true zurück und legt das Ergebnis auf request.moduleAccessIds ab (Per-Request-Memoisierung, D-09)', async () => { const moduleRegistryService = { findBySlug: vi.fn().mockResolvedValue({ id: 'mod-1', slug: 'domaincheck' }), } as any; - const accessibleIds = new Set(['mod-1']); + const accessibleIds = new Map([['mod-1', 'USE']]); const moduleAccessService = { - getAccessibleModuleIds: vi.fn().mockResolvedValue(accessibleIds), + getModuleAccessLevels: vi.fn().mockResolvedValue(accessibleIds), } as any; const guard = new ModuleGuard(makeReflector('domaincheck'), moduleRegistryService, moduleAccessService); const request = { tenantId: 't1', user: { id: 'user-1', role: 'USER' } }; @@ -114,7 +117,8 @@ describe('ModuleGuard.canActivate', () => { const result = await guard.canActivate(makeContext(request)); expect(result).toBe(true); - expect((request as any).moduleAccessIds).toBe(accessibleIds); + expect((request as any).moduleAccessLevels).toBe(accessibleIds); + expect([...(request as any).moduleAccessIds]).toEqual(['mod-1']); }); /** @@ -141,7 +145,7 @@ describe('ModuleGuard.canActivate', () => { // Fall A: der Benutzer hat tatsächlich keine Freigabe. const moduleAccessServiceGenuinelyEmpty = { - getAccessibleModuleIds: vi.fn().mockResolvedValue(new Set()), + getModuleAccessLevels: vi.fn().mockResolvedValue(new Map()), } as any; const guardA = new ModuleGuard( makeReflector('domaincheck'), @@ -153,7 +157,7 @@ describe('ModuleGuard.canActivate', () => { // einer ungebunden gebliebenen Abfrage) trotzdem eine leere Menge — // aus Sicht des Wächters nicht von Fall A zu unterscheiden. const moduleAccessServiceQueryFoundNothing = { - getAccessibleModuleIds: vi.fn().mockResolvedValue(new Set()), + getModuleAccessLevels: vi.fn().mockResolvedValue(new Map()), } as any; const guardB = new ModuleGuard( makeReflector('domaincheck'), @@ -183,3 +187,71 @@ describe('ModuleGuard.canActivate', () => { ).toBe(messageA); }); }); + +describe('ModuleGuard — Freigabestufe (261002-icv)', () => { + const registry = { + findBySlug: vi.fn().mockImplementation(async (slug: string) => ({ id: `id-${slug}`, slug })), + } as any; + const userRequest = () => ({ tenantId: 't1', user: { id: 'user-1', role: 'USER' } }); + + it('@UseModule-Route + USE: erlaubt', async () => { + const access = { getModuleAccessLevels: vi.fn().mockResolvedValue(new Map([['id-a', 'USE']])) } as any; + const guard = new ModuleGuard(makeReflector('a'), registry, access); + expect(await guard.canActivate(makeContext(userRequest()))).toBe(true); + }); + + it('@ModuleManage-Route + USE: ForbiddenException mit Hinweis auf Verwalten', async () => { + const access = { getModuleAccessLevels: vi.fn().mockResolvedValue(new Map([['id-a', 'USE']])) } as any; + const guard = new ModuleGuard(makeReflector('a', true), registry, access); + await expect(guard.canActivate(makeContext(userRequest()))).rejects.toThrow( + "Module 'a' requires manage permission", + ); + }); + + it('@ModuleManage-Route + MANAGE: erlaubt', async () => { + const access = { getModuleAccessLevels: vi.fn().mockResolvedValue(new Map([['id-a', 'MANAGE']])) } as any; + const guard = new ModuleGuard(makeReflector('a', true), registry, access); + expect(await guard.canActivate(makeContext(userRequest()))).toBe(true); + }); + + it('Administrator (MANAGE auf allen aktiven Modulen): erlaubt', async () => { + const access = { getModuleAccessLevels: vi.fn().mockResolvedValue(new Map([['id-a', 'MANAGE']])) } as any; + const guard = new ModuleGuard(makeReflector('a', true), registry, access); + const request = { tenantId: 't1', user: { id: 'admin-1', role: 'ADMIN' } }; + expect(await guard.canActivate(makeContext(request))).toBe(true); + expect(access.getModuleAccessLevels).toHaveBeenCalledWith('t1', 'admin-1', 'ADMIN'); + }); + + it('@ModuleManage-Route ohne Freigabe: ForbiddenException (nicht zugänglich)', async () => { + const access = { getModuleAccessLevels: vi.fn().mockResolvedValue(new Map()) } as any; + const guard = new ModuleGuard(makeReflector('a', true), registry, access); + await expect(guard.canActivate(makeContext(userRequest()))).rejects.toThrow( + "Module 'a' is not accessible for this user", + ); + }); + + it('MANAGE auf Modul a gewährt nichts auf Modul b (T-icv-04)', async () => { + const access = { getModuleAccessLevels: vi.fn().mockResolvedValue(new Map([['id-a', 'MANAGE']])) } as any; + const guard = new ModuleGuard(makeReflector('b', true), registry, access); + await expect(guard.canActivate(makeContext(userRequest()))).rejects.toThrow(ForbiddenException); + }); + + it('ein zweiter Lauf auf demselben Request nutzt request.moduleAccessLevels und fragt den Dienst nicht erneut', async () => { + const access = { getModuleAccessLevels: vi.fn().mockResolvedValue(new Map([['id-a', 'MANAGE']])) } as any; + const request = userRequest(); + await new ModuleGuard(makeReflector('a'), registry, access).canActivate(makeContext(request)); + await new ModuleGuard(makeReflector('a', true), registry, access).canActivate(makeContext(request)); + expect(access.getModuleAccessLevels).toHaveBeenCalledTimes(1); + }); + + it('ModuleManage(slug) setzt Slug, Manage-Schlüssel und den ModuleGuard', () => { + class Probe { + @ModuleManage('probe') + handler() {} + } + const handler = Probe.prototype.handler; + expect(Reflect.getMetadata(MODULE_SLUG_KEY, handler)).toBe('probe'); + expect(Reflect.getMetadata(MODULE_MANAGE_KEY, handler)).toBe(true); + expect(Reflect.getMetadata(GUARDS_METADATA, handler)).toContain(ModuleGuard); + }); +}); diff --git a/apps/api/src/module-registry/module.guard.ts b/apps/api/src/module-registry/module.guard.ts index 933bc7b..adba798 100644 --- a/apps/api/src/module-registry/module.guard.ts +++ b/apps/api/src/module-registry/module.guard.ts @@ -8,6 +8,7 @@ import { 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'; @@ -16,6 +17,12 @@ import { ModuleRegistryService } from './module-registry.service'; */ 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 @@ -28,6 +35,13 @@ export const MODULE_SLUG_KEY = 'moduleSlug'; * 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 { @@ -71,22 +85,37 @@ export class ModuleGuard implements CanActivate { ); } - const accessibleModuleIds = await this.moduleAccessService.getAccessibleModuleIds( - tenantId, - userId, - role, - ); + const requireManage = + this.reflector.getAllAndOverride(MODULE_MANAGE_KEY, [ + context.getHandler(), + context.getClass(), + ]) === true; - if (!accessibleModuleIds.has(module.id)) { + // 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 = + 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`, ); } - // 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; + if (requireManage && level !== ModuleGrantLevel.MANAGE) { + throw new ForbiddenException( + `Module '${moduleSlug}' requires manage permission`, + ); + } + + request.moduleAccessLevels = levels; + request.moduleAccessIds = new Set(levels.keys()); return true; } @@ -107,3 +136,30 @@ export function UseModule(slug: string) { 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), + ); +} diff --git a/apps/web/src/app/(portal)/modules/kantine-datev/kantine-datev.test.tsx b/apps/web/src/app/(portal)/modules/kantine-datev/kantine-datev.test.tsx index 371a76a..30bc692 100644 --- a/apps/web/src/app/(portal)/modules/kantine-datev/kantine-datev.test.tsx +++ b/apps/web/src/app/(portal)/modules/kantine-datev/kantine-datev.test.tsx @@ -80,13 +80,23 @@ async function upload(file = csvFile()) { fireEvent.change(input, { target: { files: [file] } }); } +/** Antwort von GET /modules/active für den Fähigkeits-Hook (261002-icv). */ +const mockFetch = vi.fn(); +function stubActiveModules(entries: unknown[]) { + mockFetch.mockResolvedValue({ ok: true, json: async () => entries }); +} + beforeEach(() => { mockUser('USER'); mockGetSettings.mockResolvedValue(CONFIGURED); + stubActiveModules([{ slug: 'kantine-datev', canManage: false }]); + vi.stubGlobal('fetch', mockFetch); }); afterEach(() => { cleanup(); + vi.unstubAllGlobals(); + mockFetch.mockReset(); for (const m of [ mockGetSettings, mockSaveSettings, @@ -120,6 +130,28 @@ describe('KantineDatevPage — nicht eingerichtet', () => { }); }); +describe('KantineDatevPage — Freigabestufe Verwalten (261002-icv)', () => { + it('Benutzer mit canManage sieht den Einstellungen-Reiter', async () => { + stubActiveModules([{ slug: 'kantine-datev', canManage: true }]); + render(); + expect(await screen.findByRole('button', { name: 'Einstellungen' })).toBeTruthy(); + }); + + it('Benutzer ohne canManage sieht keinen Einstellungen-Reiter', async () => { + render(); + await waitFor(() => expect(mockFetch).toHaveBeenCalled()); + await screen.findByText('Kantinenabrechnung'); + expect(screen.queryByRole('button', { name: 'Einstellungen' })).toBeNull(); + }); + + it('Administrator sieht den Reiter ohne jede Abfrage von /modules/active', async () => { + mockUser('ADMIN'); + render(); + expect(await screen.findByRole('button', { name: 'Einstellungen' })).toBeTruthy(); + expect(mockFetch).not.toHaveBeenCalled(); + }); +}); + describe('KantineDatevPage — Abrechnung', () => { it('zeigt nach dem Hochladen Zeilen, Abrechnungsmonat und Gesamtbetrag', async () => { mockPreview.mockResolvedValue(GOOD_PREVIEW); diff --git a/apps/web/src/app/(portal)/modules/kantine-datev/page.tsx b/apps/web/src/app/(portal)/modules/kantine-datev/page.tsx index 6dc1d1f..0fca50d 100644 --- a/apps/web/src/app/(portal)/modules/kantine-datev/page.tsx +++ b/apps/web/src/app/(portal)/modules/kantine-datev/page.tsx @@ -15,7 +15,7 @@ import { previewKantineCsv, saveKantineSettings, } from '@/lib/kantine-datev-api'; -import { useAuthStore } from '@/lib/stores/auth-store'; +import { useCanManageModule } from '@/lib/use-module-capability'; type TabId = 'billing' | 'settings'; @@ -33,8 +33,8 @@ const euro = new Intl.NumberFormat('de-DE', { style: 'currency', currency: 'EUR' */ export default function KantineDatevPage() { const t = useTranslations('kantineDatev'); - const user = useAuthStore((s) => s.user); - const isAdmin = user?.role === 'ADMIN' || user?.role === 'SUPER_ADMIN'; + // Administratoren und Benutzer mit der Freigabestufe Verwalten (261002-icv). + const canManage = useCanManageModule('kantine-datev') === true; const [tab, setTab] = useState('billing'); const [settings, setSettings] = useState(null); @@ -55,8 +55,8 @@ export default function KantineDatevPage() { }, []); const tabs: { id: TabId; label: string }[] = [{ id: 'billing', label: t('tabs.billing') }]; - if (isAdmin) tabs.push({ id: 'settings', label: t('tabs.settings') }); - const activeTab = tab === 'settings' && !isAdmin ? 'billing' : tab; + if (canManage) tabs.push({ id: 'settings', label: t('tabs.settings') }); + const activeTab = tab === 'settings' && !canManage ? 'billing' : tab; return (
@@ -67,7 +67,7 @@ export default function KantineDatevPage() { setTab('settings')} /> ) : ( @@ -81,12 +81,12 @@ export default function KantineDatevPage() { function BillingTab({ settings, settingsError, - isAdmin, + canManage, onOpenSettings, }: { settings: KantineSettings | null; settingsError: boolean; - isAdmin: boolean; + canManage: boolean; onOpenSettings: () => void; }) { const t = useTranslations('kantineDatev'); @@ -151,8 +151,8 @@ function BillingTab({ {notConfigured && (
-

{isAdmin ? t('notConfigured.admin') : t('notConfigured.user')}

- {isAdmin && ( +

{canManage ? t('notConfigured.admin') : t('notConfigured.user')}

+ {canManage && (