feat(260911-e2s): Benutzerzaehler im TenantController binden (Fan-out je Mandant)
findAll/findOne/remove zaehlen Benutzer je Mandant jetzt ueber drei
gebundene Aufrufstellen (tenantPrisma.user.count mit where: { tenantId
}, in remove zusaetzlich isActive: true) statt ueber den
Relationszaehler, der nach dem Scharfschalten unbemerkt unter der
Regel von User gelaufen waere (260911-e2s, Aufgabe 1, Pruefungen 5-7).
Fan-out-Muster aus UserService.findAllForPlatformAdmin uebernommen; die
vier tenant-Zugriffe bleiben ungebunden (Tenant ohne Regel). Antwortform,
Meldungen und Statuscodes unveraendert.
tenant.controller.spec.ts legt die Testlage aus dem Nichts an (20
Faelle): Zwei-Klienten-Nachweis ueber __makeBoundClient, Rollen-
Metadaten-Test (Klasse SUPER_ADMIN, kein Handler ueberschreibt), Wachhund
gegen mehrfache Klientenerzeugung. Falsifizierungsnachweis durchgefuehrt:
der probeweise ungebundene Zaehler in findOne macht 2 Faelle rot mit
"Cannot read properties of undefined (reading 'count')" — die dkv-Form
der Falsifizierung, nicht nur eine falsche Zahl —, danach zurueckgenommen.
Klassifikation und Entwicklungsanleitung nachgezogen: 64 Paare (ein
neues, tenant.controller.ts/user), Uebersichtszeile 8/3, Klassen-
Verteilung 32 muss-mandantengebunden, Erkennungsluecke fuer
Relationseinbindungen im Kopf der Bestandsaufnahme benannt, "Zwei
belegte Befunde" und "Was diese Etappe NICHT entscheidet" (erster
Punkt aufgeloest). Beide Dokument-Falsifizierungsnachweise durchgefuehrt
(falsche Klasse macht rls-access-inventory.spec.ts rot, falsche
Uebersichtszahl macht das herleitende Gate rot), zurueckgenommen.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01AMASaSxv5QMY7RncqZriRR
This commit is contained in:
@@ -0,0 +1,346 @@
|
|||||||
|
import 'reflect-metadata';
|
||||||
|
import { BadRequestException, NotFoundException } from '@nestjs/common';
|
||||||
|
import { Role } from '@prisma/client';
|
||||||
|
import { describe, expect, it, vi } from 'vitest';
|
||||||
|
import { ROLES_KEY } from '../auth/decorators/roles.decorator';
|
||||||
|
import { TenantController } from './tenant.controller';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* TenantController.spec — legt die Testlage fuer diesen Bereich aus dem
|
||||||
|
* Nichts an (260911-e2s, Aufgabe 3, Befund I: vorher gab es nur
|
||||||
|
* `tenant.service.spec.ts` mit zwei Faellen zu `create`).
|
||||||
|
*
|
||||||
|
* Zwei-Klienten-Nachweis (Muster aus `../dkv/dkv.service.spec.ts`):
|
||||||
|
* `__makeBoundClient(tenantId)` bietet ein `user.count`-Modell, das
|
||||||
|
* zusaetzlich nach der Mandantenkennung filtert und jeden Aufruf in ein
|
||||||
|
* Bindungsprotokoll schreibt. Der UNGEBUNDENE Nachbau (`prisma.tenant.*`)
|
||||||
|
* hat absichtlich KEIN `user`-Modell — ein versehentlich ungebundener
|
||||||
|
* Zaehler scheitert dadurch mit "Cannot read properties of undefined"
|
||||||
|
* (die `dkv`-Form der Falsifizierung), nicht mit einer nur falschen Zahl.
|
||||||
|
*/
|
||||||
|
|
||||||
|
vi.mock('../prisma/prisma-tenant.extension', () => ({
|
||||||
|
forTenant: vi.fn((prisma: any, tenantId: string) => prisma.__makeBoundClient(tenantId)),
|
||||||
|
}));
|
||||||
|
|
||||||
|
interface FakeTenantRow {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
slug: string;
|
||||||
|
isActive: boolean;
|
||||||
|
createdAt: Date;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface FakeUserRow {
|
||||||
|
id: string;
|
||||||
|
tenantId: string;
|
||||||
|
isActive: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
function makeFakePrisma(tenantRows: FakeTenantRow[], userRows: FakeUserRow[]) {
|
||||||
|
const tenants = new Map(tenantRows.map((t) => [t.id, { ...t }]));
|
||||||
|
const users = [...userRows];
|
||||||
|
const boundCallLog: { tenantId: string; model: string; method: string; where: any }[] = [];
|
||||||
|
|
||||||
|
const tenantModel = {
|
||||||
|
findMany: vi.fn(async ({ orderBy }: { orderBy?: { name?: 'asc' | 'desc' } } = {}) => {
|
||||||
|
const rows = [...tenants.values()];
|
||||||
|
if (orderBy?.name === 'asc') rows.sort((a, b) => a.name.localeCompare(b.name));
|
||||||
|
return rows;
|
||||||
|
}),
|
||||||
|
findUnique: vi.fn(async ({ where }: { where: { id: string } }) => tenants.get(where.id) ?? null),
|
||||||
|
delete: vi.fn(async ({ where }: { where: { id: string } }) => {
|
||||||
|
const row = tenants.get(where.id) ?? null;
|
||||||
|
tenants.delete(where.id);
|
||||||
|
return row;
|
||||||
|
}),
|
||||||
|
};
|
||||||
|
|
||||||
|
const fake: any = {
|
||||||
|
tenant: tenantModel,
|
||||||
|
__boundCallLog: boundCallLog,
|
||||||
|
__makeBoundClient(tenantId: string) {
|
||||||
|
return {
|
||||||
|
user: {
|
||||||
|
count: async ({
|
||||||
|
where,
|
||||||
|
}: {
|
||||||
|
where: { tenantId: string; isActive?: boolean };
|
||||||
|
}) => {
|
||||||
|
boundCallLog.push({ tenantId, model: 'user', method: 'count', where });
|
||||||
|
return users.filter(
|
||||||
|
(u) =>
|
||||||
|
u.tenantId === where.tenantId &&
|
||||||
|
(where.isActive === undefined || u.isActive === where.isActive),
|
||||||
|
).length;
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
return fake;
|
||||||
|
}
|
||||||
|
|
||||||
|
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 makeFakeTenantService() {
|
||||||
|
return {
|
||||||
|
create: vi.fn(),
|
||||||
|
findById: vi.fn(),
|
||||||
|
update: vi.fn(),
|
||||||
|
} as any;
|
||||||
|
}
|
||||||
|
|
||||||
|
const TENANT_A: FakeTenantRow = {
|
||||||
|
id: 'TENANT-A',
|
||||||
|
name: 'A GmbH',
|
||||||
|
slug: 'tenant-a',
|
||||||
|
isActive: true,
|
||||||
|
createdAt: new Date('2026-01-01'),
|
||||||
|
};
|
||||||
|
const TENANT_B: FakeTenantRow = {
|
||||||
|
id: 'TENANT-B',
|
||||||
|
name: 'B GmbH',
|
||||||
|
slug: 'tenant-b',
|
||||||
|
isActive: true,
|
||||||
|
createdAt: new Date('2026-01-02'),
|
||||||
|
};
|
||||||
|
const TENANT_C: FakeTenantRow = {
|
||||||
|
id: 'TENANT-C',
|
||||||
|
name: 'C GmbH',
|
||||||
|
slug: 'tenant-c',
|
||||||
|
isActive: true,
|
||||||
|
createdAt: new Date('2026-01-03'),
|
||||||
|
};
|
||||||
|
|
||||||
|
describe('TenantController.findAll', () => {
|
||||||
|
it('drei Mandanten (A: zwei Benutzer, B: ein Benutzer, C: keiner): liefert drei Eintraege mit userCount 2/1/0, genau drei gebundene Zaehlaufrufe je Mandantenkennung', async () => {
|
||||||
|
const prisma = makeFakePrisma(
|
||||||
|
[TENANT_A, TENANT_B, TENANT_C],
|
||||||
|
[
|
||||||
|
{ id: 'u-a1', tenantId: 'TENANT-A', isActive: true },
|
||||||
|
{ id: 'u-a2', tenantId: 'TENANT-A', isActive: true },
|
||||||
|
{ id: 'u-b1', tenantId: 'TENANT-B', isActive: true },
|
||||||
|
],
|
||||||
|
);
|
||||||
|
const controller = new TenantController(makeFakeTenantService(), prisma as any);
|
||||||
|
|
||||||
|
const result = await controller.findAll();
|
||||||
|
|
||||||
|
expect(result).toEqual([
|
||||||
|
expect.objectContaining({ id: 'TENANT-A', userCount: 2 }),
|
||||||
|
expect.objectContaining({ id: 'TENANT-B', userCount: 1 }),
|
||||||
|
expect.objectContaining({ id: 'TENANT-C', userCount: 0 }),
|
||||||
|
]);
|
||||||
|
expect(prisma.tenant.findMany).toHaveBeenCalledTimes(1);
|
||||||
|
expect(prisma.__boundCallLog).toHaveLength(3);
|
||||||
|
expectBoundCall(prisma, 'TENANT-A', 'user', 'count');
|
||||||
|
expectBoundCall(prisma, 'TENANT-B', 'user', 'count');
|
||||||
|
expectBoundCall(prisma, 'TENANT-C', 'user', 'count');
|
||||||
|
for (const call of prisma.__boundCallLog) {
|
||||||
|
expect(call.where.tenantId).toBe(call.tenantId);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it('ohne Mandanten: leere Liste, KEIN gebundener Klient erzeugt', async () => {
|
||||||
|
const prisma = makeFakePrisma([], []);
|
||||||
|
const controller = new TenantController(makeFakeTenantService(), prisma as any);
|
||||||
|
|
||||||
|
const result = await controller.findAll();
|
||||||
|
|
||||||
|
expect(result).toEqual([]);
|
||||||
|
expect(prisma.__boundCallLog).toHaveLength(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('die Antwort traegt genau die Felder id, name, slug, isActive, createdAt, userCount', async () => {
|
||||||
|
const prisma = makeFakePrisma(
|
||||||
|
[TENANT_A],
|
||||||
|
[{ id: 'u-a1', tenantId: 'TENANT-A', isActive: true }],
|
||||||
|
);
|
||||||
|
const controller = new TenantController(makeFakeTenantService(), prisma as any);
|
||||||
|
|
||||||
|
const [entry] = await controller.findAll();
|
||||||
|
|
||||||
|
expect(Object.keys(entry).sort()).toEqual(
|
||||||
|
['createdAt', 'id', 'isActive', 'name', 'slug', 'userCount'].sort(),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('TenantController.findOne', () => {
|
||||||
|
it('unbekannte Kennung: NotFoundException, KEIN gebundener Klient', async () => {
|
||||||
|
const prisma = makeFakePrisma([TENANT_A], []);
|
||||||
|
const controller = new TenantController(makeFakeTenantService(), prisma as any);
|
||||||
|
|
||||||
|
await expect(controller.findOne('unknown')).rejects.toThrow(
|
||||||
|
new NotFoundException('Tenant not found'),
|
||||||
|
);
|
||||||
|
expect(prisma.__boundCallLog).toHaveLength(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('bekannte Kennung: userCount aus dem gebundenen Klienten UNTER DIESER Kennung', async () => {
|
||||||
|
const prisma = makeFakePrisma(
|
||||||
|
[TENANT_A, TENANT_B],
|
||||||
|
[
|
||||||
|
{ id: 'u-a1', tenantId: 'TENANT-A', isActive: true },
|
||||||
|
{ id: 'u-b1', tenantId: 'TENANT-B', isActive: true },
|
||||||
|
{ id: 'u-b2', tenantId: 'TENANT-B', isActive: true },
|
||||||
|
],
|
||||||
|
);
|
||||||
|
const controller = new TenantController(makeFakeTenantService(), prisma as any);
|
||||||
|
|
||||||
|
const result = await controller.findOne('TENANT-B');
|
||||||
|
|
||||||
|
expect(result).toEqual(expect.objectContaining({ id: 'TENANT-B', userCount: 2 }));
|
||||||
|
expectBoundCall(prisma, 'TENANT-B', 'user', 'count');
|
||||||
|
expect(prisma.__boundCallLog).toHaveLength(1);
|
||||||
|
expect(prisma.__boundCallLog[0].where.tenantId).toBe('TENANT-B');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('TenantController.remove', () => {
|
||||||
|
it('unbekannte Kennung: NotFoundException, kein gebundener Klient, tenant.delete nicht aufgerufen', async () => {
|
||||||
|
const prisma = makeFakePrisma([TENANT_A], []);
|
||||||
|
const controller = new TenantController(makeFakeTenantService(), prisma as any);
|
||||||
|
|
||||||
|
await expect(controller.remove('unknown')).rejects.toThrow(
|
||||||
|
new NotFoundException('Tenant not found'),
|
||||||
|
);
|
||||||
|
expect(prisma.__boundCallLog).toHaveLength(0);
|
||||||
|
expect(prisma.tenant.delete).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('mit aktiven Benutzern: BadRequestException mit der heutigen Meldung, tenant.delete NICHT aufgerufen, der gebundene Zaehlaufruf traegt isActive=true', async () => {
|
||||||
|
const prisma = makeFakePrisma(
|
||||||
|
[TENANT_A],
|
||||||
|
[{ id: 'u-a1', tenantId: 'TENANT-A', isActive: true }],
|
||||||
|
);
|
||||||
|
const controller = new TenantController(makeFakeTenantService(), prisma as any);
|
||||||
|
|
||||||
|
await expect(controller.remove('TENANT-A')).rejects.toThrow(
|
||||||
|
new BadRequestException(
|
||||||
|
'Cannot delete tenant with active users. Deactivate or reassign users first.',
|
||||||
|
),
|
||||||
|
);
|
||||||
|
expect(prisma.tenant.delete).not.toHaveBeenCalled();
|
||||||
|
expectBoundCall(prisma, 'TENANT-A', 'user', 'count');
|
||||||
|
expect(prisma.__boundCallLog[0].where).toEqual({ tenantId: 'TENANT-A', isActive: true });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('mit ausschliesslich inaktiven Benutzern: der Riegel laesst durch, tenant.delete wird ungebunden mit { where: { id } } aufgerufen, Antwort { message: "Tenant deleted" } (heutiges Verhalten — der Fremdschluessel, der das in der echten Datenbank abfaengt, existiert im Nachbau nicht)', async () => {
|
||||||
|
const prisma = makeFakePrisma(
|
||||||
|
[TENANT_A],
|
||||||
|
[{ id: 'u-a1', tenantId: 'TENANT-A', isActive: false }],
|
||||||
|
);
|
||||||
|
const controller = new TenantController(makeFakeTenantService(), prisma as any);
|
||||||
|
|
||||||
|
const result = await controller.remove('TENANT-A');
|
||||||
|
|
||||||
|
expect(result).toEqual({ message: 'Tenant deleted' });
|
||||||
|
expect(prisma.tenant.delete).toHaveBeenCalledWith({ where: { id: 'TENANT-A' } });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('ohne Benutzer: geloescht, Antwort wie oben', async () => {
|
||||||
|
const prisma = makeFakePrisma([TENANT_C], []);
|
||||||
|
const controller = new TenantController(makeFakeTenantService(), prisma as any);
|
||||||
|
|
||||||
|
const result = await controller.remove('TENANT-C');
|
||||||
|
|
||||||
|
expect(result).toEqual({ message: 'Tenant deleted' });
|
||||||
|
expect(prisma.tenant.delete).toHaveBeenCalledWith({ where: { id: 'TENANT-C' } });
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('TenantController.create / update — delegieren an die Dienst-Attrappe', () => {
|
||||||
|
it('create: kein gebundener Klient, kein Aufruf des ungebundenen Nachbaus, delegiert an den Dienst', async () => {
|
||||||
|
const prisma = makeFakePrisma([], []);
|
||||||
|
const tenantService = makeFakeTenantService();
|
||||||
|
tenantService.create.mockResolvedValue(TENANT_A);
|
||||||
|
const controller = new TenantController(tenantService, prisma as any);
|
||||||
|
|
||||||
|
const result = await controller.create({ name: 'A GmbH', slug: 'tenant-a' } as any);
|
||||||
|
|
||||||
|
expect(result).toBe(TENANT_A);
|
||||||
|
expect(tenantService.create).toHaveBeenCalledWith({ name: 'A GmbH', slug: 'tenant-a' });
|
||||||
|
expect(prisma.tenant.findMany).not.toHaveBeenCalled();
|
||||||
|
expect(prisma.tenant.findUnique).not.toHaveBeenCalled();
|
||||||
|
expect(prisma.__boundCallLog).toHaveLength(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('update: kein gebundener Klient, kein Aufruf des ungebundenen Nachbaus (ausser der Existenzpruefung ueber den Dienst), delegiert an den Dienst', async () => {
|
||||||
|
const prisma = makeFakePrisma([], []);
|
||||||
|
const tenantService = makeFakeTenantService();
|
||||||
|
tenantService.findById.mockResolvedValue(TENANT_A);
|
||||||
|
tenantService.update.mockResolvedValue({ ...TENANT_A, name: 'Neuer Name' });
|
||||||
|
const controller = new TenantController(tenantService, prisma as any);
|
||||||
|
|
||||||
|
const result = await controller.update('TENANT-A', { name: 'Neuer Name' });
|
||||||
|
|
||||||
|
expect(result).toEqual(expect.objectContaining({ name: 'Neuer Name' }));
|
||||||
|
expect(tenantService.update).toHaveBeenCalledWith('TENANT-A', {
|
||||||
|
name: 'Neuer Name',
|
||||||
|
isActive: undefined,
|
||||||
|
});
|
||||||
|
expect(prisma.tenant.findMany).not.toHaveBeenCalled();
|
||||||
|
expect(prisma.__boundCallLog).toHaveLength(0);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('TenantController — Rollen-Metadaten (Befund E, T-E2S-01)', () => {
|
||||||
|
it('klassenweit ist genau [Role.SUPER_ADMIN] gesetzt', () => {
|
||||||
|
const roles = Reflect.getMetadata(ROLES_KEY, TenantController);
|
||||||
|
expect(roles).toEqual([Role.SUPER_ADMIN]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it.each(['findAll', 'findOne', 'create', 'update', 'remove'] as const)(
|
||||||
|
'Handler %s traegt KEINE eigene Rollenmetadaten — eine schwaechere Handler-Rolle wuerde die Klassenrolle via getAllAndOverride ueberschreiben',
|
||||||
|
(handlerName) => {
|
||||||
|
const handlerRoles = Reflect.getMetadata(
|
||||||
|
ROLES_KEY,
|
||||||
|
(TenantController.prototype as any)[handlerName],
|
||||||
|
);
|
||||||
|
expect(handlerRoles).toBeUndefined();
|
||||||
|
},
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('TenantController — Wachhund: hoechstens ein gebundener Klient je Aufruf und Mandant', () => {
|
||||||
|
it('findOne erzeugt genau EINEN gebundenen Klienten je Aufruf', async () => {
|
||||||
|
const prisma = makeFakePrisma(
|
||||||
|
[TENANT_A],
|
||||||
|
[{ id: 'u-a1', tenantId: 'TENANT-A', isActive: true }],
|
||||||
|
);
|
||||||
|
const controller = new TenantController(makeFakeTenantService(), prisma as any);
|
||||||
|
|
||||||
|
await controller.findOne('TENANT-A');
|
||||||
|
|
||||||
|
expect(prisma.__boundCallLog).toHaveLength(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('remove erzeugt genau EINEN gebundenen Klienten je Aufruf', async () => {
|
||||||
|
const prisma = makeFakePrisma([TENANT_A], []);
|
||||||
|
const controller = new TenantController(makeFakeTenantService(), prisma as any);
|
||||||
|
|
||||||
|
await controller.remove('TENANT-A');
|
||||||
|
|
||||||
|
expect(prisma.__boundCallLog).toHaveLength(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('findAll erzeugt genau so viele gebundene Klienten wie Mandanten vorhanden sind', async () => {
|
||||||
|
const prisma = makeFakePrisma([TENANT_A, TENANT_B, TENANT_C], []);
|
||||||
|
const controller = new TenantController(makeFakeTenantService(), prisma as any);
|
||||||
|
|
||||||
|
await controller.findAll();
|
||||||
|
|
||||||
|
expect(prisma.__boundCallLog).toHaveLength(3);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -13,6 +13,7 @@ import {
|
|||||||
import { Role } from '@prisma/client';
|
import { Role } from '@prisma/client';
|
||||||
import { Roles } from '../auth/decorators/roles.decorator';
|
import { Roles } from '../auth/decorators/roles.decorator';
|
||||||
import { RolesGuard } from '../auth/guards/roles.guard';
|
import { RolesGuard } from '../auth/guards/roles.guard';
|
||||||
|
import { forTenant } from '../prisma/prisma-tenant.extension';
|
||||||
import { PrismaService } from '../prisma/prisma.service';
|
import { PrismaService } from '../prisma/prisma.service';
|
||||||
import { CreateTenantDto } from './dto/create-tenant.dto';
|
import { CreateTenantDto } from './dto/create-tenant.dto';
|
||||||
import { TenantService } from './tenant.service';
|
import { TenantService } from './tenant.service';
|
||||||
@@ -21,6 +22,30 @@ import { TenantService } from './tenant.service';
|
|||||||
* Tenant CRUD controller.
|
* Tenant CRUD controller.
|
||||||
* D-10: Only Super-Admin can manage tenants.
|
* D-10: Only Super-Admin can manage tenants.
|
||||||
* T-02-09: Tenant deletion blocked if active users exist.
|
* T-02-09: Tenant deletion blocked if active users exist.
|
||||||
|
*
|
||||||
|
* User counts (260911-e2s, Aufgabe 3): `findAll`/`findOne`/`remove` used to
|
||||||
|
* read the user count through a relation include on the four unbound
|
||||||
|
* tenant reads below. Prisma renders that as a single statement with a
|
||||||
|
* LEFT JOIN into the protected `User` table — which carries a row-level
|
||||||
|
* security rule. After the switch flips, an unbound relation count reads
|
||||||
|
* zero for every tenant (measured, 260911-e2s Aufgabe 1, Pruefungen 5-7),
|
||||||
|
* which would make the platform-admin tenant list show zero users
|
||||||
|
* everywhere and let the delete gate below pass through with active users
|
||||||
|
* still present. Fixed by the fan-out pattern `UserService
|
||||||
|
* .findAllForPlatformAdmin` already uses: read tenants unbound, then bind
|
||||||
|
* ONE user count per tenant via the tenant-binding helper. The explicit
|
||||||
|
* `where: { tenantId }` on each bound count is TODAY (role runs with
|
||||||
|
* BYPASSRLS, WINDOWS #18) the only filter actually in effect.
|
||||||
|
*
|
||||||
|
* The four tenant reads/writes below stay unbound on purpose — `Tenant`
|
||||||
|
* carries no row-level security rule in any shipped migration (measured,
|
||||||
|
* 260911-e2s Aufgabe 1, Pruefungen 1/2); nothing on `Tenant` itself needs
|
||||||
|
* binding.
|
||||||
|
*
|
||||||
|
* This controller keeps talking to Prisma directly rather than going
|
||||||
|
* through a service method (same pattern as `user.controller.ts`) — a
|
||||||
|
* deliberate choice, not an oversight; see
|
||||||
|
* docs/mandantentrennung-zugriffsklassifikation.md, "(n5)".
|
||||||
*/
|
*/
|
||||||
@Controller('tenants')
|
@Controller('tenants')
|
||||||
@UseGuards(RolesGuard)
|
@UseGuards(RolesGuard)
|
||||||
@@ -38,22 +63,26 @@ export class TenantController {
|
|||||||
@Get()
|
@Get()
|
||||||
async findAll() {
|
async findAll() {
|
||||||
const tenants = await this.prisma.tenant.findMany({
|
const tenants = await this.prisma.tenant.findMany({
|
||||||
include: {
|
|
||||||
_count: {
|
|
||||||
select: { users: true },
|
|
||||||
},
|
|
||||||
},
|
|
||||||
orderBy: { name: 'asc' },
|
orderBy: { name: 'asc' },
|
||||||
});
|
});
|
||||||
|
|
||||||
return tenants.map((t: any) => ({
|
const results: any[] = [];
|
||||||
id: t.id,
|
for (const tenant of tenants) {
|
||||||
name: t.name,
|
const tenantPrisma = forTenant(this.prisma, tenant.id) as any;
|
||||||
slug: t.slug,
|
const userCount = await tenantPrisma.user.count({
|
||||||
isActive: t.isActive,
|
where: { tenantId: tenant.id },
|
||||||
createdAt: t.createdAt,
|
});
|
||||||
userCount: t._count.users,
|
results.push({
|
||||||
}));
|
id: tenant.id,
|
||||||
|
name: tenant.name,
|
||||||
|
slug: tenant.slug,
|
||||||
|
isActive: tenant.isActive,
|
||||||
|
createdAt: tenant.createdAt,
|
||||||
|
userCount,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return results;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -64,24 +93,24 @@ export class TenantController {
|
|||||||
async findOne(@Param('id') id: string) {
|
async findOne(@Param('id') id: string) {
|
||||||
const tenant = await this.prisma.tenant.findUnique({
|
const tenant = await this.prisma.tenant.findUnique({
|
||||||
where: { id },
|
where: { id },
|
||||||
include: {
|
|
||||||
_count: {
|
|
||||||
select: { users: true },
|
|
||||||
},
|
|
||||||
},
|
|
||||||
});
|
});
|
||||||
|
|
||||||
if (!tenant) {
|
if (!tenant) {
|
||||||
throw new NotFoundException('Tenant not found');
|
throw new NotFoundException('Tenant not found');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const tenantPrisma = forTenant(this.prisma, id) as any;
|
||||||
|
const userCount = await tenantPrisma.user.count({
|
||||||
|
where: { tenantId: id },
|
||||||
|
});
|
||||||
|
|
||||||
return {
|
return {
|
||||||
id: tenant.id,
|
id: tenant.id,
|
||||||
name: tenant.name,
|
name: tenant.name,
|
||||||
slug: tenant.slug,
|
slug: tenant.slug,
|
||||||
isActive: tenant.isActive,
|
isActive: tenant.isActive,
|
||||||
createdAt: tenant.createdAt,
|
createdAt: tenant.createdAt,
|
||||||
userCount: tenant._count.users,
|
userCount,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -125,20 +154,18 @@ export class TenantController {
|
|||||||
async remove(@Param('id') id: string) {
|
async remove(@Param('id') id: string) {
|
||||||
const tenant = await this.prisma.tenant.findUnique({
|
const tenant = await this.prisma.tenant.findUnique({
|
||||||
where: { id },
|
where: { id },
|
||||||
include: {
|
|
||||||
_count: {
|
|
||||||
select: {
|
|
||||||
users: { where: { isActive: true } },
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
});
|
});
|
||||||
|
|
||||||
if (!tenant) {
|
if (!tenant) {
|
||||||
throw new NotFoundException('Tenant not found');
|
throw new NotFoundException('Tenant not found');
|
||||||
}
|
}
|
||||||
|
|
||||||
if (tenant._count.users > 0) {
|
const tenantPrisma = forTenant(this.prisma, id) as any;
|
||||||
|
const activeUserCount = await tenantPrisma.user.count({
|
||||||
|
where: { tenantId: id, isActive: true },
|
||||||
|
});
|
||||||
|
|
||||||
|
if (activeUserCount > 0) {
|
||||||
throw new BadRequestException(
|
throw new BadRequestException(
|
||||||
'Cannot delete tenant with active users. Deactivate or reassign users first.',
|
'Cannot delete tenant with active users. Deactivate or reassign users first.',
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -145,13 +145,13 @@ Details dazu im Abschnitt [Das Modulsystem](#das-modulsystem).
|
|||||||
zeigt intern auf `http://api:3001`) auf.
|
zeigt intern auf `http://api:3001`) auf.
|
||||||
2. Die Anfrage trifft in `apps/api/src/main.ts` auf die globale `ValidationPipe` und läuft dann
|
2. Die Anfrage trifft in `apps/api/src/main.ts` auf die globale `ValidationPipe` und läuft dann
|
||||||
durch die drei global registrierten `APP_GUARD`s aus `app.module.ts`, in genau dieser
|
durch die drei global registrierten `APP_GUARD`s aus `app.module.ts`, in genau dieser
|
||||||
Reihenfolge: `JwtAuthGuard` (Auth) → `TenantGuard` (setzt `req.tenantId`/`req.tenantPrisma`
|
Reihenfolge: `JwtAuthGuard` (Auth) → `TenantGuard` (setzt `req.tenantId`
|
||||||
aus dem JWT) → `RolesGuard` (prüft `@Roles()`).
|
aus dem JWT) → `RolesGuard` (prüft `@Roles()`).
|
||||||
3. Trägt der Controller zusätzlich `@UseModule('slug')`, prüft anschließend `ModuleGuard`
|
3. Trägt der Controller zusätzlich `@UseModule('slug')`, prüft anschließend `ModuleGuard`
|
||||||
(`apps/api/src/module-registry/module.guard.ts`) Modulzugriff über `ModuleAccessService`.
|
(`apps/api/src/module-registry/module.guard.ts`) Modulzugriff über `ModuleAccessService`.
|
||||||
4. Der Controller ruft den zugehörigen Service auf, der über `PrismaService`
|
4. Der Controller ruft den zugehörigen Service auf, der über `PrismaService`
|
||||||
(`apps/api/src/prisma/prisma.service.ts`) oder — für mandantensensible Tabellen — über den
|
(`apps/api/src/prisma/prisma.service.ts`) oder — für mandantensensible Tabellen — über einen
|
||||||
tenant-gescopten Client aus `req.tenantPrisma` auf Postgres zugreift.
|
dienst-intern per `forTenant()` gebundenen Client auf Postgres zugreift.
|
||||||
5. Die Antwort geht als JSON zurück; das Frontend rendert sie in der jeweiligen Server- oder
|
5. Die Antwort geht als JSON zurück; das Frontend rendert sie in der jeweiligen Server- oder
|
||||||
Client-Komponente.
|
Client-Komponente.
|
||||||
|
|
||||||
@@ -296,16 +296,17 @@ Unterverzeichnisse, die vom selben `layout.tsx` mitgedeckt werden.
|
|||||||
Der tatsächliche Mechanismus ist `TenantGuard` (`apps/api/src/tenant/tenant.guard.ts`), global als
|
Der tatsächliche Mechanismus ist `TenantGuard` (`apps/api/src/tenant/tenant.guard.ts`), global als
|
||||||
`APP_GUARD` in `app.module.ts` registriert — er läuft nach `JwtAuthGuard`, weil `req.user` erst
|
`APP_GUARD` in `app.module.ts` registriert — er läuft nach `JwtAuthGuard`, weil `req.user` erst
|
||||||
dann gesetzt ist. `TenantGuard` liest `tenantId` aus dem JWT-Claim des Anfragenden, erlaubt
|
dann gesetzt ist. `TenantGuard` liest `tenantId` aus dem JWT-Claim des Anfragenden, erlaubt
|
||||||
SUPER_ADMIN einen Wechsel per `x-tenant-id`-Header, und setzt anschließend `req.tenantId` sowie
|
SUPER_ADMIN einen Wechsel per `x-tenant-id`-Header, und setzt anschließend AUSSCHLIESSLICH
|
||||||
`req.tenantPrisma` — einen über `forTenant()`
|
`req.tenantId` (260911-e2s). Die Bindung an den Mandanten geschieht dienst-intern, je
|
||||||
(`apps/api/src/prisma/prisma-tenant.extension.ts`) erzeugten Prisma-Client, der vor **jeder** Query
|
Service-Methode neu, über das Bindungshilfsmittel `forTenant()`
|
||||||
in einer Transaktion `SELECT set_config('app.current_tenant', $1, true)` ausführt.
|
(`apps/api/src/prisma/prisma-tenant.extension.ts`), das vor **jeder** Query in einer Transaktion
|
||||||
|
`SELECT set_config('app.current_tenant', $1, true)` ausführt — der Guard selbst erzeugt keinen
|
||||||
|
Prisma-Client mehr und veröffentlicht keinen auf dem Anfrageobjekt.
|
||||||
|
|
||||||
> Im Code existiert daneben eine gleichnamige `TenantMiddleware`
|
> Ein früherer Entwurf veröffentlichte zusätzlich einen gebundenen Prisma-Client auf dem
|
||||||
> (`apps/api/src/tenant/tenant.middleware.ts`) mit identischer Logik. Sie ist in `app.module.ts`
|
> Anfrageobjekt, dupliziert in einer gleichnamigen, nie in `app.module.ts` registrierten
|
||||||
> nirgends über `.apply(...).forRoutes(...)` eingebunden — der tatsächlich aktive Mechanismus ist
|
> Express-Middleware mit identischer Logik — beides wurde mit 260911-e2s entfernt, nachdem eine
|
||||||
> ausschließlich `TenantGuard`. Vereinzelte Code-Kommentare verweisen noch auf „TenantMiddleware“;
|
> Volltextsuche keinen Leser dieser Eigenschaft außerhalb der beiden Dateien fand.
|
||||||
> gemeint ist in jedem Fall der Guard.
|
|
||||||
|
|
||||||
`app.current_tenant` wird von **Postgres Row-Level-Security** ausgewertet. RLS-Policies sind aber
|
`app.current_tenant` wird von **Postgres Row-Level-Security** ausgewertet. RLS-Policies sind aber
|
||||||
**nicht** auf allen Tabellen aktiv — aktuell nur auf `User`, `PasswordResetToken`, `LdapConfig`,
|
**nicht** auf allen Tabellen aktiv — aktuell nur auf `User`, `PasswordResetToken`, `LdapConfig`,
|
||||||
@@ -318,12 +319,12 @@ RLS-Policy.
|
|||||||
**Was ein Entwickler nie vergessen darf:** Bei jeder Query gegen eine Tabelle ohne RLS-Policy muss
|
**Was ein Entwickler nie vergessen darf:** Bei jeder Query gegen eine Tabelle ohne RLS-Policy muss
|
||||||
`tenantId` **manuell** in die `where`-Klausel — die Datenbank filtert hier nichts von selbst. Das
|
`tenantId` **manuell** in die `where`-Klausel — die Datenbank filtert hier nichts von selbst. Das
|
||||||
ist im Code auch der gelebte Stil: `DkvService.loadConfig()`
|
ist im Code auch der gelebte Stil: `DkvService.loadConfig()`
|
||||||
(`apps/api/src/dkv/dkv.service.ts`) etwa nutzt den plain `PrismaService` (nicht
|
(`apps/api/src/dkv/dkv.service.ts`) etwa nutzt den plain, UNGEBUNDENEN `PrismaService` und
|
||||||
`req.tenantPrisma`) und filtert explizit mit `where: { tenantId }`. Wer bei einer solchen Tabelle
|
filtert explizit mit `where: { tenantId }`. Wer bei einer solchen Tabelle das `tenantId`-Filter
|
||||||
das `tenantId`-Filter vergisst, liest oder schreibt mandantenübergreifend — ohne dass RLS das
|
vergisst, liest oder schreibt mandantenübergreifend — ohne dass RLS das auffängt. Bei den sieben
|
||||||
auffängt. Bei den sieben RLS-geschützten Tabellen greift die DB-seitige Absicherung zusätzlich,
|
RLS-geschützten Tabellen greift die DB-seitige Absicherung zusätzlich, vorausgesetzt die Query
|
||||||
vorausgesetzt die Query läuft tatsächlich über den `tenantPrisma`-Client aus `req.tenantPrisma`
|
läuft tatsächlich über einen dienst-intern per `forTenant()` gebundenen Client und nicht über
|
||||||
und nicht über den globalen `PrismaService`.
|
den globalen, ungebundenen `PrismaService`.
|
||||||
|
|
||||||
## Berechtigungen
|
## Berechtigungen
|
||||||
|
|
||||||
|
|||||||
@@ -47,6 +47,23 @@ entscheiden, ob die Controller künftig darüber gehen (dann bräuchte es keinen
|
|||||||
zweiten `forTenant()`-Aufruf je Service-Methode) oder ob der Weg ersatzlos
|
zweiten `forTenant()`-Aufruf je Service-Methode) oder ob der Weg ersatzlos
|
||||||
entfällt. Dieser Plan entscheidet das nicht, hält den Befund nur fest.
|
entfällt. Dieser Plan entscheidet das nicht, hält den Befund nur fest.
|
||||||
|
|
||||||
|
**Entschieden (260911-e2s, Aufgabe 2):** ersatzloser Entfall, fuer ALLE
|
||||||
|
Bereiche der Etappe 2, nicht nur fuer `tenant`. Gemessen: außerhalb von
|
||||||
|
`tenant.middleware.ts` und `tenant.guard.ts` gab es KEINEN Leser (Suchumfang
|
||||||
|
oben bestaetigt, ebenso erneut gemessen in 260911-e2s Aufgabe 1, Befund B);
|
||||||
|
`tenant.middleware.ts` war zudem NIRGENDS verdrahtet (kein
|
||||||
|
`MiddlewareConsumer`, kein `configure(` in ganz `apps/api`, gemessen)
|
||||||
|
und hatte — anders als der urspruengliche Befund oben suggerierte — auch
|
||||||
|
KEINE eigenen Tests, ebenso wenig wie der Guard (`ls apps/api/src/tenant/`
|
||||||
|
vor 260911-e2s: einzige Testdatei war `tenant.service.spec.ts`). Entscheidung:
|
||||||
|
`tenant.middleware.ts` ist GELOESCHT, `tenant.guard.ts` setzt nur noch
|
||||||
|
`req.tenantId` und hat keine Prisma-Abhaengigkeit mehr. Grund: alle neun vor
|
||||||
|
diesem Bereich umgestellten Bereiche binden ausnahmslos dienst-intern (ein
|
||||||
|
Klient je Methode) — die Konvention ist durch Praxis entschieden, und tote
|
||||||
|
Verdrahtung, die wie ein Sicherheitsmechanismus aussieht, ist schlimmer als
|
||||||
|
keine. Siehe `docs/mandantentrennung-etappe2-fehlerrichtung.md`, Abschnitt
|
||||||
|
"## Bereich tenant", (n4)(a), fuer Messung und Begruendung im Volltext.
|
||||||
|
|
||||||
**WINDOWS #19 — nullbares `tenantId` bei `SearchProvider` und
|
**WINDOWS #19 — nullbares `tenantId` bei `SearchProvider` und
|
||||||
`TenderRssFeedSource` — GESCHLOSSEN (260910-jab, Aufgabe 1/2).** Beide Modelle
|
`TenderRssFeedSource` — GESCHLOSSEN (260910-jab, Aufgabe 1/2).** Beide Modelle
|
||||||
tragen ein nullbares `tenantId` (`SearchProvider` für admin-gepflegte
|
tragen ein nullbares `tenantId` (`SearchProvider` für admin-gepflegte
|
||||||
@@ -125,12 +142,12 @@ autoritative Quelle.
|
|||||||
| dashboard | 1 | 12 | **war 13/0** — Aufgabe 2/3 (260910-krx) haben `dashboard.service.ts` vollständig umgestellt: `getLayout`/`saveLayout` (gemeinsam gebunden), `getWidgets`/`addWidget`/`updateWidgetConfig`/`removeWidget` sowie `getSearchProviders`/`addSearchProvider`/`removeSearchProvider` laufen über `forTenant()`, je Methode ein Klient. Der eine verbleibende ungebundene Rohtreffer ist bewusst: der Modulkatalog (`Module`) trägt heute keinen Zeilenschutz, eine Bindung wäre heute wirkungslos, nicht katastrophal — katastrophal würde sie erst, WENN Etappe 3 dieser Tabelle eine Regel gibt (Befund E aus `module-registry`, hier übernommen) |
|
| dashboard | 1 | 12 | **war 13/0** — Aufgabe 2/3 (260910-krx) haben `dashboard.service.ts` vollständig umgestellt: `getLayout`/`saveLayout` (gemeinsam gebunden), `getWidgets`/`addWidget`/`updateWidgetConfig`/`removeWidget` sowie `getSearchProviders`/`addSearchProvider`/`removeSearchProvider` laufen über `forTenant()`, je Methode ein Klient. Der eine verbleibende ungebundene Rohtreffer ist bewusst: der Modulkatalog (`Module`) trägt heute keinen Zeilenschutz, eine Bindung wäre heute wirkungslos, nicht katastrophal — katastrophal würde sie erst, WENN Etappe 3 dieser Tabelle eine Regel gibt (Befund E aus `module-registry`, hier übernommen) |
|
||||||
| auth | 8 | 5 | unverändert gegenüber dem in Etappe 1 (260909-eor) gemessenen Stand |
|
| auth | 8 | 5 | unverändert gegenüber dem in Etappe 1 (260909-eor) gemessenen Stand |
|
||||||
| calendar | 0 | 12 | **war 12/0** — Aufgabe 2 (260911-cwh) hat `calendar.service.ts` vollständig auf `forTenant()` umgestellt: `getSources`, `addSource`, beide Abfragen von `updateSource`/`deleteSource`, alle drei Abfragen von `testConnection`, Laden plus beide Synchronstatus-Rückschreibungen von `fetchAndCacheEvents` — je Methode ein Klient. Anders als bei den sieben Bereichen davor bleibt KEIN ungebundener Rest übrig: `CalendarSource` trägt eine Pflicht-Mandantenkennung, und kein Pfad dieses Bereichs liest über Mandanten hinweg |
|
| calendar | 0 | 12 | **war 12/0** — Aufgabe 2 (260911-cwh) hat `calendar.service.ts` vollständig auf `forTenant()` umgestellt: `getSources`, `addSource`, beide Abfragen von `updateSource`/`deleteSource`, alle drei Abfragen von `testConnection`, Laden plus beide Synchronstatus-Rückschreibungen von `fetchAndCacheEvents` — je Methode ein Klient. Anders als bei den sieben Bereichen davor bleibt KEIN ungebundener Rest übrig: `CalendarSource` trägt eine Pflicht-Mandantenkennung, und kein Pfad dieses Bereichs liest über Mandanten hinweg |
|
||||||
| tenant | 8 | 0 | unverändert |
|
| tenant | 8 | 3 | **war 8/0** — 260911-e2s (Aufgabe 3) hat drei gebundene Benutzerzähler in `tenant.controller.ts` eingeführt (Fan-out je Mandant nach dem Muster von `UserService.findAllForPlatformAdmin`, ersetzt die drei vorherigen Relationszähler); die acht `tenant`-Zugriffe selbst BLEIBEN ungebunden — `Tenant` trägt keine Regel in irgendeiner ausgelieferten Migration (260911-e2s Aufgabe 1, Prüfung 1/2), hier ist Ungebundenheit richtig, nicht geduldet |
|
||||||
| favorites | 7 | 0 | unverändert |
|
| favorites | 7 | 0 | unverändert |
|
||||||
| settings | 4 | 0 | unverändert |
|
| settings | 4 | 0 | unverändert |
|
||||||
| **Summe** | **83** | **159** | Ungebunden: war 118 nach 260910-das, dann 108 nach 260910-exd (module-registry 17→7), dann 107 nach 260910-jab (`tenders` 36→35, `listForUser` gebunden), dann 95 nach 260910-krx (`dashboard` 13→1), jetzt 83 nach 260911-cwh (`calendar` 12→0). Gebunden: war 124, dann 134 nach 260910-exd (zusätzlich 10 in `module-registry`), dann 135 nach 260910-jab (zusätzlich 1 in `tenders`), dann 147 nach 260910-krx (zusätzlich 12 in `dashboard`), jetzt 159 nach 260911-cwh (zusätzlich 12 in `calendar`). Diese Übersicht ist eine Buchführungshilfe; **autoritativ ist die Fundstellentabelle unten**, die `rls-access-inventory.spec.ts` bei jedem Lauf gegen den Quelltext prüft |
|
| **Summe** | **83** | **162** | Ungebunden: war 118 nach 260910-das, dann 108 nach 260910-exd (module-registry 17→7), dann 107 nach 260910-jab (`tenders` 36→35, `listForUser` gebunden), dann 95 nach 260910-krx (`dashboard` 13→1), jetzt 83 nach 260911-cwh (`calendar` 12→0) und unverändert nach 260911-e2s (`tenant` bleibt bei 8 ungebundenen Rohtreffern — nur die Gebunden-Spalte änderte sich). Gebunden: war 124, dann 134 nach 260910-exd (zusätzlich 10 in `module-registry`), dann 135 nach 260910-jab (zusätzlich 1 in `tenders`), dann 147 nach 260910-krx (zusätzlich 12 in `dashboard`), dann 159 nach 260911-cwh (zusätzlich 12 in `calendar`), jetzt 162 nach 260911-e2s (zusätzlich 3 in `tenant`). Diese Übersicht ist eine Buchführungshilfe; **autoritativ ist die Fundstellentabelle unten**, die `rls-access-inventory.spec.ts` bei jedem Lauf gegen den Quelltext prüft |
|
||||||
|
|
||||||
## Klassen-Verteilung (nach (Datei, Modell)-Fundstellen, 63 Paare)
|
## Klassen-Verteilung (nach (Datei, Modell)-Fundstellen, 64 Paare)
|
||||||
|
|
||||||
Stand 260909-jts (Aufgabe 3): 61 Paare aus dem vorherigen Durchlauf
|
Stand 260909-jts (Aufgabe 3): 61 Paare aus dem vorherigen Durchlauf
|
||||||
(260909-ipc) plus ein bisher vollstaendig unsichtbares Paar
|
(260909-ipc) plus ein bisher vollstaendig unsichtbares Paar
|
||||||
@@ -188,13 +205,22 @@ Vermerk waere von einer vergessenen Nachziehung nicht zu unterscheiden —
|
|||||||
deshalb steht die Abwesenheit einer Aenderung hier ausdruecklich, statt
|
deshalb steht die Abwesenheit einer Aenderung hier ausdruecklich, statt
|
||||||
stillschweigend uebersprungen zu werden.
|
stillschweigend uebersprungen zu werden.
|
||||||
|
|
||||||
|
**Stand 260911-e2s (Aufgabe 3):** 64 Paare — 63 aus dem vorherigen
|
||||||
|
Durchlauf plus EIN neues Paar (`tenant.controller.ts`/`user`, Klasse
|
||||||
|
`muss-mandantengebunden`, Stand `gebunden`): die drei gebundenen
|
||||||
|
Benutzerzähler des Fan-outs (Befund F/G aus 260911-e2s Aufgabe 1). Keine
|
||||||
|
bestehende Klasse verschiebt sich — die beiden `tenant`-Paare
|
||||||
|
(`tenant.controller.ts`/`tenant`, `tenant.service.ts`/`tenant`) bleiben
|
||||||
|
`keine-mandantengebundene-tabelle`/`ungebunden`, nur ihre Begründung wird
|
||||||
|
fortgeschrieben (siehe Fundstellentabelle unten).
|
||||||
|
|
||||||
| Klasse | Anzahl Paare |
|
| Klasse | Anzahl Paare |
|
||||||
|---|---|
|
|---|---|
|
||||||
| muss-mandantengebunden | 31 |
|
| muss-mandantengebunden | 32 |
|
||||||
| keine-mandantengebundene-tabelle | 17 |
|
| keine-mandantengebundene-tabelle | 17 |
|
||||||
| beides | 13 |
|
| beides | 13 |
|
||||||
| bewusst-uebergreifend | 2 |
|
| bewusst-uebergreifend | 2 |
|
||||||
| **Summe** | **63** |
|
| **Summe** | **64** |
|
||||||
|
|
||||||
## Der Hintergrunddienst als Falle — fünf Fälle
|
## Der Hintergrunddienst als Falle — fünf Fälle
|
||||||
|
|
||||||
@@ -333,6 +359,18 @@ Mandantenkennung der Anfrage, die sie ausgelöst hat, und kann strukturell
|
|||||||
keine andere haben. Kein Kandidat für diese Liste; dieser Absatz hält die
|
keine andere haben. Kein Kandidat für diese Liste; dieser Absatz hält die
|
||||||
Abwesenheit fest, damit sie nicht wie ein Übersehen aussieht.
|
Abwesenheit fest, damit sie nicht wie ein Übersehen aussieht.
|
||||||
|
|
||||||
|
Auch der Bereich `tenant` fügt diesem Abschnitt keinen sechsten Fall hinzu,
|
||||||
|
gemessen statt angenommen (260911-e2s, Aufgabe 1, Befund J). Anweisung:
|
||||||
|
`grep -rn "@Cron\|onModuleInit\|onApplicationBootstrap\|setInterval\|setTimeout\|Scheduler" apps/api/src/tenant --include=*.ts`
|
||||||
|
und `grep -rn '\$transaction(\|\$queryRaw\|\$executeRaw' apps/api/src/tenant --include=*.ts`
|
||||||
|
liefern je null Treffer — kein Hintergrunddienst, kein Roh-SQL, keine
|
||||||
|
Transaktion in diesem Bereich. `TenantService.create` ruft nach dem Anlegen
|
||||||
|
eines Mandanten `groupsService.ensureDefaultGroup(tenant.id)` auf; dieser
|
||||||
|
Aufruf läuft seit 260909-jts bereits vollständig über den gebundenen Weg des
|
||||||
|
Bereichs `groups` (`forTenant()`/`withTenantTransaction()`, siehe
|
||||||
|
Fundstellentabelle unten, `groups.service.ts`/`group`, Stand `gebunden`) —
|
||||||
|
nichts an dieser Übergabe musste in 260911-e2s umgestellt werden.
|
||||||
|
|
||||||
## Bestandsaufnahme
|
## Bestandsaufnahme
|
||||||
|
|
||||||
Maschinell ermittelt, `rls-access-inventory.spec.ts` hält Vollständigkeit
|
Maschinell ermittelt, `rls-access-inventory.spec.ts` hält Vollständigkeit
|
||||||
@@ -341,6 +379,17 @@ oder — seit 260909-ipc, Befund G — in `<gebundener Client>.<Modell>`
|
|||||||
verwendet), Klasse, Stand (`gebunden`/`ungebunden`/`gemischt`, seit
|
verwendet), Klasse, Stand (`gebunden`/`ungebunden`/`gemischt`, seit
|
||||||
260909-ipc maschinell gegen den Quelltext geprüft), Begründung.
|
260909-ipc maschinell gegen den Quelltext geprüft), Begründung.
|
||||||
|
|
||||||
|
**Erkennungslücke, seit 260911-e2s vermessen (Aufgabe 1, (n4)(b)):** die
|
||||||
|
Bestandsaufnahme sieht ausschließlich (Datei, Modell)-Paare über
|
||||||
|
`this.prisma.<Modell>` bzw. `<gebundener Client>.<Modell>` — eine
|
||||||
|
Relationseinbindung (`include:`, Relationszähler `_count`) in eine ZWEITE
|
||||||
|
Tabelle erzeugt kein eigenes Paar und ist für das Werkzeug strukturell
|
||||||
|
unsichtbar. Alle 19 `include:`-Stellen und alle `_count`-Stellen des
|
||||||
|
API-Quelltexts wurden einzeln nachgesehen; die einzige gefährliche
|
||||||
|
Ausprägung (ungebundener äußerer Aufruf auf einer UNGESCHÜTZTEN Tabelle,
|
||||||
|
Einbindung in eine GESCHÜTZTE Tabelle) war `tenant.controller.ts` — hier in
|
||||||
|
Aufgabe 3 behoben (Fan-out ersetzt den Relationszähler).
|
||||||
|
|
||||||
| Datei | Modell | Klasse | Stand | Begründung |
|
| Datei | Modell | Klasse | Stand | Begründung |
|
||||||
|---|---|---|---|---|
|
|---|---|---|---|---|
|
||||||
| apps/api/src/auth/auth.service.ts | passwordResetToken | muss-mandantengebunden | gebunden | Kein eigenes `tenantId`, RLS ueber Join auf `User` (Migration 20260618112133). `requestPasswordReset`/`resetPassword` laufen vollstaendig ueber `forTenant()` (Etappe 1, WINDOWS #20, Aufgabe 1) — von der alten, nur `this.prisma.*` erkennenden Suche nie erfasst, weil bereits gebunden; die erweiterte Erkennung aus Aufgabe 2 (260909-ipc) macht diese Fundstelle erstmals sichtbar. |
|
| apps/api/src/auth/auth.service.ts | passwordResetToken | muss-mandantengebunden | gebunden | Kein eigenes `tenantId`, RLS ueber Join auf `User` (Migration 20260618112133). `requestPasswordReset`/`resetPassword` laufen vollstaendig ueber `forTenant()` (Etappe 1, WINDOWS #20, Aufgabe 1) — von der alten, nur `this.prisma.*` erkennenden Suche nie erfasst, weil bereits gebunden; die erweiterte Erkennung aus Aufgabe 2 (260909-ipc) macht diese Fundstelle erstmals sichtbar. |
|
||||||
@@ -376,8 +425,9 @@ verwendet), Klasse, Stand (`gebunden`/`ungebunden`/`gemischt`, seit
|
|||||||
| apps/api/src/module-registry/module-registry.service.ts | module | keine-mandantengebundene-tabelle | ungebunden | Modulkatalog ist plattformweit. Bleibt bewusst ungebunden (260910-exd, Aufgabe 1, Befund E): keine Regel auf der Tabelle, eine Bindung waere heute wirkungslos, nicht katastrophal — katastrophal erst, WENN Etappe 3 dieser Tabelle eine Regel gibt. `findBySlug` ist zusaetzlich die Stelle, die `ModuleGuard` bei JEDER Modulanfrage aufruft. |
|
| apps/api/src/module-registry/module-registry.service.ts | module | keine-mandantengebundene-tabelle | ungebunden | Modulkatalog ist plattformweit. Bleibt bewusst ungebunden (260910-exd, Aufgabe 1, Befund E): keine Regel auf der Tabelle, eine Bindung waere heute wirkungslos, nicht katastrophal — katastrophal erst, WENN Etappe 3 dieser Tabelle eine Regel gibt. `findBySlug` ist zusaetzlich die Stelle, die `ModuleGuard` bei JEDER Modulanfrage aufruft. |
|
||||||
| apps/api/src/module-registry/module-registry.service.ts | tenantModuleActivation | muss-mandantengebunden | gebunden | Aktivierung je Mandant. Seit 260910-exd (Aufgabe 3) laufen `findActiveForTenant`, `activateForTenant`, beide Zugriffe von `deactivateForTenant` (ueber EINEN Klienten) und `isModuleActive` ueber `forTenant()`, je Methode EIN Klient. |
|
| apps/api/src/module-registry/module-registry.service.ts | tenantModuleActivation | muss-mandantengebunden | gebunden | Aktivierung je Mandant. Seit 260910-exd (Aufgabe 3) laufen `findActiveForTenant`, `activateForTenant`, beide Zugriffe von `deactivateForTenant` (ueber EINEN Klienten) und `isModuleActive` ueber `forTenant()`, je Methode EIN Klient. |
|
||||||
| apps/api/src/settings/settings.service.ts | smtpConfig | muss-mandantengebunden | ungebunden | SMTP-Zugangsdaten je Mandant, `tenantId`-Spalte vorhanden. |
|
| apps/api/src/settings/settings.service.ts | smtpConfig | muss-mandantengebunden | ungebunden | SMTP-Zugangsdaten je Mandant, `tenantId`-Spalte vorhanden. |
|
||||||
| apps/api/src/tenant/tenant.controller.ts | tenant | keine-mandantengebundene-tabelle | ungebunden | `Tenant` ist die Mandantentabelle selbst — hat keine eigene `tenantId`-Spalte, kann sie per Definition nicht haben (Migration 20260909140000, Gruppe b). |
|
| apps/api/src/tenant/tenant.controller.ts | tenant | keine-mandantengebundene-tabelle | ungebunden | `Tenant` ist die Mandantentabelle selbst — hat keine eigene `tenantId`-Spalte, kann sie per Definition nicht haben (Migration 20260909140000, Gruppe b). Gemessen 260911-e2s (Aufgabe 1, Prüfung 1/2): keine Regel auf `Tenant` in irgendeiner der 34 ausgelieferten Migrationen einschließlich `20260910120000_rls_widen_membership_grant_and_platform_read`; gebunden und ungebunden liefern über Roh-SQL UND generierten Client dieselben Zeilen. |
|
||||||
| apps/api/src/tenant/tenant.service.ts | tenant | keine-mandantengebundene-tabelle | ungebunden | Dieselbe Begründung. |
|
| apps/api/src/tenant/tenant.controller.ts | user | muss-mandantengebunden | gebunden | Seit 260911-e2s (Aufgabe 3): `findAll`/`findOne`/`remove` zählen Benutzer je Mandant über drei gebundene Aufrufstellen (`tenantPrisma.user.count`, Fan-out-Muster aus `UserService.findAllForPlatformAdmin`) statt über den früheren Relationszähler (`include: { _count: { select: { users } } }`), der nach dem Scharfschalten unter der Regel von `User` unbemerkt null geliefert hätte (260911-e2s Aufgabe 1, Prüfungen 5-7). `where: { tenantId }` bleibt heute (Rolle mit BYPASSRLS, WINDOWS #18) der einzige wirksame Filter. |
|
||||||
|
| apps/api/src/tenant/tenant.service.ts | tenant | keine-mandantengebundene-tabelle | ungebunden | Dieselbe Begründung. Gemessen 260911-e2s (Aufgabe 1, Prüfung 1/2): keine Regel auf `Tenant` in irgendeiner der 34 ausgelieferten Migrationen einschließlich `20260910120000_rls_widen_membership_grant_and_platform_read`. |
|
||||||
| apps/api/src/tenders/adapters/email-alert.adapter.ts | tenderEmailConfig | bewusst-uebergreifend | ungebunden | `fetchTenders()` liest bewusst jede aktive `TenderEmailConfig`-Zeile über ALLE Mandanten in einer Abfrage (Plattform-Scheduler, ein Tick pro Postfach, D-13/D-01) — ausführlich im Dateikopf begründet, darf laut Kommentar niemals in `forTenant()` verpackt werden. |
|
| apps/api/src/tenders/adapters/email-alert.adapter.ts | tenderEmailConfig | bewusst-uebergreifend | ungebunden | `fetchTenders()` liest bewusst jede aktive `TenderEmailConfig`-Zeile über ALLE Mandanten in einer Abfrage (Plattform-Scheduler, ein Tick pro Postfach, D-13/D-01) — ausführlich im Dateikopf begründet, darf laut Kommentar niemals in `forTenant()` verpackt werden. |
|
||||||
| apps/api/src/tenders/adapters/rss.adapter.ts | tenderRssFeedSource | bewusst-uebergreifend | ungebunden | Fan-out über jeden aktiven Feed, plattformweit UND persönlich, in einer Abfrage (Zeilen 55–83 im Dateikopf begründet) — dieselbe Scheduler-Ebene wie beim E-Mail-Adapter. |
|
| apps/api/src/tenders/adapters/rss.adapter.ts | tenderRssFeedSource | bewusst-uebergreifend | ungebunden | Fan-out über jeden aktiven Feed, plattformweit UND persönlich, in einer Abfrage (Zeilen 55–83 im Dateikopf begründet) — dieselbe Scheduler-Ebene wie beim E-Mail-Adapter. |
|
||||||
| apps/api/src/tenders/tender-dedup.service.ts | tender | keine-mandantengebundene-tabelle | ungebunden | Explizit im Dateikopf: "platform-global, RLS-exempt tables. Never wrap these queries in forTenant()." (D-03) |
|
| apps/api/src/tenders/tender-dedup.service.ts | tender | keine-mandantengebundene-tabelle | ungebunden | Explizit im Dateikopf: "platform-global, RLS-exempt tables. Never wrap these queries in forTenant()." (D-03) |
|
||||||
@@ -409,7 +459,7 @@ verwendet), Klasse, Stand (`gebunden`/`ungebunden`/`gemischt`, seit
|
|||||||
|
|
||||||
## Was diese Etappe NICHT entscheidet
|
## Was diese Etappe NICHT entscheidet
|
||||||
|
|
||||||
- Ob Controller künftig über `req.tenantPrisma` statt eines erneuten
|
- ~~Ob Controller künftig über `req.tenantPrisma` statt eines erneuten
|
||||||
`forTenant()`-Aufrufs im Service gehen (offener Befund oben). Der Bereich
|
`forTenant()`-Aufrufs im Service gehen (offener Befund oben). Der Bereich
|
||||||
`ldap` (260909-ipc) hat sich für den Dienst-internen Weg entschieden —
|
`ldap` (260909-ipc) hat sich für den Dienst-internen Weg entschieden —
|
||||||
`forTenant(this.prisma, tenantId)` wird in jeder umgestellten Methode neu
|
`forTenant(this.prisma, tenantId)` wird in jeder umgestellten Methode neu
|
||||||
@@ -425,7 +475,15 @@ verwendet), Klasse, Stand (`gebunden`/`ungebunden`/`gemischt`, seit
|
|||||||
`calendar` (260911-cwh) hat sich für denselben dienst-internen Weg
|
`calendar` (260911-cwh) hat sich für denselben dienst-internen Weg
|
||||||
entschieden — jede der sechs umgestellten Methoden in `calendar.service.ts`
|
entschieden — jede der sechs umgestellten Methoden in `calendar.service.ts`
|
||||||
erzeugt ihren eigenen `forTenant()`-Aufruf, wie alle acht Bereiche vor ihm.
|
erzeugt ihren eigenen `forTenant()`-Aufruf, wie alle acht Bereiche vor ihm.
|
||||||
Die Frage bleibt für alle übrigen Bereiche der Etappe 2 offen.
|
Die Frage bleibt für alle übrigen Bereiche der Etappe 2 offen.~~
|
||||||
|
**Aufgelöst (260911-e2s):** die Frage ist für ALLE Bereiche entschieden,
|
||||||
|
nicht nur für `tenant` — dienst-intern, ein Klient je Methode, ist die
|
||||||
|
Konvention. Die Anfrageobjekt-Eigenschaft existiert nicht mehr:
|
||||||
|
`tenant.guard.ts` setzt nur noch `req.tenantId`, `tenant.middleware.ts`
|
||||||
|
(der nie verdrahtete Zwilling mit identischer Logik) ist gelöscht. Siehe
|
||||||
|
Abschnitt "Zwei belegte Befunde" oben und
|
||||||
|
`docs/mandantentrennung-etappe2-fehlerrichtung.md`, "## Bereich tenant",
|
||||||
|
(n4)(a).
|
||||||
- ~~Wie die WINDOWS-#19-Policy für `SearchProvider`/`TenderRssFeedSource`
|
- ~~Wie die WINDOWS-#19-Policy für `SearchProvider`/`TenderRssFeedSource`
|
||||||
am Ende genau lautet — nur, dass sie vor dem Scharfschalten gelöst sein
|
am Ende genau lautet — nur, dass sie vor dem Scharfschalten gelöst sein
|
||||||
muss.~~ Aufgelöst (260910-jab): `TenderRssFeedSource` bekommt vier nach
|
muss.~~ Aufgelöst (260910-jab): `TenderRssFeedSource` bekommt vier nach
|
||||||
|
|||||||
Reference in New Issue
Block a user