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 { Roles } from '../auth/decorators/roles.decorator';
|
||||
import { RolesGuard } from '../auth/guards/roles.guard';
|
||||
import { forTenant } from '../prisma/prisma-tenant.extension';
|
||||
import { PrismaService } from '../prisma/prisma.service';
|
||||
import { CreateTenantDto } from './dto/create-tenant.dto';
|
||||
import { TenantService } from './tenant.service';
|
||||
@@ -21,6 +22,30 @@ import { TenantService } from './tenant.service';
|
||||
* Tenant CRUD controller.
|
||||
* D-10: Only Super-Admin can manage tenants.
|
||||
* 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')
|
||||
@UseGuards(RolesGuard)
|
||||
@@ -38,22 +63,26 @@ export class TenantController {
|
||||
@Get()
|
||||
async findAll() {
|
||||
const tenants = await this.prisma.tenant.findMany({
|
||||
include: {
|
||||
_count: {
|
||||
select: { users: true },
|
||||
},
|
||||
},
|
||||
orderBy: { name: 'asc' },
|
||||
});
|
||||
|
||||
return tenants.map((t: any) => ({
|
||||
id: t.id,
|
||||
name: t.name,
|
||||
slug: t.slug,
|
||||
isActive: t.isActive,
|
||||
createdAt: t.createdAt,
|
||||
userCount: t._count.users,
|
||||
}));
|
||||
const results: any[] = [];
|
||||
for (const tenant of tenants) {
|
||||
const tenantPrisma = forTenant(this.prisma, tenant.id) as any;
|
||||
const userCount = await tenantPrisma.user.count({
|
||||
where: { tenantId: tenant.id },
|
||||
});
|
||||
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) {
|
||||
const tenant = await this.prisma.tenant.findUnique({
|
||||
where: { id },
|
||||
include: {
|
||||
_count: {
|
||||
select: { users: true },
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
if (!tenant) {
|
||||
throw new NotFoundException('Tenant not found');
|
||||
}
|
||||
|
||||
const tenantPrisma = forTenant(this.prisma, id) as any;
|
||||
const userCount = await tenantPrisma.user.count({
|
||||
where: { tenantId: id },
|
||||
});
|
||||
|
||||
return {
|
||||
id: tenant.id,
|
||||
name: tenant.name,
|
||||
slug: tenant.slug,
|
||||
isActive: tenant.isActive,
|
||||
createdAt: tenant.createdAt,
|
||||
userCount: tenant._count.users,
|
||||
userCount,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -125,20 +154,18 @@ export class TenantController {
|
||||
async remove(@Param('id') id: string) {
|
||||
const tenant = await this.prisma.tenant.findUnique({
|
||||
where: { id },
|
||||
include: {
|
||||
_count: {
|
||||
select: {
|
||||
users: { where: { isActive: true } },
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
if (!tenant) {
|
||||
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(
|
||||
'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.
|
||||
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
|
||||
Reihenfolge: `JwtAuthGuard` (Auth) → `TenantGuard` (setzt `req.tenantId`/`req.tenantPrisma`
|
||||
Reihenfolge: `JwtAuthGuard` (Auth) → `TenantGuard` (setzt `req.tenantId`
|
||||
aus dem JWT) → `RolesGuard` (prüft `@Roles()`).
|
||||
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`.
|
||||
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
|
||||
tenant-gescopten Client aus `req.tenantPrisma` auf Postgres zugreift.
|
||||
(`apps/api/src/prisma/prisma.service.ts`) oder — für mandantensensible Tabellen — über einen
|
||||
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
|
||||
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
|
||||
`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
|
||||
SUPER_ADMIN einen Wechsel per `x-tenant-id`-Header, und setzt anschließend `req.tenantId` sowie
|
||||
`req.tenantPrisma` — einen über `forTenant()`
|
||||
(`apps/api/src/prisma/prisma-tenant.extension.ts`) erzeugten Prisma-Client, der vor **jeder** Query
|
||||
in einer Transaktion `SELECT set_config('app.current_tenant', $1, true)` ausführt.
|
||||
SUPER_ADMIN einen Wechsel per `x-tenant-id`-Header, und setzt anschließend AUSSCHLIESSLICH
|
||||
`req.tenantId` (260911-e2s). Die Bindung an den Mandanten geschieht dienst-intern, je
|
||||
Service-Methode neu, über das Bindungshilfsmittel `forTenant()`
|
||||
(`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`
|
||||
> (`apps/api/src/tenant/tenant.middleware.ts`) mit identischer Logik. Sie ist in `app.module.ts`
|
||||
> nirgends über `.apply(...).forRoutes(...)` eingebunden — der tatsächlich aktive Mechanismus ist
|
||||
> ausschließlich `TenantGuard`. Vereinzelte Code-Kommentare verweisen noch auf „TenantMiddleware“;
|
||||
> gemeint ist in jedem Fall der Guard.
|
||||
> Ein früherer Entwurf veröffentlichte zusätzlich einen gebundenen Prisma-Client auf dem
|
||||
> Anfrageobjekt, dupliziert in einer gleichnamigen, nie in `app.module.ts` registrierten
|
||||
> Express-Middleware mit identischer Logik — beides wurde mit 260911-e2s entfernt, nachdem eine
|
||||
> Volltextsuche keinen Leser dieser Eigenschaft außerhalb der beiden Dateien fand.
|
||||
|
||||
`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`,
|
||||
@@ -318,12 +319,12 @@ RLS-Policy.
|
||||
**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
|
||||
ist im Code auch der gelebte Stil: `DkvService.loadConfig()`
|
||||
(`apps/api/src/dkv/dkv.service.ts`) etwa nutzt den plain `PrismaService` (nicht
|
||||
`req.tenantPrisma`) und filtert explizit mit `where: { tenantId }`. Wer bei einer solchen Tabelle
|
||||
das `tenantId`-Filter vergisst, liest oder schreibt mandantenübergreifend — ohne dass RLS das
|
||||
auffängt. Bei den sieben RLS-geschützten Tabellen greift die DB-seitige Absicherung zusätzlich,
|
||||
vorausgesetzt die Query läuft tatsächlich über den `tenantPrisma`-Client aus `req.tenantPrisma`
|
||||
und nicht über den globalen `PrismaService`.
|
||||
(`apps/api/src/dkv/dkv.service.ts`) etwa nutzt den plain, UNGEBUNDENEN `PrismaService` und
|
||||
filtert explizit mit `where: { tenantId }`. Wer bei einer solchen Tabelle das `tenantId`-Filter
|
||||
vergisst, liest oder schreibt mandantenübergreifend — ohne dass RLS das auffängt. Bei den sieben
|
||||
RLS-geschützten Tabellen greift die DB-seitige Absicherung zusätzlich, vorausgesetzt die Query
|
||||
läuft tatsächlich über einen dienst-intern per `forTenant()` gebundenen Client und nicht über
|
||||
den globalen, ungebundenen `PrismaService`.
|
||||
|
||||
## 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
|
||||
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
|
||||
`TenderRssFeedSource` — GESCHLOSSEN (260910-jab, Aufgabe 1/2).** Beide Modelle
|
||||
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) |
|
||||
| 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 |
|
||||
| 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 |
|
||||
| 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
|
||||
(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
|
||||
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 |
|
||||
|---|---|
|
||||
| muss-mandantengebunden | 31 |
|
||||
| muss-mandantengebunden | 32 |
|
||||
| keine-mandantengebundene-tabelle | 17 |
|
||||
| beides | 13 |
|
||||
| bewusst-uebergreifend | 2 |
|
||||
| **Summe** | **63** |
|
||||
| **Summe** | **64** |
|
||||
|
||||
## 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
|
||||
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
|
||||
|
||||
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
|
||||
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 |
|
||||
|---|---|---|---|---|
|
||||
| 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 | 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/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.service.ts | tenant | keine-mandantengebundene-tabelle | ungebunden | Dieselbe Begründung. |
|
||||
| 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.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/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) |
|
||||
@@ -409,7 +459,7 @@ verwendet), Klasse, Stand (`gebunden`/`ungebunden`/`gemischt`, seit
|
||||
|
||||
## 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
|
||||
`ldap` (260909-ipc) hat sich für den Dienst-internen Weg entschieden —
|
||||
`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
|
||||
entschieden — jede der sechs umgestellten Methoden in `calendar.service.ts`
|
||||
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`
|
||||
am Ende genau lautet — nur, dass sie vor dem Scharfschalten gelöst sein
|
||||
muss.~~ Aufgelöst (260910-jab): `TenderRssFeedSource` bekommt vier nach
|
||||
|
||||
Reference in New Issue
Block a user