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>
This commit is contained in:
2026-10-02 13:28:24 +02:00
parent b94d267584
commit a222711ad9
17 changed files with 664 additions and 103 deletions
@@ -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';
+13 -1
View File
@@ -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
@@ -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<string, unknown>) {
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');
});
});
@@ -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;
}
+14
View File
@@ -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';`,
);
});
});
@@ -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();
+41 -17
View File
@@ -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<ModuleGrant> => {
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;
@@ -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) => {
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', () => {
@@ -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);
}
@@ -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({
@@ -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<Set<string>> {
// EIN gebundener Klient fuer alle vier mandantengebundenen Zugriffe
): Promise<Map<string, ModuleGrantLevel>> {
// 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<string, ModuleGrantLevel>();
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<string, ModuleGrantLevel>();
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<Set<string>> {
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,
}));
}
/**
@@ -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);
});
});
+66 -10
View File
@@ -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<boolean>(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<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`,
);
}
// 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),
);
}
@@ -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(<KantineDatevPage />);
expect(await screen.findByRole('button', { name: 'Einstellungen' })).toBeTruthy();
});
it('Benutzer ohne canManage sieht keinen Einstellungen-Reiter', async () => {
render(<KantineDatevPage />);
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(<KantineDatevPage />);
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);
@@ -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<TabId>('billing');
const [settings, setSettings] = useState<KantineSettings | null>(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 (
<div className="mx-auto max-w-4xl space-y-6 p-3 sm:p-6">
@@ -67,7 +67,7 @@ export default function KantineDatevPage() {
<BillingTab
settings={settings}
settingsError={settingsError}
isAdmin={isAdmin}
canManage={canManage}
onOpenSettings={() => 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 && (
<div className="space-y-2 rounded-md border border-status-warn/40 bg-status-warn/10 px-4 py-3 text-sm text-foreground">
<p>{isAdmin ? t('notConfigured.admin') : t('notConfigured.user')}</p>
{isAdmin && (
<p>{canManage ? t('notConfigured.admin') : t('notConfigured.user')}</p>
{canManage && (
<button
type="button"
onClick={onOpenSettings}
+2
View File
@@ -19,6 +19,8 @@ export interface ApiModule {
icon?: string;
version: string;
isSystem: boolean;
/** Freigabestufe Verwalten (261002-icv) — nur Anzeige, bindend bleibt die API. */
canManage?: boolean;
}
/**
+56
View File
@@ -0,0 +1,56 @@
'use client';
import { useEffect, useState } from 'react';
import { useAuthStore } from '@/lib/stores/auth-store';
const API_URL = process.env.NEXT_PUBLIC_API_URL || 'http://localhost:3001';
/**
* Darf der angemeldete Benutzer die Einstellungen dieses Moduls ändern
* (Freigabestufe Verwalten, 261002-icv)?
*
* Rückgabe: `null` solange noch unklar (kein Benutzer geladen bzw. Abfrage
* läuft), sonst `true`/`false`. Administratoren und Super-Administratoren
* sind sofort `true` — das spiegelt den Kurzschluss im Backend, es gibt
* dafür keine Abfrage. Alle anderen fragen einmal `GET /modules/active` ab
* und sind nur `true`, wenn der Eintrag dieses Moduls `canManage === true`
* trägt; ein Fehler zählt als `false`.
*
* Reine Anzeigehilfe: Welche Schaltflächen sichtbar sind, entscheidet nichts
* über die Berechtigung — bindend ist allein der ModuleGuard der API.
*/
export function useCanManageModule(moduleSlug: string): boolean | null {
const role = useAuthStore((s) => s.user?.role ?? null);
const hasUser = useAuthStore((s) => s.user !== null && s.user !== undefined);
const isAdmin = role === 'ADMIN' || role === 'SUPER_ADMIN';
const [fetched, setFetched] = useState<boolean | null>(null);
useEffect(() => {
if (!hasUser || isAdmin) return;
let cancelled = false;
(async () => {
try {
const response = await fetch(`${API_URL}/modules/active`, {
credentials: 'include',
cache: 'no-store',
});
if (!response.ok) {
if (!cancelled) setFetched(false);
return;
}
const modules = (await response.json()) as Array<{ slug: string; canManage?: boolean }>;
const entry = Array.isArray(modules) ? modules.find((m) => m.slug === moduleSlug) : null;
if (!cancelled) setFetched(entry?.canManage === true);
} catch {
if (!cancelled) setFetched(false);
}
})();
return () => {
cancelled = true;
};
}, [hasUser, isAdmin, moduleSlug]);
if (!hasUser) return null;
if (isAdmin) return true;
return fetched;
}