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 // darf höchstens eine Gruppe die Standard-Markierung tragen, DB-erzwungen
// über einen partiellen Unique-Index in der Hand-SQL-Ergänzung dieser // über einen partiellen Unique-Index in der Hand-SQL-Ergänzung dieser
// Migration (Prisma 6.19 kennt keine partiellen Indizes ohne Preview-Flag). // 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 { enum MembershipSource {
MANUAL MANUAL
LDAP 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 { model Group {
id String @id @default(uuid()) id String @id @default(uuid())
tenantId String tenantId String
@@ -191,6 +199,10 @@ model ModuleGrant {
userId String? userId String?
user User? @relation(fields: [userId], references: [id], onDelete: Cascade) user User? @relation(fields: [userId], references: [id], onDelete: Cascade)
createdAt DateTime @default(now()) 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 // Entweder-oder (Gruppe XOR Benutzer, D-04) + Duplikat-Schutz je Variante
// werden per hand-editierter migration.sql ergänzt — Prisma 6.19 hat kein // 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). * DTO für Grant-Erstellung und -Entzug (PERM-03).
@@ -21,4 +22,12 @@ export class CreateModuleGrantDto {
@IsString() @IsString()
@IsOptional() @IsOptional()
userId?: string; 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/); 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) => { findFirst: async ({ where }: any) => {
return findGrant(where.tenantId, where.moduleId, where.groupId, where.userId) ?? null; 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) => { findMany: async ({ where }: any) => {
let rows = Array.from(grants.values()).filter((g) => g.tenantId === where.tenantId); 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', () => { describe('ModuleGrantsService.revoke', () => {
it('entfernt einen bestehenden Grant', async () => { it('entfernt einen bestehenden Grant', async () => {
const prisma = makeFakePrisma(); const prisma = makeFakePrisma();
+41 -17
View File
@@ -4,6 +4,7 @@ import {
Logger, Logger,
NotFoundException, NotFoundException,
} from '@nestjs/common'; } from '@nestjs/common';
import { type ModuleGrant, ModuleGrantLevel } from '@prisma/client';
import { PrismaService } from '../prisma/prisma.service'; import { PrismaService } from '../prisma/prisma.service';
import { forTenant } from '../prisma/prisma-tenant.extension'; import { forTenant } from '../prisma/prisma-tenant.extension';
import { prismaErrorCode } from '../prisma/prisma-error'; 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 * über `this.logger`. Es entsteht bewusst keine Audit-Tabelle und keine
* Ansicht im Admin-UI. * Ansicht im Admin-UI.
* *
* D-04: der Datensatz trägt keine Rechtestufe, und dieser Service bietet * Seit 261002-icv trägt der Datensatz eine Freigabestufe `level` (Benutzen /
* keine Methode, die eine solche setzen könnte. * Verwalten); nur dieser ausschließlich Administratoren zugängliche Service
* setzt sie.
*/ */
@Injectable() @Injectable()
export class ModuleGrantsService { export class ModuleGrantsService {
@@ -82,9 +84,9 @@ export class ModuleGrantsService {
*/ */
async grant( async grant(
tenantId: string, 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)) { if ((groupId && userId) || (!groupId && !userId)) {
throw new BadRequestException( throw new BadRequestException(
'Ein Grant muss entweder eine groupId oder eine userId tragen, nicht beides und nicht keines', '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 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 { try {
const created = await tenantPrisma.moduleGrant.create({ const created = await tenantPrisma.moduleGrant.create({
@@ -127,27 +160,18 @@ export class ModuleGrantsService {
moduleId, moduleId,
groupId: groupId ?? null, groupId: groupId ?? null,
userId: userId ?? null, userId: userId ?? null,
level: level ?? ModuleGrantLevel.USE,
}, },
}); });
this.logger.log( 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; return created;
} catch (err: unknown) { } catch (err: unknown) {
if (prismaErrorCode(err) === 'P2002') { if (prismaErrorCode(err) === 'P2002') {
const existing = await tenantPrisma.moduleGrant.findFirst({ const existing = await tenantPrisma.moduleGrant.findFirst({ where: targetWhere });
where: {
tenantId,
moduleId,
groupId: groupId ?? null,
userId: userId ?? null,
},
});
if (existing) { if (existing) {
this.logger.log( return applyToExisting(existing);
`Grant bereits vorhanden (Doppelklick abgefangen): tenant=${tenantId} module=${moduleId} ${target}`,
);
return existing;
} }
} }
throw err; throw err;
@@ -1,9 +1,8 @@
import 'reflect-metadata'; import 'reflect-metadata';
import { BadRequestException, ForbiddenException, ValidationPipe } from '@nestjs/common'; import { BadRequestException, ForbiddenException, ValidationPipe } from '@nestjs/common';
import { Role } from '@prisma/client';
import { describe, expect, it, vi } from 'vitest'; import { describe, expect, it, vi } from 'vitest';
import { ROLES_KEY } from '../auth/decorators/roles.decorator'; 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 { KantineDatevSettingsDto } from './dto/kantine-datev-settings.dto';
import { KantineDatevController } from './kantine-datev.controller'; import { KantineDatevController } from './kantine-datev.controller';
@@ -25,16 +24,19 @@ describe('KantineDatevController — Metadaten', () => {
expect(Reflect.getMetadata(MODULE_SLUG_KEY, KantineDatevController)).toBe('kantine-datev'); expect(Reflect.getMetadata(MODULE_SLUG_KEY, KantineDatevController)).toBe('kantine-datev');
}); });
it('PUT settings verlangt ADMIN/SUPER_ADMIN', () => { it('PUT settings verlangt die Freigabestufe Verwalten und trägt keine Routen-Rolle (261002-icv)', () => {
expect(Reflect.getMetadata(ROLES_KEY, proto.saveSettings)).toEqual([ expect(Reflect.getMetadata(MODULE_MANAGE_KEY, proto.saveSettings)).toBe(true);
Role.ADMIN, expect(Reflect.getMetadata(MODULE_SLUG_KEY, proto.saveSettings)).toBe('kantine-datev');
Role.SUPER_ADMIN, expect(Reflect.getMetadata(ROLES_KEY, proto.saveSettings)).toBeUndefined();
]);
}); });
it.each(['getSettings', 'preview', 'export'])('%s traegt keine Routen-Rolle', (name) => { it.each(['getSettings', 'preview', 'export'])(
expect(Reflect.getMetadata(ROLES_KEY, proto[name])).toBeUndefined(); '%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', () => { describe('KantineDatevController — Verhalten', () => {
@@ -11,10 +11,8 @@ import {
UseInterceptors, UseInterceptors,
} from '@nestjs/common'; } from '@nestjs/common';
import { FileInterceptor } from '@nestjs/platform-express'; 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 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 { KantineDatevSettingsDto } from './dto/kantine-datev-settings.dto';
import { KantineDatevService } from './kantine-datev.service'; import { KantineDatevService } from './kantine-datev.service';
@@ -22,7 +20,8 @@ import { KantineDatevService } from './kantine-datev.service';
* `@UseModule('kantine-datev')` auf Klassenebene — Aktivierung UND Freigabe. * `@UseModule('kantine-datev')` auf Klassenebene — Aktivierung UND Freigabe.
* `tenantId` kommt ausschliesslich aus `req.tenantId` (TenantGuard). Lesen, * `tenantId` kommt ausschliesslich aus `req.tenantId` (TenantGuard). Lesen,
* Vorschau und Export stehen jedem Benutzer mit Modulzugriff offen; die * 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). * bleiben im Arbeitsspeicher (multer-Standard), 5 MB Grenze (T-FM5-04).
* Keine `:id`-Routen in diesem Controller. * Keine `:id`-Routen in diesem Controller.
*/ */
@@ -45,7 +44,7 @@ export class KantineDatevController {
} }
@Put('settings') @Put('settings')
@Roles(Role.ADMIN, Role.SUPER_ADMIN) @ModuleManage('kantine-datev')
async saveSettings(@Req() req: AuthenticatedRequest, @Body() dto: KantineDatevSettingsDto) { async saveSettings(@Req() req: AuthenticatedRequest, @Body() dto: KantineDatevSettingsDto) {
return this.service.saveSettings(this.requireTenantId(req), dto); return this.service.saveSettings(this.requireTenantId(req), dto);
} }
@@ -30,8 +30,8 @@ const BOUND_MODEL_NAMES = ['tenantModuleActivation', 'moduleGrant'];
function makeFakePrisma(opts: { function makeFakePrisma(opts: {
activations?: { moduleId: string }[]; activations?: { moduleId: string }[];
directGrants?: { moduleId: string }[]; directGrants?: { moduleId: string; level?: string }[];
groupGrants?: { moduleId: string }[]; groupGrants?: { moduleId: string; level?: string }[];
} = {}) { } = {}) {
const activations = opts.activations ?? []; const activations = opts.activations ?? [];
const directGrants = opts.directGrants ?? []; 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)', () => { describe('ModuleAccessService.findAccessibleModules — ordering (PERM-04)', () => {
it('sortiert die zugänglichen Module deterministisch nach Namen aufsteigend', async () => { it('sortiert die zugänglichen Module deterministisch nach Namen aufsteigend', async () => {
const prisma = makeFakePrisma({ const prisma = makeFakePrisma({
@@ -1,5 +1,5 @@
import { Injectable } from '@nestjs/common'; import { Injectable } from '@nestjs/common';
import { Role } from '@prisma/client'; import { ModuleGrantLevel, Role } from '@prisma/client';
import { PrismaService } from '../prisma/prisma.service'; import { PrismaService } from '../prisma/prisma.service';
import { forTenant } from '../prisma/prisma-tenant.extension'; 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 * `getAccessibleModuleIds` auf, damit Sidebar, Modulseiten und API
* niemals auseinanderdriften können — genau das Sicherheitsloch, das * niemals auseinanderdriften können — genau das Sicherheitsloch, das
* D-01 strukturell verhindert. * 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() @Injectable()
export class ModuleAccessService { export class ModuleAccessService {
constructor(private readonly prisma: PrismaService) {} 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 * D-03/L-03: ADMIN/SUPER_ADMIN umgehen jede Grant-Pruefung — sie erhalten
* mandantenweit aktiven Module ihres eigenen Mandanten. Diese Rolle * alle aktiven Module ihres eigenen Mandanten mit Stufe MANAGE. Diese Rolle
* kommt ausschließlich aus dem JWT (Aufrufer), nie aus Body/Params — * kommt ausschliesslich aus dem JWT (Aufrufer), nie aus Body/Params —
* der Kurzschluss kann daher keine Module eines fremden Mandanten * der Kurzschluss kann daher keine Module eines fremden Mandanten
* liefern, weil `tenantId` ebenfalls aus dem JWT stammt (T-15-10). * liefern, weil `tenantId` ebenfalls aus dem JWT stammt (T-15-10).
* *
* Für alle anderen Rollen (USER): Vereinigungsmenge aus Direkt-Grants * Fuer alle anderen Rollen (USER): Vereinigungsmenge aus Direkt-Grants
* und Grants über Gruppenmitgliedschaften, geschnitten mit den * und Grants ueber Gruppenmitgliedschaften, geschnitten mit den
* mandantenweit aktiven Modulen (D-02 — ein Grant auf ein deaktiviertes * aktiven Modulen (D-02 — ein Grant auf ein deaktiviertes Modul gewaehrt
* Modul gewährt keinen Zugriff). Die Gruppen-Query ist eine einzige * keinen Zugriff, auch keine Verwaltungsstufe). Besteht Zugriff ueber
* verschachtelte Prisma-Query (`group: { memberships: { some: { userId } } }`) * mehrere Wege, gilt die hoehere Stufe (MANAGE gewinnt, L-02); ein Wert
* statt einer Schleife über die Gruppen des Benutzers — sonst entsteht * ausser 'MANAGE' (z. B. eine Zeile ohne `level`) zaehlt als USE. Die
* ein N+1 pro geschütztem Endpoint. * 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 * Rein lesend, kein Caching ueber Request-Grenzen hinweg (D-09) — ein
* Freigabe-Entzug wirkt bei der nächsten Anfrage. * Freigabe-Entzug wirkt bei der naechsten Anfrage.
*/ */
async getAccessibleModuleIds( async getModuleAccessLevels(
tenantId: string, tenantId: string,
userId: string, userId: string,
role: Role, role: Role,
): Promise<Set<string>> { ): Promise<Map<string, ModuleGrantLevel>> {
// EIN gebundener Klient fuer alle vier mandantengebundenen Zugriffe // EIN gebundener Klient fuer alle mandantengebundenen Zugriffe
// dieser Methode (Kurzschlusszweig, Direktweg, Gruppenweg, Schnittmenge) // dieser Methode (Kurzschlusszweig, Direktweg, Gruppenweg, Schnittmenge)
// — nicht ein Klient je Modellzugriff (260910-exd, Aufgabe 2). Die // — nicht ein Klient je Modellzugriff (260910-exd, Aufgabe 2). Die
// bestehenden `where`-Filter mit tenantId bleiben ZUSAETZLICH stehen: // bestehenden `where`-Filter mit tenantId bleiben ZUSAETZLICH stehen:
@@ -61,46 +68,80 @@ export class ModuleAccessService {
where: { tenantId, isActive: true }, where: { tenantId, isActive: true },
select: { moduleId: 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([ const [direct, viaGroup] = await Promise.all([
tenantPrisma.moduleGrant.findMany({ tenantPrisma.moduleGrant.findMany({
where: { tenantId, userId }, where: { tenantId, userId },
select: { moduleId: true }, select: { moduleId: true, level: true },
}), }),
tenantPrisma.moduleGrant.findMany({ tenantPrisma.moduleGrant.findMany({
where: { tenantId, group: { memberships: { some: { userId } } } }, 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) { const granted = new Map<string, ModuleGrantLevel>();
return new Set(); 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({ const activations = await tenantPrisma.tenantModuleActivation.findMany({
where: { where: {
tenantId, tenantId,
isActive: true, isActive: true,
moduleId: { in: grantedIds }, moduleId: { in: [...granted.keys()] },
}, },
select: { moduleId: true }, 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 * Lädt die vollständigen Module-Datensätze, auf die dieser Benutzer
* Zugriff hat, sortiert nach Name. Bedient `GET /modules/active` — die * Zugriff hat, sortiert nach Name. Bedient `GET /modules/active` — die
* explizite Sortierung hält die Sidebar-Reihenfolge über Aufrufe hinweg * 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) { 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 []; return [];
} }
@@ -111,10 +152,14 @@ export class ModuleAccessService {
// Tabelle eine Regel gibt — dann verschwaende der gesamte Katalog fuer // Tabelle eine Regel gibt — dann verschwaende der gesamte Katalog fuer
// jeden Mandanten. Diese Bedingung steht hier als Bedingung, nicht als // jeden Mandanten. Diese Bedingung steht hier als Bedingung, nicht als
// heute beobachtbare Tatsache. // heute beobachtbare Tatsache.
return this.prisma.module.findMany({ const rows = await this.prisma.module.findMany({
where: { id: { in: [...accessibleIds] } }, where: { id: { in: [...levels.keys()] } },
orderBy: { name: 'asc' }, 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 { ForbiddenException } from '@nestjs/common';
import { GUARDS_METADATA } from '@nestjs/common/constants';
import { describe, expect, it, vi } from 'vitest'; 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 * ModuleGuard.canActivate — deckt die vollständige Behavior-Liste aus
@@ -18,26 +19,28 @@ function makeContext(request: any) {
} as any; } as any;
} }
function makeReflector(slug: string | undefined) { function makeReflector(slug: string | undefined, manage = false) {
return { getAllAndOverride: vi.fn(() => slug) } as any; return {
getAllAndOverride: vi.fn((key: string) => (key === MODULE_MANAGE_KEY ? manage : slug)),
} as any;
} }
describe('ModuleGuard.canActivate', () => { describe('ModuleGuard.canActivate', () => {
it('gibt true zurück und ruft keinen Service auf, wenn kein @UseModule-Slug in den Metadaten steht', async () => { 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 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 guard = new ModuleGuard(makeReflector(undefined), moduleRegistryService, moduleAccessService);
const result = await guard.canActivate(makeContext({})); const result = await guard.canActivate(makeContext({}));
expect(result).toBe(true); expect(result).toBe(true);
expect(moduleRegistryService.findBySlug).not.toHaveBeenCalled(); 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 () => { 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 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); const guard = new ModuleGuard(makeReflector('domaincheck'), moduleRegistryService, moduleAccessService);
await expect(guard.canActivate(makeContext({ user: {} }))).rejects.toThrow( 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 () => { it('wirft ForbiddenException("No user context"), wenn tenantId gesetzt ist, aber userId/role fehlen', async () => {
const moduleRegistryService = { findBySlug: vi.fn() } as any; 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); const guard = new ModuleGuard(makeReflector('domaincheck'), moduleRegistryService, moduleAccessService);
await expect( await expect(
@@ -57,7 +60,7 @@ describe('ModuleGuard.canActivate', () => {
it('wirft ForbiddenException bei unbekanntem Slug (findBySlug liefert null)', async () => { it('wirft ForbiddenException bei unbekanntem Slug (findBySlug liefert null)', async () => {
const moduleRegistryService = { findBySlug: vi.fn().mockResolvedValue(null) } as any; 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); const guard = new ModuleGuard(makeReflector('unknown-slug'), moduleRegistryService, moduleAccessService);
await expect( await expect(
@@ -65,7 +68,7 @@ describe('ModuleGuard.canActivate', () => {
makeContext({ tenantId: 't1', user: { id: 'user-1', role: 'USER' } }), makeContext({ tenantId: 't1', user: { id: 'user-1', role: 'USER' } }),
), ),
).rejects.toThrow(ForbiddenException); ).rejects.toThrow(ForbiddenException);
expect(moduleAccessService.getAccessibleModuleIds).not.toHaveBeenCalled(); expect(moduleAccessService.getModuleAccessLevels).not.toHaveBeenCalled();
}); });
it('USER ohne Grant auf ein aktives Modul: wirft ForbiddenException', async () => { 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' }), findBySlug: vi.fn().mockResolvedValue({ id: 'mod-1', slug: 'domaincheck' }),
} as any; } as any;
const moduleAccessService = { const moduleAccessService = {
getAccessibleModuleIds: vi.fn().mockResolvedValue(new Set()), getModuleAccessLevels: vi.fn().mockResolvedValue(new Map()),
} as any; } as any;
const guard = new ModuleGuard(makeReflector('domaincheck'), moduleRegistryService, moduleAccessService); const guard = new ModuleGuard(makeReflector('domaincheck'), moduleRegistryService, moduleAccessService);
@@ -89,7 +92,7 @@ describe('ModuleGuard.canActivate', () => {
findBySlug: vi.fn().mockResolvedValue({ id: 'mod-1', slug: 'domaincheck' }), findBySlug: vi.fn().mockResolvedValue({ id: 'mod-1', slug: 'domaincheck' }),
} as any; } as any;
const moduleAccessService = { const moduleAccessService = {
getAccessibleModuleIds: vi.fn().mockResolvedValue(new Set(['mod-1'])), getModuleAccessLevels: vi.fn().mockResolvedValue(new Map([['mod-1', 'MANAGE']])),
} as any; } as any;
const guard = new ModuleGuard(makeReflector('domaincheck'), moduleRegistryService, moduleAccessService); const guard = new ModuleGuard(makeReflector('domaincheck'), moduleRegistryService, moduleAccessService);
const request = { tenantId: 't1', user: { id: 'admin-1', role: 'ADMIN' } }; const request = { tenantId: 't1', user: { id: 'admin-1', role: 'ADMIN' } };
@@ -97,16 +100,16 @@ describe('ModuleGuard.canActivate', () => {
const result = await guard.canActivate(makeContext(request)); const result = await guard.canActivate(makeContext(request));
expect(result).toBe(true); 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 () => { it('USER mit Zugriff: gibt true zurück und legt das Ergebnis auf request.moduleAccessIds ab (Per-Request-Memoisierung, D-09)', async () => {
const moduleRegistryService = { const moduleRegistryService = {
findBySlug: vi.fn().mockResolvedValue({ id: 'mod-1', slug: 'domaincheck' }), findBySlug: vi.fn().mockResolvedValue({ id: 'mod-1', slug: 'domaincheck' }),
} as any; } as any;
const accessibleIds = new Set(['mod-1']); const accessibleIds = new Map([['mod-1', 'USE']]);
const moduleAccessService = { const moduleAccessService = {
getAccessibleModuleIds: vi.fn().mockResolvedValue(accessibleIds), getModuleAccessLevels: vi.fn().mockResolvedValue(accessibleIds),
} as any; } as any;
const guard = new ModuleGuard(makeReflector('domaincheck'), moduleRegistryService, moduleAccessService); const guard = new ModuleGuard(makeReflector('domaincheck'), moduleRegistryService, moduleAccessService);
const request = { tenantId: 't1', user: { id: 'user-1', role: 'USER' } }; const request = { tenantId: 't1', user: { id: 'user-1', role: 'USER' } };
@@ -114,7 +117,8 @@ describe('ModuleGuard.canActivate', () => {
const result = await guard.canActivate(makeContext(request)); const result = await guard.canActivate(makeContext(request));
expect(result).toBe(true); 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. // Fall A: der Benutzer hat tatsächlich keine Freigabe.
const moduleAccessServiceGenuinelyEmpty = { const moduleAccessServiceGenuinelyEmpty = {
getAccessibleModuleIds: vi.fn().mockResolvedValue(new Set()), getModuleAccessLevels: vi.fn().mockResolvedValue(new Map()),
} as any; } as any;
const guardA = new ModuleGuard( const guardA = new ModuleGuard(
makeReflector('domaincheck'), makeReflector('domaincheck'),
@@ -153,7 +157,7 @@ describe('ModuleGuard.canActivate', () => {
// einer ungebunden gebliebenen Abfrage) trotzdem eine leere Menge — // einer ungebunden gebliebenen Abfrage) trotzdem eine leere Menge —
// aus Sicht des Wächters nicht von Fall A zu unterscheiden. // aus Sicht des Wächters nicht von Fall A zu unterscheiden.
const moduleAccessServiceQueryFoundNothing = { const moduleAccessServiceQueryFoundNothing = {
getAccessibleModuleIds: vi.fn().mockResolvedValue(new Set()), getModuleAccessLevels: vi.fn().mockResolvedValue(new Map()),
} as any; } as any;
const guardB = new ModuleGuard( const guardB = new ModuleGuard(
makeReflector('domaincheck'), makeReflector('domaincheck'),
@@ -183,3 +187,71 @@ describe('ModuleGuard.canActivate', () => {
).toBe(messageA); ).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, UseGuards,
} from '@nestjs/common'; } from '@nestjs/common';
import { Reflector } from '@nestjs/core'; import { Reflector } from '@nestjs/core';
import { ModuleGrantLevel } from '@prisma/client';
import { ModuleAccessService } from './module-access.service'; import { ModuleAccessService } from './module-access.service';
import { ModuleRegistryService } from './module-registry.service'; import { ModuleRegistryService } from './module-registry.service';
@@ -16,6 +17,12 @@ import { ModuleRegistryService } from './module-registry.service';
*/ */
export const MODULE_SLUG_KEY = 'moduleSlug'; 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 * Guard that checks whether the requesting user has access to the module
* identified by its slug — Aktivierung UND (Rolle ODER Direkt-Grant ODER * 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 * T-15-03: Ohne `@UseModule(slug)`-Metadaten gibt der Guard bewusst
* `true` zurück (Durchsetzung hängt am Dekorator) — jeder neue * `true` zurück (Durchsetzung hängt am Dekorator) — jeder neue
* Modul-Controller MUSS `@UseModule` tragen (Projektregel seit Phase 3). * 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() @Injectable()
export class ModuleGuard implements CanActivate { export class ModuleGuard implements CanActivate {
@@ -71,22 +85,37 @@ export class ModuleGuard implements CanActivate {
); );
} }
const accessibleModuleIds = await this.moduleAccessService.getAccessibleModuleIds( const requireManage =
tenantId, this.reflector.getAllAndOverride<boolean>(MODULE_MANAGE_KEY, [
userId, context.getHandler(),
role, 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( throw new ForbiddenException(
`Module '${moduleSlug}' is not accessible for this user`, `Module '${moduleSlug}' is not accessible for this user`,
); );
} }
// Per-Request-Memoisierung (D-09): ein nachfolgender Handler im if (requireManage && level !== ModuleGrantLevel.MANAGE) {
// selben Request bezahlt die Auflösung nicht ein zweites Mal. Über throw new ForbiddenException(
// Request-Grenzen hinweg wird nichts zwischengespeichert. `Module '${moduleSlug}' requires manage permission`,
request.moduleAccessIds = accessibleModuleIds; );
}
request.moduleAccessLevels = levels;
request.moduleAccessIds = new Set(levels.keys());
return true; return true;
} }
@@ -107,3 +136,30 @@ export function UseModule(slug: string) {
UseGuards(ModuleGuard), 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] } }); 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(() => { beforeEach(() => {
mockUser('USER'); mockUser('USER');
mockGetSettings.mockResolvedValue(CONFIGURED); mockGetSettings.mockResolvedValue(CONFIGURED);
stubActiveModules([{ slug: 'kantine-datev', canManage: false }]);
vi.stubGlobal('fetch', mockFetch);
}); });
afterEach(() => { afterEach(() => {
cleanup(); cleanup();
vi.unstubAllGlobals();
mockFetch.mockReset();
for (const m of [ for (const m of [
mockGetSettings, mockGetSettings,
mockSaveSettings, 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', () => { describe('KantineDatevPage — Abrechnung', () => {
it('zeigt nach dem Hochladen Zeilen, Abrechnungsmonat und Gesamtbetrag', async () => { it('zeigt nach dem Hochladen Zeilen, Abrechnungsmonat und Gesamtbetrag', async () => {
mockPreview.mockResolvedValue(GOOD_PREVIEW); mockPreview.mockResolvedValue(GOOD_PREVIEW);
@@ -15,7 +15,7 @@ import {
previewKantineCsv, previewKantineCsv,
saveKantineSettings, saveKantineSettings,
} from '@/lib/kantine-datev-api'; } from '@/lib/kantine-datev-api';
import { useAuthStore } from '@/lib/stores/auth-store'; import { useCanManageModule } from '@/lib/use-module-capability';
type TabId = 'billing' | 'settings'; type TabId = 'billing' | 'settings';
@@ -33,8 +33,8 @@ const euro = new Intl.NumberFormat('de-DE', { style: 'currency', currency: 'EUR'
*/ */
export default function KantineDatevPage() { export default function KantineDatevPage() {
const t = useTranslations('kantineDatev'); const t = useTranslations('kantineDatev');
const user = useAuthStore((s) => s.user); // Administratoren und Benutzer mit der Freigabestufe Verwalten (261002-icv).
const isAdmin = user?.role === 'ADMIN' || user?.role === 'SUPER_ADMIN'; const canManage = useCanManageModule('kantine-datev') === true;
const [tab, setTab] = useState<TabId>('billing'); const [tab, setTab] = useState<TabId>('billing');
const [settings, setSettings] = useState<KantineSettings | null>(null); 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') }]; const tabs: { id: TabId; label: string }[] = [{ id: 'billing', label: t('tabs.billing') }];
if (isAdmin) tabs.push({ id: 'settings', label: t('tabs.settings') }); if (canManage) tabs.push({ id: 'settings', label: t('tabs.settings') });
const activeTab = tab === 'settings' && !isAdmin ? 'billing' : tab; const activeTab = tab === 'settings' && !canManage ? 'billing' : tab;
return ( return (
<div className="mx-auto max-w-4xl space-y-6 p-3 sm:p-6"> <div className="mx-auto max-w-4xl space-y-6 p-3 sm:p-6">
@@ -67,7 +67,7 @@ export default function KantineDatevPage() {
<BillingTab <BillingTab
settings={settings} settings={settings}
settingsError={settingsError} settingsError={settingsError}
isAdmin={isAdmin} canManage={canManage}
onOpenSettings={() => setTab('settings')} onOpenSettings={() => setTab('settings')}
/> />
) : ( ) : (
@@ -81,12 +81,12 @@ export default function KantineDatevPage() {
function BillingTab({ function BillingTab({
settings, settings,
settingsError, settingsError,
isAdmin, canManage,
onOpenSettings, onOpenSettings,
}: { }: {
settings: KantineSettings | null; settings: KantineSettings | null;
settingsError: boolean; settingsError: boolean;
isAdmin: boolean; canManage: boolean;
onOpenSettings: () => void; onOpenSettings: () => void;
}) { }) {
const t = useTranslations('kantineDatev'); const t = useTranslations('kantineDatev');
@@ -151,8 +151,8 @@ function BillingTab({
{notConfigured && ( {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"> <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> <p>{canManage ? t('notConfigured.admin') : t('notConfigured.user')}</p>
{isAdmin && ( {canManage && (
<button <button
type="button" type="button"
onClick={onOpenSettings} onClick={onOpenSettings}
+2
View File
@@ -19,6 +19,8 @@ export interface ApiModule {
icon?: string; icon?: string;
version: string; version: string;
isSystem: boolean; 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;
}