feat(jts-03): module-grants.service.ts binden, beide Dokumente schliessen
Alle fuenf Methoden von ModuleGrantsService (assertTargetBelongsToTenant,
grant, revoke, getMatrix, getUserAccess) laufen jetzt ueber forTenant(); bei
den beiden Datenlieferungen teilen sich alle parallel abgesetzten
Teilabfragen denselben gebundenen Client. Die Mandanten-Gegenpruefung vor
jedem Erteilen bleibt ausdruecklich bestehen und bekommt einen Verweis auf
Befund F/T-JTS-03: die Regel auf ModuleGrant prueft nur die
Mandantenkennung der Zeile, nicht die referenzierte Gruppe. Der veraltete
Kommentar ueber der Mitgliedschaftsabfrage im Benutzer-Detail ("kein
forTenant hier") ist durch den neuen Stand ersetzt.
module-grants.service.spec.ts bekommt denselben Bindungsnachweis-Mock wie
groups.service.spec.ts (zwei unterscheidbare Clients ueber demselben
Speicher) und sechs neue Bindungsnachweise; alle 28 Bestandstests bleiben
gruen.
Beide Dokumente geschlossen: die Bereichsuebersicht fuer groups ist neu
gemessen (0 ungebunden, 31 gebunden — ein dokumentierter methodischer
Bodensatz, da die einfache Rohtrefferzaehlung die neun ueber `tx` gebundenen
Zugriffe innerhalb der drei Transaktionen nicht sieht), die
Klassen-Verteilung auf 62 Paare aktualisiert, und der als offen gefuehrte
Befund D aus dem ldap-Abschnitt der Fehlerrichtung ist mit Verweis auf
diesen Durchlauf als erledigt vermerkt (Nachtrag, nicht Neuschrieb). 743
Tests und die Typpruefung gruen; Schema, Migrationen und alle vier
Compose-Dateien unveraendert.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01AMASaSxv5QMY7RncqZriRR
This commit is contained in:
@@ -9,7 +9,18 @@ import { ModuleGrantsService } from './module-grants.service';
|
||||
* groups.service.spec.ts / module-access.service.spec.ts — keine Live-DB,
|
||||
* P2002 wird exakt wie ein echter Postgres-Client über den Fehlercode
|
||||
* simuliert.
|
||||
*
|
||||
* Bindung an forTenant() (260909-jts, Aufgabe 3, Befund C uebertragen von
|
||||
* groups.service.spec.ts): derselbe Mock wie dort — der gebundene Client
|
||||
* ist ein ZWEITES, von `prisma` unterscheidbares Objekt ueber DEMSELBEN
|
||||
* Speicher, das protokolliert, welche Aufrufe ueber ihn liefen. Ein reiner
|
||||
* Identitaets-Mock (`forTenant: vi.fn((p) => p)`) koennte einen
|
||||
* vergessenen Bindungsaufruf nicht von einem ungebundenen Aufruf
|
||||
* unterscheiden.
|
||||
*/
|
||||
vi.mock('../prisma/prisma-tenant.extension', () => ({
|
||||
forTenant: vi.fn((prisma: any, tenantId: string) => prisma.__makeBoundClient(tenantId)),
|
||||
}));
|
||||
|
||||
function makeFakePrisma() {
|
||||
const groups = new Map<string, any>();
|
||||
@@ -17,6 +28,7 @@ function makeFakePrisma() {
|
||||
const memberships = new Map<string, Set<string>>(); // groupId -> Set<userId>
|
||||
const membershipSources = new Map<string, string>(); // `${groupId}::${userId}` -> source
|
||||
const activations = new Map<string, any>(); // key: tenantId::moduleId
|
||||
const boundCallLog: { tenantId: string; model: string; method: string }[] = [];
|
||||
const grants = new Map<string, any>();
|
||||
let grantCounter = 0;
|
||||
|
||||
@@ -41,7 +53,7 @@ function makeFakePrisma() {
|
||||
);
|
||||
}
|
||||
|
||||
return {
|
||||
const fake: any = {
|
||||
__seedGroup(group: { id: string; tenantId: string; name: string; internalName?: string | null }) {
|
||||
groups.set(group.id, { internalName: null, ...group });
|
||||
},
|
||||
@@ -166,7 +178,46 @@ function makeFakePrisma() {
|
||||
return rows;
|
||||
},
|
||||
},
|
||||
// --- Bindungsnachweis (260909-jts, Befund C uebertragen) ---------------
|
||||
__boundCallLog: boundCallLog,
|
||||
__makeBoundClient(tenantId: string) {
|
||||
const bound: any = { __isBoundClient: true, __tenantId: tenantId };
|
||||
for (const modelName of BOUND_MODEL_NAMES) {
|
||||
const model = fake[modelName];
|
||||
const wrapped: any = {};
|
||||
for (const method of Object.keys(model)) {
|
||||
wrapped[method] = async (...args: any[]) => {
|
||||
boundCallLog.push({ tenantId, model: modelName, method });
|
||||
return model[method](...args);
|
||||
};
|
||||
}
|
||||
bound[modelName] = wrapped;
|
||||
}
|
||||
return bound;
|
||||
},
|
||||
};
|
||||
|
||||
return fake;
|
||||
}
|
||||
|
||||
/** Modelle, die `__makeBoundClient()` je Aufruf mit einem eigenen, das
|
||||
* Herkunfts-Tenant protokollierenden Wrapper versieht. */
|
||||
const BOUND_MODEL_NAMES = ['group', 'user', 'tenantModuleActivation', 'moduleGrant', 'groupMembership'];
|
||||
|
||||
/**
|
||||
* Bindungsnachweis: mindestens ein Aufruf von `<tenantId>.<model>.<method>`
|
||||
* lief ueber den gebundenen Client (nicht ueber den rohen, ungebundenen
|
||||
* Fake). Ein vergessener `forTenant()`-Aufruf hinterlaesst hier KEINEN
|
||||
* Eintrag und laesst den Test fehlschlagen.
|
||||
*/
|
||||
function expectBoundCall(prisma: any, tenantId: string, model: string, method: string) {
|
||||
const found = prisma.__boundCallLog.some(
|
||||
(c: any) => c.tenantId === tenantId && c.model === model && c.method === method,
|
||||
);
|
||||
expect(
|
||||
found,
|
||||
`erwarteter gebundener Aufruf ${model}.${method}(tenant=${tenantId}) fehlt im Protokoll: ${JSON.stringify(prisma.__boundCallLog)}`,
|
||||
).toBe(true);
|
||||
}
|
||||
|
||||
function seedBase(prisma: ReturnType<typeof makeFakePrisma>) {
|
||||
@@ -595,3 +646,82 @@ describe('ModuleGrantsService — Logging (D-23)', () => {
|
||||
expect(message).toContain('g1');
|
||||
});
|
||||
});
|
||||
|
||||
// --- Bindung an forTenant() (260909-jts, Aufgabe 3) -------------------------
|
||||
|
||||
describe('ModuleGrantsService — Bindung an forTenant() (260909-jts)', () => {
|
||||
it('grant() bindet die Mandanten-Gegenpruefung, die Aktivierungspruefung und moduleGrant.create an den uebergebenen Mandanten', async () => {
|
||||
const prisma = makeFakePrisma();
|
||||
seedBase(prisma);
|
||||
const service = new ModuleGrantsService(prisma as any);
|
||||
|
||||
await service.grant('t1', { moduleId: 'mod-1', groupId: 'g1' });
|
||||
|
||||
expectBoundCall(prisma, 't1', 'group', 'findFirst');
|
||||
expectBoundCall(prisma, 't1', 'tenantModuleActivation', 'findUnique');
|
||||
expectBoundCall(prisma, 't1', 'moduleGrant', 'create');
|
||||
});
|
||||
|
||||
it('grant() bindet auch die Mandanten-Gegenpruefung fuer eine userId und bleibt wirksam gegen einen fremden Benutzer (T-15-01)', 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);
|
||||
|
||||
expectBoundCall(prisma, 't1', 'user', 'findFirst');
|
||||
});
|
||||
|
||||
it('revoke() bindet moduleGrant.deleteMany an den uebergebenen Mandanten', 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' });
|
||||
|
||||
expectBoundCall(prisma, 't1', 'moduleGrant', 'deleteMany');
|
||||
});
|
||||
|
||||
it('getMatrix() bindet alle drei parallelen Teilabfragen (tenantModuleActivation, group, moduleGrant) an DENSELBEN gebundenen Mandanten', async () => {
|
||||
const prisma = makeFakePrisma();
|
||||
seedBase(prisma);
|
||||
const service = new ModuleGrantsService(prisma as any);
|
||||
|
||||
await service.getMatrix('t1');
|
||||
|
||||
expectBoundCall(prisma, 't1', 'tenantModuleActivation', 'findMany');
|
||||
expectBoundCall(prisma, 't1', 'group', 'findMany');
|
||||
expectBoundCall(prisma, 't1', 'moduleGrant', 'findMany');
|
||||
});
|
||||
|
||||
it('getUserAccess() bindet alle vier parallelen Teilabfragen (tenantModuleActivation, moduleGrant x2, groupMembership) an DENSELBEN gebundenen Mandanten', 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.getUserAccess('t1', 'u1');
|
||||
|
||||
expectBoundCall(prisma, 't1', 'tenantModuleActivation', 'findMany');
|
||||
expectBoundCall(prisma, 't1', 'moduleGrant', 'findMany');
|
||||
expectBoundCall(prisma, 't1', 'groupMembership', 'findMany');
|
||||
});
|
||||
|
||||
it('getUserAccess() bindet weiterhin die Mandanten-Gegenpruefung — sie wird durch die Bindung NICHT ersetzt', 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,
|
||||
);
|
||||
|
||||
expectBoundCall(prisma, 't1', 'user', 'findFirst');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -5,6 +5,7 @@ import {
|
||||
NotFoundException,
|
||||
} from '@nestjs/common';
|
||||
import { PrismaService } from '../prisma/prisma.service';
|
||||
import { forTenant } from '../prisma/prisma-tenant.extension';
|
||||
|
||||
/**
|
||||
* Schreibseite der Modul-Freigaben (PERM-03): Grants für Gruppen und für
|
||||
@@ -47,8 +48,9 @@ export class ModuleGrantsService {
|
||||
groupId?: string,
|
||||
userId?: string,
|
||||
): Promise<void> {
|
||||
const tenantPrisma = forTenant(this.prisma, tenantId) as any;
|
||||
if (groupId) {
|
||||
const group = await this.prisma.group.findFirst({
|
||||
const group = await tenantPrisma.group.findFirst({
|
||||
where: { id: groupId, tenantId },
|
||||
});
|
||||
if (!group) {
|
||||
@@ -56,7 +58,7 @@ export class ModuleGrantsService {
|
||||
}
|
||||
}
|
||||
if (userId) {
|
||||
const user = await this.prisma.user.findFirst({
|
||||
const user = await tenantPrisma.user.findFirst({
|
||||
where: { id: userId, tenantId },
|
||||
});
|
||||
if (!user) {
|
||||
@@ -88,9 +90,20 @@ export class ModuleGrantsService {
|
||||
);
|
||||
}
|
||||
|
||||
// Die Mandanten-Gegenpruefung bleibt ausdruecklich erhalten (T-JTS-03,
|
||||
// 260909-jts, Aufgabe 1): die ausgelieferte Regel auf ModuleGrant
|
||||
// prueft ausschliesslich die Mandantenkennung der Zeile selbst
|
||||
// ("tenantId" = current_tenant_id()), NICHT die referenzierte Gruppe.
|
||||
// Eine Zeile mit korrekter eigener Mandantenkennung, die auf die
|
||||
// Gruppe eines fremden Mandanten zeigt, verletzt diese Regel
|
||||
// nachweislich nicht (gemessen gegen die echte Migration in Aufgabe 1).
|
||||
// Diese Anwendungspruefung ist damit der einzige Schutz gegen diese
|
||||
// Form der Rechteausweitung und darf nicht als "macht jetzt die
|
||||
// Datenbank" entfallen.
|
||||
await this.assertTargetBelongsToTenant(tenantId, groupId, userId);
|
||||
|
||||
const activation = await this.prisma.tenantModuleActivation.findUnique({
|
||||
const tenantPrisma = forTenant(this.prisma, tenantId) as any;
|
||||
const activation = await tenantPrisma.tenantModuleActivation.findUnique({
|
||||
where: { tenantId_moduleId: { tenantId, moduleId } },
|
||||
});
|
||||
if (!activation?.isActive) {
|
||||
@@ -102,7 +115,7 @@ export class ModuleGrantsService {
|
||||
const target = groupId ? `group=${groupId}` : `user=${userId}`;
|
||||
|
||||
try {
|
||||
const created = await this.prisma.moduleGrant.create({
|
||||
const created = await tenantPrisma.moduleGrant.create({
|
||||
data: {
|
||||
tenantId,
|
||||
moduleId,
|
||||
@@ -116,7 +129,7 @@ export class ModuleGrantsService {
|
||||
return created;
|
||||
} catch (err: any) {
|
||||
if (err?.code === 'P2002') {
|
||||
const existing = await this.prisma.moduleGrant.findFirst({
|
||||
const existing = await tenantPrisma.moduleGrant.findFirst({
|
||||
where: {
|
||||
tenantId,
|
||||
moduleId,
|
||||
@@ -148,7 +161,8 @@ export class ModuleGrantsService {
|
||||
const { moduleId, groupId, userId } = data;
|
||||
const target = groupId ? `group=${groupId}` : `user=${userId}`;
|
||||
|
||||
await this.prisma.moduleGrant.deleteMany({
|
||||
const tenantPrisma = forTenant(this.prisma, tenantId) as any;
|
||||
await tenantPrisma.moduleGrant.deleteMany({
|
||||
where: {
|
||||
tenantId,
|
||||
moduleId,
|
||||
@@ -170,16 +184,17 @@ export class ModuleGrantsService {
|
||||
* hinweg stabil.
|
||||
*/
|
||||
async getMatrix(tenantId: string) {
|
||||
const tenantPrisma = forTenant(this.prisma, tenantId) as any;
|
||||
const [activations, groups, groupGrants] = await Promise.all([
|
||||
this.prisma.tenantModuleActivation.findMany({
|
||||
tenantPrisma.tenantModuleActivation.findMany({
|
||||
where: { tenantId, isActive: true },
|
||||
include: { module: true },
|
||||
}),
|
||||
this.prisma.group.findMany({
|
||||
tenantPrisma.group.findMany({
|
||||
where: { tenantId },
|
||||
orderBy: { name: 'asc' },
|
||||
}),
|
||||
this.prisma.moduleGrant.findMany({
|
||||
tenantPrisma.moduleGrant.findMany({
|
||||
where: { tenantId, groupId: { not: null } },
|
||||
select: { moduleId: true, groupId: true },
|
||||
}),
|
||||
@@ -226,26 +241,30 @@ export class ModuleGrantsService {
|
||||
async getUserAccess(tenantId: string, userId: string) {
|
||||
await this.assertTargetBelongsToTenant(tenantId, undefined, userId);
|
||||
|
||||
const tenantPrisma = forTenant(this.prisma, tenantId) as any;
|
||||
const [activations, groupGrants, directGrants, memberships] = await Promise.all([
|
||||
this.prisma.tenantModuleActivation.findMany({
|
||||
tenantPrisma.tenantModuleActivation.findMany({
|
||||
where: { tenantId, isActive: true },
|
||||
include: { module: true },
|
||||
}),
|
||||
this.prisma.moduleGrant.findMany({
|
||||
tenantPrisma.moduleGrant.findMany({
|
||||
where: { tenantId, group: { memberships: { some: { userId } } } },
|
||||
include: { group: true },
|
||||
}),
|
||||
this.prisma.moduleGrant.findMany({
|
||||
tenantPrisma.moduleGrant.findMany({
|
||||
where: { tenantId, userId },
|
||||
select: { moduleId: true },
|
||||
}),
|
||||
// Kein forTenant hier — dieselbe Begründung wie bei den drei
|
||||
// Abfragen oben: die Datenbankrolle umgeht RLS ohnehin (siehe
|
||||
// Migration 20260804130918_groups_rls_policies), der `where`-Filter
|
||||
// ist wie im Rest dieser Methode und in GroupsService der primäre
|
||||
// Schutz. GroupMembership trägt keine eigene tenantId-Spalte, daher
|
||||
// läuft der Mandantenfilter über die Relation `group: { tenantId }`.
|
||||
this.prisma.groupMembership.findMany({
|
||||
// Mandantengebunden seit 260909-jts (Aufgabe 3): der Kontext wird
|
||||
// über denselben tenantPrisma wie die drei Abfragen oben gesetzt —
|
||||
// es entsteht kein zweiter gebundener Client. Der `where`-Filter
|
||||
// über die Beziehung zur Gruppe (`group: { tenantId }`) bleibt
|
||||
// ZUSÄTZLICH stehen: GroupMembership trägt keine eigene tenantId-
|
||||
// Spalte, und die ausgelieferte Regel auf dieser Tabelle bezieht
|
||||
// ihre Sichtbarkeit ausschließlich über die Gruppenseite (gemessen
|
||||
// in Aufgabe 1) — der Anwendungsfilter ist deshalb nicht redundant,
|
||||
// sondern das zweite Netz.
|
||||
tenantPrisma.groupMembership.findMany({
|
||||
where: { userId, group: { tenantId } },
|
||||
include: { group: { select: { id: true, name: true, internalName: true } } },
|
||||
}),
|
||||
|
||||
Reference in New Issue
Block a user