feat(15-03): ModuleGrantsService — Freigaben setzen/entziehen mit Mandanten-Gegenprüfung

- assertTargetBelongsToTenant prüft groupId/userId aus dem Request-Body
  gegen tenantId aus dem JWT (T-15-01), vor jedem Grant-Insert
- grant: Entweder-oder-Regel (D-04), aktive TenantModuleActivation (D-02),
  P2002 als Erfolg (Doppelklick-Schutz)
- getMatrix (D-15) und getUserAccess (D-16) für Matrix-Seite und
  Benutzer-Detail, jeweils sortiert und mandantengescoped
- 20 Tests inkl. adjacency/empty/ordering/idempotency/concurrency
This commit is contained in:
2026-08-04 18:41:12 +02:00
parent 614de2815a
commit 5e256db01d
3 changed files with 732 additions and 0 deletions
@@ -0,0 +1,457 @@
import { BadRequestException, Logger, NotFoundException } from '@nestjs/common';
import { afterEach, describe, expect, it, vi } from 'vitest';
import { ModuleGrantsService } from './module-grants.service';
/**
* ModuleGrantsService.spec — Beweis für PERM-03 (D-15/D-16), die
* Entweder-oder-Regel (D-04) und die Mandanten-Gegenprüfung vor jedem
* Grant-Insert (T-15-01). Hand-rolled In-Memory-Prisma-Fake im Stil von
* groups.service.spec.ts / module-access.service.spec.ts — keine Live-DB,
* P2002 wird exakt wie ein echter Postgres-Client über den Fehlercode
* simuliert.
*/
function makeFakePrisma() {
const groups = new Map<string, any>();
const users = new Map<string, any>();
const memberships = new Map<string, Set<string>>(); // groupId -> Set<userId>
const activations = new Map<string, any>(); // key: tenantId::moduleId
const grants = new Map<string, any>();
let grantCounter = 0;
function throwUnique(): never {
const err: any = new Error('Unique constraint failed');
err.code = 'P2002';
throw err;
}
function findGrant(
tenantId: string,
moduleId: string,
groupId?: string | null,
userId?: string | null,
) {
return Array.from(grants.values()).find(
(g) =>
g.tenantId === tenantId &&
g.moduleId === moduleId &&
(g.groupId ?? null) === (groupId ?? null) &&
(g.userId ?? null) === (userId ?? null),
);
}
return {
__seedGroup(group: { id: string; tenantId: string; name: string }) {
groups.set(group.id, group);
},
__seedUser(user: { id: string; tenantId: string }) {
users.set(user.id, user);
},
__seedMembership(groupId: string, userId: string) {
const set = memberships.get(groupId) ?? new Set<string>();
set.add(userId);
memberships.set(groupId, set);
},
__seedActivation(a: {
tenantId: string;
moduleId: string;
isActive: boolean;
module: { id: string; category: string; name: string };
}) {
activations.set(`${a.tenantId}::${a.moduleId}`, a);
},
__grantCount() {
return grants.size;
},
group: {
findFirst: async ({ where }: any) => {
return (
Array.from(groups.values()).find(
(g) => g.id === where.id && g.tenantId === where.tenantId,
) ?? null
);
},
findMany: async ({ where }: any) => {
return Array.from(groups.values())
.filter((g) => g.tenantId === where.tenantId)
.sort((a, b) => a.name.localeCompare(b.name));
},
},
user: {
findFirst: async ({ where }: any) => {
return (
Array.from(users.values()).find(
(u) => u.id === where.id && u.tenantId === where.tenantId,
) ?? null
);
},
},
tenantModuleActivation: {
findUnique: async ({ where }: any) => {
const { tenantId, moduleId } = where.tenantId_moduleId;
return activations.get(`${tenantId}::${moduleId}`) ?? null;
},
findMany: async ({ where }: any) => {
return Array.from(activations.values()).filter(
(a) => a.tenantId === where.tenantId && a.isActive === where.isActive,
);
},
},
moduleGrant: {
create: async ({ data }: any) => {
if (findGrant(data.tenantId, data.moduleId, data.groupId, data.userId)) {
throwUnique();
}
grantCounter += 1;
const record = { id: `grant-${grantCounter}`, createdAt: new Date(), ...data };
grants.set(record.id, record);
return record;
},
findFirst: async ({ where }: any) => {
return findGrant(where.tenantId, where.moduleId, where.groupId, where.userId) ?? null;
},
findMany: async ({ where }: any) => {
let rows = Array.from(grants.values()).filter((g) => g.tenantId === where.tenantId);
if (where.moduleId !== undefined) {
rows = rows.filter((g) => g.moduleId === where.moduleId);
}
if (where.groupId?.not === null) {
rows = rows.filter((g) => g.groupId !== null && g.groupId !== undefined);
}
if (where.group) {
const userId = where.group.memberships.some.userId;
rows = rows
.filter((g) => g.groupId && memberships.get(g.groupId)?.has(userId))
.map((g) => ({ ...g, group: groups.get(g.groupId) }));
} else if (where.userId !== undefined) {
rows = rows.filter((g) => g.userId === where.userId);
}
return rows;
},
deleteMany: async ({ where }: any) => {
let count = 0;
for (const [id, g] of grants.entries()) {
if (
g.tenantId === where.tenantId &&
g.moduleId === where.moduleId &&
(where.groupId === undefined || g.groupId === where.groupId) &&
(where.userId === undefined || g.userId === where.userId)
) {
grants.delete(id);
count += 1;
}
}
return { count };
},
},
};
}
function seedBase(prisma: ReturnType<typeof makeFakePrisma>) {
prisma.__seedGroup({ id: 'g1', tenantId: 't1', name: 'Gruppe A' });
prisma.__seedUser({ id: 'u1', tenantId: 't1' });
prisma.__seedActivation({
tenantId: 't1',
moduleId: 'mod-1',
isActive: true,
module: { id: 'mod-1', category: 'ops', name: 'Modul Eins' },
});
}
describe('ModuleGrantsService.grant', () => {
it('legt einen Gruppen-Grant an und gibt ihn zurück', 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.moduleId).toBe('mod-1');
expect(result.groupId).toBe('g1');
expect(result.userId ?? null).toBeNull();
});
it('legt einen Direkt-Grant an und gibt ihn zurück', async () => {
const prisma = makeFakePrisma();
seedBase(prisma);
const service = new ModuleGrantsService(prisma as any);
const result = await service.grant('t1', { moduleId: 'mod-1', userId: 'u1' });
expect(result.moduleId).toBe('mod-1');
expect(result.userId).toBe('u1');
expect(result.groupId ?? null).toBeNull();
});
it('wirft BadRequestException, wenn groupId UND userId gesetzt sind', async () => {
const prisma = makeFakePrisma();
seedBase(prisma);
const service = new ModuleGrantsService(prisma as any);
await expect(
service.grant('t1', { moduleId: 'mod-1', groupId: 'g1', userId: 'u1' }),
).rejects.toBeInstanceOf(BadRequestException);
expect(prisma.__grantCount()).toBe(0);
});
it('wirft BadRequestException, wenn weder groupId noch userId gesetzt sind', async () => {
const prisma = makeFakePrisma();
seedBase(prisma);
const service = new ModuleGrantsService(prisma as any);
await expect(service.grant('t1', { moduleId: 'mod-1' })).rejects.toBeInstanceOf(
BadRequestException,
);
expect(prisma.__grantCount()).toBe(0);
});
it('wirft NotFoundException für eine groupId aus einem anderen Mandanten und legt nichts an', async () => {
const prisma = makeFakePrisma();
seedBase(prisma);
prisma.__seedGroup({ id: 'g-foreign', tenantId: 't2', name: 'Fremde Gruppe' });
const service = new ModuleGrantsService(prisma as any);
await expect(
service.grant('t1', { moduleId: 'mod-1', groupId: 'g-foreign' }),
).rejects.toBeInstanceOf(NotFoundException);
expect(prisma.__grantCount()).toBe(0);
});
it('wirft NotFoundException für eine userId aus einem anderen Mandanten und legt nichts an', async () => {
const prisma = makeFakePrisma();
seedBase(prisma);
prisma.__seedUser({ id: 'u-foreign', tenantId: 't2' });
const service = new ModuleGrantsService(prisma as any);
await expect(
service.grant('t1', { moduleId: 'mod-1', userId: 'u-foreign' }),
).rejects.toBeInstanceOf(NotFoundException);
expect(prisma.__grantCount()).toBe(0);
});
it('wirft BadRequestException, wenn keine aktive TenantModuleActivation für das Modul existiert', async () => {
const prisma = makeFakePrisma();
prisma.__seedGroup({ id: 'g1', tenantId: 't1', name: 'Gruppe A' });
// keine Activation geseedet
const service = new ModuleGrantsService(prisma as any);
await expect(
service.grant('t1', { moduleId: 'mod-unaktiviert', groupId: 'g1' }),
).rejects.toBeInstanceOf(BadRequestException);
expect(prisma.__grantCount()).toBe(0);
});
it('idempotency: ein zweiter Grant auf dieselbe Kombination legt keinen zweiten Datensatz an und wirft nicht', async () => {
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' });
expect(second.id).toBe(first.id);
expect(prisma.__grantCount()).toBe(1);
});
it('concurrency: zwei parallele Grant-Erstellungen für dieselbe Kombination führen zu genau einer Zeile, keine der beiden wirft', async () => {
const prisma = makeFakePrisma();
seedBase(prisma);
const service = new ModuleGrantsService(prisma as any);
const [first, second] = await Promise.all([
service.grant('t1', { moduleId: 'mod-1', groupId: 'g1' }),
service.grant('t1', { moduleId: 'mod-1', groupId: 'g1' }),
]);
expect(first.groupId).toBe('g1');
expect(second.groupId).toBe('g1');
expect(prisma.__grantCount()).toBe(1);
});
});
describe('ModuleGrantsService.revoke', () => {
it('entfernt einen bestehenden Grant', async () => {
const prisma = makeFakePrisma();
seedBase(prisma);
const service = new ModuleGrantsService(prisma as any);
await service.grant('t1', { moduleId: 'mod-1', groupId: 'g1' });
await service.revoke('t1', { moduleId: 'mod-1', groupId: 'g1' });
expect(prisma.__grantCount()).toBe(0);
});
it('idempotency: ein zweites Entziehen eines bereits entzogenen Grants ist folgenlos und wirft nicht', async () => {
const prisma = makeFakePrisma();
seedBase(prisma);
const service = new ModuleGrantsService(prisma as any);
await service.grant('t1', { moduleId: 'mod-1', groupId: 'g1' });
await service.revoke('t1', { moduleId: 'mod-1', groupId: 'g1' });
await expect(
service.revoke('t1', { moduleId: 'mod-1', groupId: 'g1' }),
).resolves.not.toThrow();
expect(prisma.__grantCount()).toBe(0);
});
it('entfernt nichts, wenn die groupId aus einem anderen Mandanten stammt', async () => {
const prisma = makeFakePrisma();
seedBase(prisma);
const service = new ModuleGrantsService(prisma as any);
await service.grant('t1', { moduleId: 'mod-1', groupId: 'g1' });
await service.revoke('t2', { moduleId: 'mod-1', groupId: 'g1' });
expect(prisma.__grantCount()).toBe(1);
});
});
describe('ModuleGrantsService.getMatrix', () => {
it('liefert modules, groups und grants; Module nach category+name, Gruppen nach name sortiert', async () => {
const prisma = makeFakePrisma();
prisma.__seedActivation({
tenantId: 't1',
moduleId: 'mod-b',
isActive: true,
module: { id: 'mod-b', category: 'zzz', name: 'B-Modul' },
});
prisma.__seedActivation({
tenantId: 't1',
moduleId: 'mod-a',
isActive: true,
module: { id: 'mod-a', category: 'aaa', name: 'A-Modul' },
});
prisma.__seedGroup({ id: 'g2', tenantId: 't1', name: 'Zeta' });
prisma.__seedGroup({ id: 'g1', tenantId: 't1', name: 'Alpha' });
const service = new ModuleGrantsService(prisma as any);
await service.grant('t1', { moduleId: 'mod-a', groupId: 'g1' });
const matrix = await service.getMatrix('t1');
expect(matrix.modules.map((m: any) => m.id)).toEqual(['mod-a', 'mod-b']);
expect(matrix.groups.map((g: any) => g.name)).toEqual(['Alpha', 'Zeta']);
expect(matrix.grants).toEqual([{ moduleId: 'mod-a', groupId: 'g1' }]);
});
it('empty: ein Mandant ohne Gruppen liefert eine leere Gruppenliste und wirft nicht', async () => {
const prisma = makeFakePrisma();
prisma.__seedActivation({
tenantId: 't1',
moduleId: 'mod-1',
isActive: true,
module: { id: 'mod-1', category: 'ops', name: 'Modul Eins' },
});
const service = new ModuleGrantsService(prisma as any);
const matrix = await service.getMatrix('t1');
expect(matrix.groups).toEqual([]);
expect(matrix.modules.map((m: any) => m.id)).toEqual(['mod-1']);
});
it('ordering: die Matrix-Antwort liefert dieselbe Reihenfolge über wiederholte Aufrufe', async () => {
const prisma = makeFakePrisma();
prisma.__seedActivation({
tenantId: 't1',
moduleId: 'mod-b',
isActive: true,
module: { id: 'mod-b', category: 'zzz', name: 'B-Modul' },
});
prisma.__seedActivation({
tenantId: 't1',
moduleId: 'mod-a',
isActive: true,
module: { id: 'mod-a', category: 'aaa', name: 'A-Modul' },
});
const service = new ModuleGrantsService(prisma as any);
const first = await service.getMatrix('t1');
const second = await service.getMatrix('t1');
expect(first.modules.map((m: any) => m.id)).toEqual(second.modules.map((m: any) => m.id));
});
});
describe('ModuleGrantsService.getUserAccess', () => {
it('liefert je aktivem Modul die geerbten Gruppen und den Direkt-Grant-Status', async () => {
const prisma = makeFakePrisma();
seedBase(prisma);
prisma.__seedMembership('g1', 'u1');
const service = new ModuleGrantsService(prisma as any);
await service.grant('t1', { moduleId: 'mod-1', groupId: 'g1' });
const result = await service.getUserAccess('t1', 'u1');
expect(result).toEqual([
{
module: { id: 'mod-1', category: 'ops', name: 'Modul Eins' },
viaGroups: ['Gruppe A'],
direct: false,
},
]);
});
it('adjacency: ein Direkt-Grant UND ein Gruppen-Grant auf dasselbe Modul erscheinen gleichzeitig, keiner verdrängt den anderen', async () => {
const prisma = makeFakePrisma();
seedBase(prisma);
prisma.__seedMembership('g1', 'u1');
const service = new ModuleGrantsService(prisma as any);
await service.grant('t1', { moduleId: 'mod-1', groupId: 'g1' });
await service.grant('t1', { moduleId: 'mod-1', userId: 'u1' });
const result = await service.getUserAccess('t1', 'u1');
expect(result[0].viaGroups).toEqual(['Gruppe A']);
expect(result[0].direct).toBe(true);
});
it('wirft NotFoundException für eine userId aus einem anderen Mandanten', async () => {
const prisma = makeFakePrisma();
seedBase(prisma);
prisma.__seedUser({ id: 'u-foreign', tenantId: 't2' });
const service = new ModuleGrantsService(prisma as any);
await expect(service.getUserAccess('t1', 'u-foreign')).rejects.toBeInstanceOf(
NotFoundException,
);
});
});
describe('ModuleGrantsService — Logging (D-23)', () => {
afterEach(() => {
vi.restoreAllMocks();
});
it('grant schreibt eine Logzeile mit Mandant, Modul, Ziel und Aktion', async () => {
const prisma = makeFakePrisma();
seedBase(prisma);
const logSpy = vi.spyOn(Logger.prototype, 'log').mockImplementation(() => undefined);
const service = new ModuleGrantsService(prisma as any);
await service.grant('t1', { moduleId: 'mod-1', groupId: 'g1' });
expect(logSpy).toHaveBeenCalled();
const message = logSpy.mock.calls[0][0] as string;
expect(message).toContain('t1');
expect(message).toContain('mod-1');
expect(message).toContain('g1');
});
it('revoke schreibt eine Logzeile mit Mandant, Modul, Ziel und Aktion', async () => {
const prisma = makeFakePrisma();
seedBase(prisma);
const logSpy = vi.spyOn(Logger.prototype, 'log').mockImplementation(() => undefined);
const service = new ModuleGrantsService(prisma as any);
await service.grant('t1', { moduleId: 'mod-1', groupId: 'g1' });
logSpy.mockClear();
await service.revoke('t1', { moduleId: 'mod-1', groupId: 'g1' });
expect(logSpy).toHaveBeenCalled();
const message = logSpy.mock.calls[0][0] as string;
expect(message).toContain('t1');
expect(message).toContain('mod-1');
expect(message).toContain('g1');
});
});