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:
@@ -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';
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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) => {
|
||||
expect(Reflect.getMetadata(ROLES_KEY, proto[name])).toBeUndefined();
|
||||
});
|
||||
it.each(['getSettings', 'preview', 'export'])(
|
||||
'%s traegt weder Routen-Rolle noch Verwalten-Pflicht',
|
||||
(name) => {
|
||||
expect(Reflect.getMetadata(ROLES_KEY, proto[name])).toBeUndefined();
|
||||
expect(Reflect.getMetadata(MODULE_MANAGE_KEY, proto[name])).toBeUndefined();
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
describe('KantineDatevController — Verhalten', () => {
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
Reference in New Issue
Block a user