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:
@@ -0,0 +1,24 @@
|
|||||||
|
import { IsNotEmpty, IsOptional, IsString } from 'class-validator';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* DTO für Grant-Erstellung und -Entzug (PERM-03).
|
||||||
|
*
|
||||||
|
* Die Entweder-oder-Regel (genau eine von groupId/userId, D-04) wird im
|
||||||
|
* Service geprüft, nicht hier — sie setzt zwei Felder zueinander in
|
||||||
|
* Beziehung, das DTO deckt nur die Feldtypen ab. Dieselbe Form bedient
|
||||||
|
* sowohl POST /module-grants (anlegen) als auch DELETE /module-grants
|
||||||
|
* (entziehen, Ziel im Body statt Pfadparameter).
|
||||||
|
*/
|
||||||
|
export class CreateModuleGrantDto {
|
||||||
|
@IsString()
|
||||||
|
@IsNotEmpty()
|
||||||
|
moduleId!: string;
|
||||||
|
|
||||||
|
@IsString()
|
||||||
|
@IsOptional()
|
||||||
|
groupId?: string;
|
||||||
|
|
||||||
|
@IsString()
|
||||||
|
@IsOptional()
|
||||||
|
userId?: string;
|
||||||
|
}
|
||||||
@@ -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');
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,251 @@
|
|||||||
|
import {
|
||||||
|
BadRequestException,
|
||||||
|
Injectable,
|
||||||
|
Logger,
|
||||||
|
NotFoundException,
|
||||||
|
} from '@nestjs/common';
|
||||||
|
import { PrismaService } from '../prisma/prisma.service';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Schreibseite der Modul-Freigaben (PERM-03): Grants für Gruppen und für
|
||||||
|
* einzelne Benutzer anlegen und entziehen, plus die Datenlieferung für die
|
||||||
|
* Freigabe-Matrix (D-15) und das Benutzer-Detail (D-16).
|
||||||
|
*
|
||||||
|
* Liest/schreibt dieselben ModuleGrant-Zeilen, die
|
||||||
|
* ModuleAccessService.getAccessibleModuleIds (15-01) für die Leseseite
|
||||||
|
* konsumiert — eine Schreib- und eine Leseseite auf einem Datensatz.
|
||||||
|
*
|
||||||
|
* D-23: jede erfolgreiche Mutation schreibt ausschließlich eine Logzeile
|
||||||
|
* ü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.
|
||||||
|
*/
|
||||||
|
@Injectable()
|
||||||
|
export class ModuleGrantsService {
|
||||||
|
private readonly logger = new Logger(ModuleGrantsService.name);
|
||||||
|
|
||||||
|
constructor(private readonly prisma: PrismaService) {}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Prüft, dass die referenzierte Gruppe bzw. der referenzierte Benutzer
|
||||||
|
* zum Mandanten aus dem JWT gehört, und wirft andernfalls
|
||||||
|
* NotFoundException.
|
||||||
|
*
|
||||||
|
* tenantId stammt vertrauenswürdig aus dem Token — groupId/userId kommen
|
||||||
|
* dagegen aus dem Request-Body eines Admin-Clients. Ohne diese
|
||||||
|
* Gegenprüfung könnte ein Admin eines Mandanten einen Grant auf eine
|
||||||
|
* Gruppe oder einen Benutzer eines anderen Mandanten legen und darüber
|
||||||
|
* Zugriff verschaffen (T-15-01). Im Bestandscode gibt es dafür kein
|
||||||
|
* Vorbild — die bisherigen Ownership-Prüfungen (z. B.
|
||||||
|
* DashboardService.removeWidget) betreffen nur direktes Eigentum, nicht
|
||||||
|
* eine zweite Mandantengrenze über eine Relation.
|
||||||
|
*/
|
||||||
|
private async assertTargetBelongsToTenant(
|
||||||
|
tenantId: string,
|
||||||
|
groupId?: string,
|
||||||
|
userId?: string,
|
||||||
|
): Promise<void> {
|
||||||
|
if (groupId) {
|
||||||
|
const group = await this.prisma.group.findFirst({
|
||||||
|
where: { id: groupId, tenantId },
|
||||||
|
});
|
||||||
|
if (!group) {
|
||||||
|
throw new NotFoundException(`Gruppe '${groupId}' nicht gefunden`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (userId) {
|
||||||
|
const user = await this.prisma.user.findFirst({
|
||||||
|
where: { id: userId, tenantId },
|
||||||
|
});
|
||||||
|
if (!user) {
|
||||||
|
throw new NotFoundException(`Benutzer '${userId}' nicht gefunden`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Legt einen Grant für eine Gruppe ODER einen einzelnen Benutzer an (nie
|
||||||
|
* beides, nie keines — D-04). Prüfreihenfolge: Entweder-oder der beiden
|
||||||
|
* Referenzen (BadRequestException mit Klartext, damit das Admin-UI nicht
|
||||||
|
* den rohen Postgres-Constraint-Namen sieht), dann die Mandanten-
|
||||||
|
* Gegenprüfung, dann die aktive TenantModuleActivation des Mandanten für
|
||||||
|
* die moduleId (ein Grant auf ein nicht aktiviertes Modul wäre
|
||||||
|
* wirkungslos, D-02), dann create. Ein P2002 aus dem partiellen
|
||||||
|
* Unique-Index (zwei parallele Klicks auf dieselbe Matrix-Zelle) wird als
|
||||||
|
* Erfolg behandelt und liefert den bestehenden Datensatz zurück statt
|
||||||
|
* eines HTTP 500.
|
||||||
|
*/
|
||||||
|
async grant(
|
||||||
|
tenantId: string,
|
||||||
|
data: { moduleId: string; groupId?: string; userId?: string },
|
||||||
|
) {
|
||||||
|
const { moduleId, groupId, userId } = data;
|
||||||
|
if ((groupId && userId) || (!groupId && !userId)) {
|
||||||
|
throw new BadRequestException(
|
||||||
|
'Ein Grant muss entweder eine groupId oder eine userId tragen, nicht beides und nicht keines',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
await this.assertTargetBelongsToTenant(tenantId, groupId, userId);
|
||||||
|
|
||||||
|
const activation = await this.prisma.tenantModuleActivation.findUnique({
|
||||||
|
where: { tenantId_moduleId: { tenantId, moduleId } },
|
||||||
|
});
|
||||||
|
if (!activation?.isActive) {
|
||||||
|
throw new BadRequestException(
|
||||||
|
`Modul '${moduleId}' ist für diesen Mandanten nicht aktiviert`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const target = groupId ? `group=${groupId}` : `user=${userId}`;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const created = await this.prisma.moduleGrant.create({
|
||||||
|
data: {
|
||||||
|
tenantId,
|
||||||
|
moduleId,
|
||||||
|
groupId: groupId ?? null,
|
||||||
|
userId: userId ?? null,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
this.logger.log(
|
||||||
|
`Grant erteilt: tenant=${tenantId} module=${moduleId} ${target}`,
|
||||||
|
);
|
||||||
|
return created;
|
||||||
|
} catch (err: any) {
|
||||||
|
if (err?.code === 'P2002') {
|
||||||
|
const existing = await this.prisma.moduleGrant.findFirst({
|
||||||
|
where: {
|
||||||
|
tenantId,
|
||||||
|
moduleId,
|
||||||
|
groupId: groupId ?? null,
|
||||||
|
userId: userId ?? null,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
if (existing) {
|
||||||
|
this.logger.log(
|
||||||
|
`Grant bereits vorhanden (Doppelklick abgefangen): tenant=${tenantId} module=${moduleId} ${target}`,
|
||||||
|
);
|
||||||
|
return existing;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
throw err;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Entzieht einen Grant. deleteMany statt delete: folgenlos, wenn nichts
|
||||||
|
* passt, kein vorheriger Lookup nötig. tenantId im where ist gleichzeitig
|
||||||
|
* der IDOR-Schutz (T-15-02) — ein Ziel eines fremden Mandanten trifft
|
||||||
|
* null Zeilen.
|
||||||
|
*/
|
||||||
|
async revoke(
|
||||||
|
tenantId: string,
|
||||||
|
data: { moduleId: string; groupId?: string; userId?: string },
|
||||||
|
) {
|
||||||
|
const { moduleId, groupId, userId } = data;
|
||||||
|
const target = groupId ? `group=${groupId}` : `user=${userId}`;
|
||||||
|
|
||||||
|
await this.prisma.moduleGrant.deleteMany({
|
||||||
|
where: {
|
||||||
|
tenantId,
|
||||||
|
moduleId,
|
||||||
|
...(groupId ? { groupId } : {}),
|
||||||
|
...(userId ? { userId } : {}),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
this.logger.log(
|
||||||
|
`Grant entzogen: tenant=${tenantId} module=${moduleId} ${target}`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Datenlieferung für die Freigabe-Matrix (D-15): die aktiven Module, die
|
||||||
|
* Gruppen und die Gruppen-Grants des Mandanten in einer Antwort. Module
|
||||||
|
* sind nach category und dann name sortiert, Gruppen nach name — die
|
||||||
|
* explizite Sortierung hält Spalten-/Zeilenreihenfolge über Aufrufe
|
||||||
|
* hinweg stabil.
|
||||||
|
*/
|
||||||
|
async getMatrix(tenantId: string) {
|
||||||
|
const [activations, groups, groupGrants] = await Promise.all([
|
||||||
|
this.prisma.tenantModuleActivation.findMany({
|
||||||
|
where: { tenantId, isActive: true },
|
||||||
|
include: { module: true },
|
||||||
|
}),
|
||||||
|
this.prisma.group.findMany({
|
||||||
|
where: { tenantId },
|
||||||
|
orderBy: { name: 'asc' },
|
||||||
|
}),
|
||||||
|
this.prisma.moduleGrant.findMany({
|
||||||
|
where: { tenantId, groupId: { not: null } },
|
||||||
|
select: { moduleId: true, groupId: true },
|
||||||
|
}),
|
||||||
|
]);
|
||||||
|
|
||||||
|
const modules = activations
|
||||||
|
.map((a: any) => a.module)
|
||||||
|
.sort(
|
||||||
|
(a: any, b: any) =>
|
||||||
|
a.category.localeCompare(b.category) || a.name.localeCompare(b.name),
|
||||||
|
);
|
||||||
|
|
||||||
|
return {
|
||||||
|
modules,
|
||||||
|
groups,
|
||||||
|
grants: groupGrants.map((g: any) => ({
|
||||||
|
moduleId: g.moduleId as string,
|
||||||
|
groupId: g.groupId as string,
|
||||||
|
})),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Datenlieferung für das Benutzer-Detail (D-16): je aktivem Modul die
|
||||||
|
* Namen der Gruppen, über die der Benutzer das Modul erbt, und ein
|
||||||
|
* Kennzeichen für einen bestehenden Direkt-Grant. Ohne diese Anzeige ist
|
||||||
|
* im Benutzer-Detail nicht erkennbar, warum jemand Zugriff hat.
|
||||||
|
*/
|
||||||
|
async getUserAccess(tenantId: string, userId: string) {
|
||||||
|
await this.assertTargetBelongsToTenant(tenantId, undefined, userId);
|
||||||
|
|
||||||
|
const [activations, groupGrants, directGrants] = await Promise.all([
|
||||||
|
this.prisma.tenantModuleActivation.findMany({
|
||||||
|
where: { tenantId, isActive: true },
|
||||||
|
include: { module: true },
|
||||||
|
}),
|
||||||
|
this.prisma.moduleGrant.findMany({
|
||||||
|
where: { tenantId, group: { memberships: { some: { userId } } } },
|
||||||
|
include: { group: true },
|
||||||
|
}),
|
||||||
|
this.prisma.moduleGrant.findMany({
|
||||||
|
where: { tenantId, userId },
|
||||||
|
select: { moduleId: true },
|
||||||
|
}),
|
||||||
|
]);
|
||||||
|
|
||||||
|
const directModuleIds = new Set(directGrants.map((g: any) => g.moduleId as string));
|
||||||
|
const groupNamesByModule = new Map<string, string[]>();
|
||||||
|
for (const g of groupGrants as any[]) {
|
||||||
|
if (!g.group) continue;
|
||||||
|
const names = groupNamesByModule.get(g.moduleId) ?? [];
|
||||||
|
names.push(g.group.name);
|
||||||
|
groupNamesByModule.set(g.moduleId, names);
|
||||||
|
}
|
||||||
|
|
||||||
|
const modules = activations
|
||||||
|
.map((a: any) => a.module)
|
||||||
|
.sort(
|
||||||
|
(a: any, b: any) =>
|
||||||
|
a.category.localeCompare(b.category) || a.name.localeCompare(b.name),
|
||||||
|
);
|
||||||
|
|
||||||
|
return modules.map((module: any) => ({
|
||||||
|
module,
|
||||||
|
viaGroups: groupNamesByModule.get(module.id) ?? [],
|
||||||
|
direct: directModuleIds.has(module.id),
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user