Files

926 lines
88 KiB
Markdown

---
phase: quick-260911-e2s
plan: 01
type: execute
wave: 1
depends_on: []
autonomous: true
requirements: [WINDOWS-18, ETAPPE-2-TENANT]
files_modified:
- apps/api/scripts/rls-scratch-check.mjs
- docs/mandantentrennung-etappe2-fehlerrichtung.md
- apps/api/src/tenant/tenant.guard.ts
- apps/api/src/tenant/tenant.guard.spec.ts
- apps/api/src/tenant/tenant.middleware.ts
- apps/api/src/prisma/rls-access-inventory.spec.ts
- apps/api/src/app.module.ts
- apps/api/src/module-registry/module.guard.ts
- apps/api/src/dkv/dkv.controller.ts
- apps/api/src/tenant/tenant.controller.ts
- apps/api/src/tenant/tenant.controller.spec.ts
- docs/mandantentrennung-zugriffsklassifikation.md
- docs/anleitung-entwicklung.md
files_deleted:
- apps/api/src/tenant/tenant.middleware.ts
estimate:
tokens: 180000
raw_tokens: 180000
tasks: 3
confidence: low
must_haves:
truths:
- "Die seit Etappe 1 offene Frage nach dem gebundenen Klienten auf dem Anfrageobjekt ist ENTSCHIEDEN, nicht ein zehntes Mal vertagt: die Verdrahtung ist entfernt (Guard setzt nur noch die Mandantenkennung; die nie verdrahtete Middleware ist geloescht), die Entscheidung steht mit Grund in Code, Kritikschrift, Klassifikation (Abschnitt `Zwei belegte Befunde` UND Abschnitt `Was diese Etappe NICHT entscheidet`) und Entwicklungsanleitung, und ein Test macht ein Wiederauftauchen der Eigenschaft rot."
- "`req.tenantId` — die tragende Leistung des Guards, gelesen an 22 Stellen in 9 Dateien ausserhalb des Bereichs (gemessen) — bleibt in ALLEN fuenf Zweigen gesetzt; der SUPER_ADMIN-Wechsel per `x-tenant-id` (vom Marktplatz-Frontend benutzt) und die `ForbiddenException` fuer mandantenlose Nicht-SUPER_ADMIN bleiben. Jeder Zweig ist als Testfall festgenagelt, nicht behauptet."
- "Die Klassifikation der Tabelle `Tenant` ist gegen den Code UND gegen alle 34 ausgelieferten Migrationen geprueft, zur Laufzeit im Werkzeug (Pruefung 1): kein `CREATE POLICY`, kein `ENABLE ROW LEVEL SECURITY`, keine `tenantId`-Spalte, auch nicht in 20260910120000. Ein gebundener und ein ungebundener Lesezugriff auf `Tenant` liefern DIESELBEN Zeilen — Roh-SQL UND generierter Client (Pruefungen 2 und 4). Auf `Tenant` selbst ist nichts zu binden, und das ist gemessen."
- "Der Befund, den die Erwartung 'null Umstellungsarbeit' uebersehen haette, ist gemessen und behoben: drei der acht Zugriffsstellen (`findAll`, `findOne`, `remove` im Controller) zaehlen ueber den Relationszaehler in die geschuetzte Tabelle `User` hinein — Prisma erzeugt EINE Anweisung mit LEFT JOIN auf `User`. Nach dem Scharfschalten zeigte die Mandantenliste des Plattform-Administrators fuer JEDEN Mandanten null Benutzer, und der Loeschriegel (T-02-09) wuerde vakuum. Gemessen im Werkzeug (Pruefungen 5-7), behoben nach dem Fan-out-Muster des Bereichs `user` (260910-das): Mandanten ungebunden lesen, je Mandant EINEN gebundenen Zaehler."
- "Die umgekehrte Fehlerrichtung dieses Bereichs ist in ihrer Auspraegung benannt: nicht 'keine Mandanten', sondern 'jeder Mandant hat 0 Benutzer' (eine falsche Zahl, keine leere Liste) und ein Loeschen, das statt der verstaendlichen 400-Meldung an den Fremdschluessel `ON DELETE RESTRICT` laeuft (laut, 500, falsche Botschaft; die referentielle Pruefung umgeht den Zeilenschutz — gemessen, Pruefung 7). Das Frontend (`admin/tenants/page.tsx`) zeigt die falsche Zahl an und verschluckt jede nicht-OK-Antwort beim Loeschen. Festgehalten in (n2)/(n3)."
- "Die Erkennungsluecke der maschinellen Bestandsaufnahme ist benannt und vermessen: sie sieht (Datei, Modell)-Paare ueber `this.prisma.<Modell>` bzw. `<gebundener Klient>.<Modell>`, aber KEINE Relationseinbindung (`include`, Relationszaehler) in eine zweite Tabelle. Alle 19 `include:`-Stellen und alle `_count`-Stellen des API-Quelltexts sind einzeln nachgesehen; der Bereich `tenant` war die einzige gefaehrliche Auspraegung (ungebundener aeusserer Aufruf auf ungeschuetzter Tabelle, Einbindung in geschuetzte Tabelle). Steht in (n4) und im Kopf der Bestandsaufnahme."
- "Die SUPER_ADMIN-Beschraenkung der Mandantenverwaltung ist GELESEN und festgenagelt: klassenweites `@Roles(Role.SUPER_ADMIN)`, `RolesGuard` mit `getAllAndOverride([handler, class])`, kein Handler ueberschreibt — ein Metadaten-Test schlaegt fehl, sobald ein Handler eine schwaechere Rolle bekommt."
- "Die Testlage fuer Guard und Controller entsteht aus dem Nichts (heute: nur `tenant.service.spec.ts` mit zwei Faellen zu `create`): `tenant.guard.spec.ts` und `tenant.controller.spec.ts` mit dem Zwei-Klienten-Nachweis, jeweils durch einen zurueckgenommenen Falsifizierungsnachweis mit woertlicher Meldung belegt."
- "Alle fuenf handgepflegten Dokumentstellen der Klassifikation sind nachgezogen und maschinell gegatet, die Gates LEITEN ihre Werte aus den im Dokument genannten Messanweisungen ab; der Umfang ist als ERLAUBNISLISTE gegen `f1017fa` gegatet; drei Fremddateien werden ausschliesslich in Kommentarzeilen angefasst und genau das ist gegatet."
- "Baseline gehalten am Ende JEDER Aufgabe: mindestens 883 Tests gruen (nach Aufgabe 2 und 3 mehr), Typpruefung sauber, das Wegwerf-Werkzeug meldet alle Pruefungen bestanden (mindestens 110). Der Schalter bleibt AUS, Schema und Migrationen unveraendert, keine Compose- oder Umgebungsdatei angefasst, nichts in Active Directory, KEINE Regel auf `Tenant`."
artifacts:
- "apps/api/scripts/rls-scratch-check.mjs — ein zwoelfter Abschnitt `runTenantAreaChecks` mit mindestens neun namentlich benannten Pruefungen, davon fuenf ueber den generierten Client; Migrationsscan zur Laufzeit; Wegwerf-Tabelle `Tenant` auf den ausgelieferten Spaltensatz gebracht und gegen `CREATE TABLE` aus 20260618112124 geprueft; Fremdschluessel `User_tenantId_fkey` wortgleich aus der Migration"
- "docs/mandantentrennung-etappe2-fehlerrichtung.md — Abschnitt `## Bereich tenant` mit (n1) Messung, (n2) Signaltabelle, (n3) Leere/falsche Zahl als Abwesenheit im Backend UND Frontend, (n4) bewusst nicht geloest (darunter die Entscheidung zum Anfrageobjekt mit Grund, die Erkennungsluecke mit Liste, der Fremdschluessel als Rueckhalt), (n5) bewusst nicht angefasst"
- "apps/api/src/tenant/tenant.guard.ts — setzt nur noch `req.tenantId`; keine Prisma-Abhaengigkeit mehr; Kopfkommentar nennt die Entscheidung und den Grund"
- "apps/api/src/tenant/tenant.middleware.ts — GELOESCHT (nie verdrahtet, gemessen)"
- "apps/api/src/tenant/tenant.guard.spec.ts — NEU, alle fuenf Zweige, Abwesenheit der Anfrageobjekt-Eigenschaft, Header nur fuer SUPER_ADMIN"
- "apps/api/src/prisma/rls-access-inventory.spec.ts — Ausnahmeliste `FORTENANT_ASSIGNMENT_EXCEPTIONS` geleert mit fortgeschriebenem Kommentar; neuer Testfall gegen veraltete Ausnahmeeintraege"
- "apps/api/src/app.module.ts, apps/api/src/module-registry/module.guard.ts, apps/api/src/dkv/dkv.controller.ts — je EINE Kommentarzeile berichtigt (nennen heute die Anfrageobjekt-Eigenschaft bzw. die nie verdrahtete Middleware), keine Codezeile"
- "apps/api/src/tenant/tenant.controller.ts — `findAll`/`findOne`/`remove` zaehlen Benutzer je Mandant ueber `forTenant()` (drei Aufrufstellen, `tenantPrisma.user.count`), die vier `tenant`-Zugriffe bleiben ungebunden; Kopfkommentar nennt Fan-out-Muster und Grund"
- "apps/api/src/tenant/tenant.controller.spec.ts — NEU, Zwei-Klienten-Nachweis ueber `__makeBoundClient`, Faelle fuer alle drei zaehlenden Handler, Rollen-Metadaten-Test, Wachhund"
- "docs/mandantentrennung-zugriffsklassifikation.md — Abschnitt `Zwei belegte Befunde` (Befund 1 entschieden), Kopf der Bestandsaufnahme (Erkennungsluecke), drei Bestandsaufnahme-Zeilen (zwei fortgeschrieben, eine NEU: `tenant.controller.ts`/`user`), Uebersichtszeile, Summenzeile, Klassen-Verteilung (64 Paare, mit Stand-Vermerk), Hintergrunddienst-Abschnitt (kein sechster Fall, gemessen), Abschnitt `Was diese Etappe NICHT entscheidet` (erster Punkt aufgeloest)"
- "docs/anleitung-entwicklung.md — Abschnitt `## Mandantentrennung`: Guard-Beschreibung ohne Anfrageobjekt-Klient, Middleware-Hinweiskasten ersetzt durch einen Satz zur Entfernung"
key_links:
- "`TenantGuard` ist als zweiter `APP_GUARD` in `app.module.ts` registriert (nach `JwtAuthGuard`, vor `RolesGuard`); die Reihenfolge ist tragend, weil `req.user` erst nach dem JWT-Guard existiert. Die Middleware war NIE registriert (kein `MiddlewareConsumer`, kein `configure(` im gesamten `apps/api`) — der Hinweis im Auftrag, `app.module.ts` verdrahte die Middleware, ist falsch: Zeile 57 verdrahtet den Guard."
- "Prisma 6.19 uebersetzt `include: { _count: { select: { users: true } } }` in EINE SQL-Anweisung: `SELECT ... FROM \"Tenant\" LEFT JOIN (SELECT \"tenantId\", COUNT(*) FROM \"User\" ... GROUP BY \"tenantId\") ...` (gemessen mit Abfrageprotokoll, Befund F). Der Zaehler laeuft damit unter der Regel von `User` — ungebunden nach dem Scharfschalten null fuer jeden Mandanten."
- "Die referentielle Pruefung des Fremdschluessels `User_tenantId_fkey` (`ON DELETE RESTRICT`, 20260618112124:105) laeuft mit den Rechten des Tabelleneigentuemers und umgeht den Zeilenschutz — deshalb faengt sie ein Loeschen, das der vakuum gewordene Riegel durchliesse, laut ab (Pruefung 7 misst das, statt es der Dokumentation zu glauben)."
- "Das Fan-out-Muster: `this.prisma.tenant.findMany` ungebunden (Tenant ohne Regel, gemessen) als Schleifentreiber, dann je Mandant `const tenantPrisma = forTenant(this.prisma, tenant.id) as any; tenantPrisma.user.count({ where: { tenantId: tenant.id } })` — wortgleich die Form von `UserService.findAllForPlatformAdmin`. Das ausdrueckliche `where: { tenantId }` ist HEUTE (BYPASSRLS) der einzige Filter und bleibt."
- "Der SUPER_ADMIN-Kopfwechsel `x-tenant-id` ist NICHT tot: `apps/web/src/app/(portal)/marketplace/page.tsx` und `marketplace/[slug]/page.tsx` senden ihn (vier Stellen). Er bleibt im Guard und wird durch Tests festgenagelt — auch der Fall, dass ein Nicht-SUPER_ADMIN den Header schickt und ignoriert wird (T-04-03)."
---
<!-- planner-discipline-allow: .tenantPrisma -->
<!-- planner-discipline-allow: \.tenantPrisma -->
<!-- planner-discipline-allow: req.tenantPrisma -->
<!-- planner-discipline-allow: TenantMiddleware -->
<!-- planner-discipline-allow: forTenant -->
<!-- planner-discipline-allow: _count -->
<!-- planner-discipline-allow: this.prisma.user -->
<!-- planner-discipline-allow: this\.prisma\.user -->
<!-- planner-discipline-allow: PrismaService -->
<objective>
Etappe 2 der Mandantentrennung, zehnter Bereich: `tenant`. Acht Zugriffe in
zwei Dateien, beide auf die Mandantentabelle selbst — die einzige Tabelle, die
per Definition keine Mandantenkennung tragen kann. Auf ihr ist nichts zu
binden, und dieser Plan misst das, statt es der Klassifikation zu glauben.
Zweck: dieser Bereich traegt zwei Dinge, die jeder der neun Bereiche davor
vertagt oder uebersehen hat. Erstens die seit Etappe 1 offene Frage, ob der
gebundene Klient auf dem Anfrageobjekt (gesetzt von Guard und Middleware, von
niemandem gelesen) bleibt oder geht — nach neun Bereichen, die alle
dienst-intern binden, ist die Konvention durch Praxis entschieden, und tote
Verdrahtung, die wie ein Sicherheitsmechanismus AUSSIEHT, ist schlimmer als
keine. Zweitens einen Befund, den die Erwartung "null Umstellungsarbeit"
verdeckt haette: drei der acht Zugriffe zaehlen ueber den Relationszaehler in
die geschuetzte Tabelle `User` hinein. Nach dem Scharfschalten saehe der
Plattform-Administrator eine Mandantenliste, in der JEDER Mandant null
Benutzer hat, und der Loeschriegel "keine aktiven Benutzer" wuerde vakuum. Die
maschinelle Bestandsaufnahme kann das strukturell nicht sehen — auch das wird
hier benannt und vermessen.
Ergebnis: eine Entscheidung mit Grund an vier Stellen, eine geloeschte
Middleware, ein Guard ohne Prisma, zwei aus dem Nichts angelegte Testdateien,
drei gebundene Zaehler im Controller nach dem Fan-out-Muster des Bereichs
`user`, eine gemessene Kritikschrift, und Dokumente, die am Ende nachweislich
mit dem Quelltext uebereinstimmen.
**Der Schalter bleibt AUS.** `DATABASE_URL` zeigt weiterhin auf die Rolle
`tessera` mit `BYPASSRLS`. Schema und Migrationen werden NICHT angefasst,
`Tenant` bekommt KEINE Regel. Nichts wird in Active Directory geaendert.
</objective>
<execution_context>
@~/.claude/gsd-core/workflows/execute-plan.md
@~/.claude/gsd-core/templates/summary.md
</execution_context>
<context>
@.planning/STATE.md
@docs/mandantentrennung-zugriffsklassifikation.md
@docs/mandantentrennung-etappe2-fehlerrichtung.md
@docs/anleitung-entwicklung.md
@apps/api/prisma/migrations/20260618112124_auth_multi_tenancy/migration.sql
@apps/api/prisma/migrations/20260618112133_rls_policies/migration.sql
@apps/api/prisma/migrations/20260910120000_rls_widen_membership_grant_and_platform_read/migration.sql
@apps/api/src/prisma/prisma-tenant.extension.ts
@apps/api/src/prisma/rls-access-inventory.spec.ts
@apps/api/scripts/rls-scratch-check.mjs
@apps/api/src/tenant/tenant.guard.ts
@apps/api/src/tenant/tenant.middleware.ts
@apps/api/src/tenant/tenant.controller.ts
@apps/api/src/tenant/tenant.service.ts
@apps/api/src/tenant/tenant.service.spec.ts
@apps/api/src/tenant/tenant.module.ts
@apps/api/src/app.module.ts
@apps/api/src/auth/guards/roles.guard.ts
@apps/api/src/auth/decorators/roles.decorator.ts
@apps/api/src/user/user.service.ts
@apps/api/src/dkv/dkv.service.spec.ts
@apps/api/src/dashboard/dashboard.service.spec.ts
@apps/api/src/module-registry/module.guard.spec.ts
@.planning/quick/260911-cwh-mandantentrennung-etappe-2-bereich-calen/260911-cwh-PLAN.md
</context>
<planning_time_findings>
Alle Zahlen unten sind zur Planungszeit am 2026-09-11 gegen HEAD `f1017fa`
GEMESSEN, mit der jeweils angegebenen Anweisung. Sie leiten die Untersuchung,
sie sind KEINE Bearbeitungsvollmacht — jede Datei wird vor jeder Aenderung
erneut gelesen, jede Zahl zur Ausfuehrungszeit erneut gemessen, und weicht
eine Messung ab, gilt die Messung und nicht dieser Plan. Baseline zur
Planungszeit selbst nachgemessen: 883 Tests in 57 Dateien gruen, Werkzeug
101/101 in etwa neun Sekunden.
**Befund A — die Zahl haelt, ein Modell, zwei Dateien; die Klassifikation der
Tabelle stimmt.** Gemessen mit
`grep -ro "this\.prisma\.[a-zA-Z]*" apps/api/src/tenant | grep -v spec`:
acht Treffer, alle auf `tenant` — vier in `tenant.controller.ts`
(`findAll`: `findMany`, `findOne`: `findUnique`, `remove`: `findUnique` und
`delete`), vier in `tenant.service.ts` (`findAll`, `findById`, `create`,
`update`). `model Tenant` in `schema.prisma` hat keine `tenantId`-Spalte.
Gemessen ueber alle 34 Migrationsverzeichnisse mit
`grep -rhoiE 'ALTER TABLE "[A-Za-z]+" (ENABLE|FORCE) ROW LEVEL SECURITY' apps/api/prisma/migrations --include=*.sql | sort | uniq -c`:
23 Tabellen mit Zeilenschutz, `Tenant` ist NICHT darunter; mit
`grep -rniE 'POLICY|ROW LEVEL|GRANT|REVOKE|FORCE' apps/api/prisma/migrations --include=*.sql | grep -iE '"Tenant"'`:
genau ein Treffer, und das ist die Fremdschluessel-Definition von
`ModuleGrant`, keine Regel. `grep -n '"Tenant"' apps/api/prisma/migrations/20260910120000_rls_widen_membership_grant_and_platform_read/migration.sql`:
null Treffer. `"Tenant"` kommt ueberhaupt nur in zwei Migrationen vor
(`20260618112124` mit `CREATE TABLE`, `20260804130130` als Fremdschluesselziel);
es gibt KEIN `ALTER TABLE "Tenant"` — der Spaltensatz aus `CREATE TABLE` ist
der ausgelieferte Stand (sechs Spalten: `id`, `name`, `slug`, `isActive`,
`createdAt`, `updatedAt`). Die beiden Bestandsaufnahme-Zeilen
(`keine-mandantengebundene-tabelle`, `ungebunden`) stimmen mit dem Quelltext
ueberein. Aufgabe 1 prueft das zur Laufzeit im Werkzeug (Pruefung 1), damit
die Aussage nicht an diesem Absatz haengt.
**Befund B — die Anfrageobjekt-Eigenschaft hat KEINEN Leser, und die
Reichweite der Suche steht hier.** Suchumfang:
`grep -rn "tenantPrisma" apps/api --include=*.ts --include=*.mjs --include=*.js`
(ohne `node_modules`, `dist/` ist gitignored und Build-Ausgabe): 30 Dateien
unter `apps/api/src`. JEDER Treffer ausserhalb von `tenant/` ist eine lokale
Zuweisung `const tenantPrisma = forTenant(` mit anschliessendem
`tenantPrisma.<Modell>` — die dienst-interne Konvention. Property-Zugriffe der
Form `<etwas>.tenantPrisma` (gemessen mit `grep -rn '\.tenantPrisma' apps/api/src --include=*.ts`)
gibt es genau sieben: die vier Zuweisungen in `tenant.middleware.ts:44,48`
und `tenant.guard.ts:41,44`, und drei KOMMENTARE (`tenant.guard.ts:14`,
`app.module.ts:57`, `rls-access-inventory.spec.ts:47`). Kein Lesezugriff.
`apps/web/src` und `packages`: null Treffer. Zusatzbefund zu Fehler 6 des
Vorhabens: der Etappe-1-Befund spricht von "diesen beiden Dateien und ihren
Tests" — es GIBT keine Tests fuer Guard oder Middleware
(`ls apps/api/src/tenant/`: einzige Testdatei ist `tenant.service.spec.ts`,
zwei Faelle, beide zu `create`; `grep -rln "TenantGuard\|TenantMiddleware" apps/api/src | grep spec`:
nur `rls-access-inventory.spec.ts`, das die beiden Dateien in einer
Ausnahmeliste fuehrt). Die Auflage "Tests anpassen, nicht loeschen" trifft
deshalb auf nichts — die Tests muessen ANGELEGT werden (Befund I).
**Befund C — die Middleware ist NICHT verdrahtet, und der Auftrag irrt an
dieser Stelle.** `grep -rn "TenantMiddleware\|MiddlewareConsumer\|configure(" apps/api --include=*.ts --include=*.js --include=*.mjs --include=*.json -l | grep -v node_modules | grep -v /dist/`:
drei Dateien — die Klasse selbst und zwei Kommentare
(`module.guard.ts:25` "via TenantMiddleware/JwtAuthGuard",
`dkv.controller.ts:33` "set by TenantMiddleware"). `app.module.ts`
implementiert kein `NestModule`, hat kein `configure`, importiert die
Middleware nicht; Zeile 57 ist ein Kommentar ueber den GUARD, Zeile 58-61
registriert `TenantGuard` als `APP_GUARD`. Der Kopfkommentar des Guards
erklaert es selbst: "middleware ran too early to access it" — die Middleware
ist der ueberholte erste Entwurf aus Phase 02, durch den Guard ersetzt und
liegen geblieben. `docs/anleitung-entwicklung.md:304-307` haelt das bereits
als Hinweiskasten fest ("nirgends ueber `.apply(...).forRoutes(...)`
eingebunden"). ENTSCHEIDUNG dieses Plans: die Middleware wird GELOESCHT
(eine nie aufgerufene Kopie des Guards mit identischer Logik ist tote
Verdrahtung in Reinform), der Guard verliert die Anfrageobjekt-Eigenschaft
und die Prisma-Abhaengigkeit. Grund: neun Bereiche binden dienst-intern, ein
Klient je Methode; ein je Anfrage erzeugter Klient ohne Leser ist Kosten ohne
Nutzen (eine `$extends`-Instanz je authentifizierter Anfrage — kein
Verbindungsaufbau, aber eine Zuweisung, die niemand liest) und suggeriert
einem spaeteren Leser einen Schutz, der nicht wirkt. Zur Ausfuehrungszeit
ist die Nichtverdrahtung erneut zu messen (Anweisung oben); faende die
Messung eine Verdrahtung, bleibt die Datei und verliert nur die Eigenschaft
— dann steht das im SUMMARY als Abweichung.
**Befund D — was der Guard SONST tut, ist tragend und bleibt.** Er setzt
`req.tenantId` — gelesen an 22 Stellen in 9 Dateien ausserhalb von
`src/tenant/` (gemessen mit
`grep -rc "req\.tenantId\|request\.tenantId" apps/api/src --include=*.ts | grep -v ":0" | grep -v spec | grep -v src/tenant/`:
`ldap.controller.ts` 11, `settings.controller.ts` 3, `dkv.controller.ts` 2,
`dkv.service.ts`, `groups.controller.ts`, `module-grants.controller.ts`,
`module.guard.ts`, `tender-triage.dto.ts`, `app.module.ts` je 1). Er erlaubt
SUPER_ADMIN den Mandantenwechsel per `x-tenant-id` — und das Frontend
BENUTZT ihn: `grep -rn "x-tenant-id" apps/web/src` findet vier Stellen in
`marketplace/page.tsx` und `marketplace/[slug]/page.tsx`. Er wirft
`ForbiddenException('No tenant context')` fuer mandantenlose
Nicht-SUPER_ADMIN. Fuenf Zweige: kein Nutzer (Durchlass, nichts gesetzt);
Nutzer mit Mandant ohne Header; Nicht-SUPER_ADMIN MIT Header (Header wird
ignoriert, T-04-03); SUPER_ADMIN mit Header; SUPER_ADMIN ohne Mandant und
ohne Header (`null`); mandantenloser Nicht-SUPER_ADMIN (Ausnahme). Der
`null`-Zweig ist mit heutigem Sitzungsnachweis unerreichbar (`User.tenantId`
ist `String`, nicht nullbar; `auth.service.ts:146` und `jwt.strategy.ts:32`
tragen `tenantId` immer) — er bleibt unveraendert und wird als heutiges
Verhalten getestet, nicht umgebaut (Befund L).
**Befund E — die SUPER_ADMIN-Beschraenkung ist adaequat, aber nirgends
festgenagelt.** `tenant.controller.ts:26-27`: klassenweit
`@UseGuards(RolesGuard)` und `@Roles(Role.SUPER_ADMIN)`; kein Handler traegt
ein eigenes `@Roles`. `roles.guard.ts:11-14`: `getAllAndOverride` mit
`[context.getHandler(), context.getClass()]` — Handler-Metadaten wuerden die
Klassen-Metadaten UEBERSCHREIBEN. Heute korrekt; ein spaeteres
`@Roles(Role.ADMIN)` an einem einzelnen Handler oeffnete die
Mandantenverwaltung fuer Mandanten-Admins, ohne dass irgendein Test rot
wuerde. `RolesGuard` ist zusaetzlich global als `APP_GUARD` registriert —
die klassenweite `@UseGuards`-Registrierung ist redundant, harmlos, bleibt
(n5). Aufgabe 3 nagelt die Metadaten fest.
**Befund F — der eigentliche Befund dieses Bereichs: drei Zugriffe zaehlen in
die geschuetzte Tabelle `User` hinein.** `findAll` (Zeile 40-47) und
`findOne` (65-72) laden mit `include: { _count: { select: { users: true } } }`,
`remove` (126-135) mit `users: { where: { isActive: true } }` als Zaehler.
Gemessen mit Prisma-Abfrageprotokoll gegen die lokale Entwicklungsdatenbank
(nur lesend): Prisma 6.19 erzeugt EINE Anweisung —
`SELECT "Tenant".* , COALESCE("aggr_selection_0_User"."_aggr_count_users", 0) FROM "Tenant" LEFT JOIN (SELECT "tenantId", COUNT(*) AS "_aggr_count_users" FROM "User" WHERE 1=1 GROUP BY "tenantId") AS "aggr_selection_0_User" ON ("Tenant"."id" = "aggr_selection_0_User"."tenantId")`
(fuer `remove` mit `WHERE "User"."isActive" = $1` im Unterselect). `User`
traegt die Regel `"tenantId" = current_tenant_id()` (20260618112133:10-11).
Folge nach dem Scharfschalten: ungebunden (der heutige Weg — SUPER_ADMIN
ohne Header, Controller auf `this.prisma`) liefert die Mandantenzeilen
vollstaendig (Tenant ohne Regel), aber `userCount = 0` fuer JEDEN Mandanten;
gebunden unter Mandant X liefert die Zeilen vollstaendig, den Zaehler nur
fuer X. `remove` sieht null aktive Benutzer, der Riegel T-02-09 passiert,
`tenant.delete` laeuft — und trifft den Fremdschluessel `User_tenantId_fkey`
`ON DELETE RESTRICT` (20260618112124:105): laut, SQLSTATE 23503, Prisma
P2003, HTTP 500 mit generischer Meldung statt der verstaendlichen 400. Die
referentielle Pruefung laeuft laut PostgreSQL-Dokumentation mit
Eigentuemerrechten und umgeht den Zeilenschutz — Aufgabe 1 MISST das
(Pruefung 7) statt es zu zitieren. Zur Ausfuehrungszeit an allen drei
Stellen erneut nachzulesen.
**Befund G — die Bestandsaufnahme ist an dieser Stelle strukturell blind,
und die Blindheit ist vermessen.** `rls-access-inventory.spec.ts` sammelt
(Datei, Modell)-Paare ueber `this.prisma.<Modell>` und
`<gebundener Klient>.<Modell>`; eine Relationseinbindung in eine ZWEITE
Tabelle (`include`, Relationszaehler) erzeugt kein Paar. Gemessen:
`grep -rn "_count" apps/api/src --include=*.ts | grep -v spec` ausserhalb
von `tenant/`: `groups.service.ts:66` (auf gebundenem Klient — harmlos) und
`tenders.controller.ts:405` (`groupBy` auf der plattformweiten `Tender`, kein
Relationszugriff — harmlos). `grep -rn "include:" apps/api/src --include=*.ts | grep -v spec`:
19 Stellen in 8 Dateien; alle auf gebundenen Klienten oder auf
plattformweiten Tabellen mit plattformweiten Relationen
(`tenders.controller.ts:612` bindet `sources` = `TenderSource`, D-03), mit
EINER Ausnahme neben `tenant`: `ldap-config.service.ts:309`
(`getAllActiveConfigs`, `include: { tenant, fieldMappings }`) — dort ist die
AEUSSERE Tabelle `LdapConfig` bereits geschuetzt, die Einbindung fuegt dem
bekannten Etappe-3-Fall nichts hinzu. Die gefaehrliche Auspraegung
(ungebundener aeusserer Aufruf auf UNGESCHUETZTER Tabelle, Einbindung in
GESCHUETZTE Tabelle) existiert im gesamten API-Quelltext genau einmal: in
diesem Bereich. Aufgabe 1 wiederholt beide Messungen und listet sie in (n4);
Aufgabe 3 haelt die Luecke im Kopf der Bestandsaufnahme fest. KEIN neuer
Ledger-Eintrag dafuer (Entscheidung, siehe (n4)): die einzige Auspraegung
wird in diesem Plan behoben; faende die Wiederholung zur Ausfuehrungszeit
eine ZWEITE, ist ein Ledger-Eintrag ueber `gsd-tools windows append`
anzulegen, und das steht dann im SUMMARY.
**Befund H — die Reparaturform existiert bereits im Codebestand.**
`user.service.ts:231-252` `findAllForPlatformAdmin` (260910-das): Mandanten
ungebunden lesen (`this.prisma.tenant.findMany`, "DARF DAS: Tenant traegt
keinen Zeilenschutz"), dann je Mandant
`const tenantPrisma = forTenant(this.prisma, tenant.id) as any;` und ein
gebundener Lesezugriff mit ausdruecklichem `where: { tenantId: tenant.id }`.
Genau diese Form bekommen `findAll`, `findOne` und `remove`: der
Relationszaehler faellt weg, an seine Stelle tritt je Mandant EIN gebundener
`user.count`. Das ausdrueckliche `where: { tenantId }` ist HEUTE (Rolle mit
`BYPASSRLS`) der einzige wirksame Filter und bleibt. Der Controller greift
weiterhin direkt auf Prisma zu — das wird NICHT in den Dienst verschoben
(Scope-Disziplin laut Auftrag; `user.controller.ts` hat denselben Bau mit
fuenf `forTenant()`-Stellen). Mehrkosten: eine gebundene Zaehlabfrage je
Mandant statt eines Joins — bei einstelliger Mandantenzahl belanglos, in
(n4) festgehalten.
**Befund I — es gibt KEINE Testdatei fuer Guard, Middleware oder Controller.**
Vorlagen: `module.guard.spec.ts` (`makeContext`-Attrappe fuer
`ExecutionContext`), `dkv.service.spec.ts` (`vi.mock` des
Bindungshilfsmittels, `__makeBoundClient`, Bindungsprotokoll),
`dashboard.service.spec.ts` ab Zeile 545 (Wachhund ueber
`vi.mocked(forTenant).mock.calls.length`). Der ungebundene Nachbau in
`tenant.controller.spec.ts` bekommt KEIN `user`-Modell — ein versehentlich
ungebundener Zaehler scheitert dann mit "Cannot read properties of undefined"
(die `dkv`-Form der Falsifizierung), nicht mit einer falschen Zahl.
**Befund J — kein Hintergrunddienst, kein Roh-SQL, keine Transaktion.**
`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`:
je null Treffer. `TenantService.create` ruft `groupsService.ensureDefaultGroup(tenant.id)`
auf — der laeuft seit 260909-jts ueber `withTenantTransaction()`, gebunden an
den soeben angelegten Mandanten; nach dem Scharfschalten funktioniert die
Mandantenanlage samt Standardgruppe. Kein sechster Fall der
Hintergrunddienst-Falle.
**Befund K — die Buchfuehrung nach diesem Plan.** Uebersichtszeile `tenant`:
heute 8/0; danach 8/3 (die vier `tenant`-Zugriffe des Controllers und die
vier des Dienstes bleiben ungebunden; drei `tenantPrisma.user.count(`
erfuellen das Muster `tenantPrisma\.[a-zA-Z]*\.`). Summenzeile: ungebunden
83 unveraendert, gebunden 159 + 3 = 162. Bestandsaufnahme: ein NEUES Paar
`tenant.controller.ts`/`user` (`muss-mandantengebunden`, `gebunden`) — 64
Paare; Klassen-Verteilung `muss-mandantengebunden` 31 → 32, Ueberschrift
63 → 64. Das Loeschen der Middleware aendert KEIN Paar (sie enthaelt weder
`this.prisma.<Modell>` noch eine `const X = forTenant(`-Zuweisung). Alle
Zahlen sind zur Ausfuehrungszeit aus den Messanweisungen des Dokuments
abzuleiten, nicht von hier abzuschreiben.
**Befund L — zwei Nebenbefunde, festgehalten statt repariert.**
`TenantService.findAll` hat null Aufrufer
(`grep -rn "tenantService\.findAll" apps/api/src --include=*.ts | grep -v spec`:
nichts) — bleibt, wird in (n4) genannt (Scope). Der `null`-Zweig des Guards
(Befund D) — bleibt, getestet als heutiges Verhalten.
**Befund M — was das Werkzeug bereits hat.** `runUserAreaChecks` legt seit
260910-das eine Wegwerf-Tabelle `"Tenant"` an (nur `id`, `slug`) und misst
`tenant-tabelle-ohne-zeilenschutz-bleibt-lesbar`; `runAuthLookupChecks` legt
`"User"` an (mit eingeschaltetem und erzwungenem Zeilenschutz und der
wortgleichen Regel; OHNE Fremdschluessel auf `"Tenant"`). Die beiden
Abschnitte nach `runCalendarAreaChecks` (`runTransactionShapeMeasurement`,
`runConcurrencyProbe`) fassen weder `"User"` noch `"Tenant"` an (gemessen:
kein Treffer nach Zeile 3189). Der neue Abschnitt darf deshalb `"Tenant"` um
die vier fehlenden Spalten erweitern und den Fremdschluessel nachruesten —
alle vorhandenen `User`-Zeilen referenzieren `TENANT-A`/`TENANT-B`, die es
gibt. `readSchemaModelFieldNames()` zaehlt Relationsfelder mit
(`users`, `ldapConfig`, `groups`, `moduleGrants` sind KEINE Spalten) — fuer
`Tenant` ist die Spaltenliste deshalb aus `CREATE TABLE "Tenant"` in
20260618112124 zu schneiden, nicht aus dem Schema.
</planning_time_findings>
<tasks>
<task type="tracer">
<name>Aufgabe 1: Die Fehlerrichtung fuer diesen Bereich MESSEN und aufschreiben — nichts auf `Tenant` zu binden, aber drei Zaehler laufen in `User` hinein</name>
<precondition>Der lokale Datenbank-Container `tessera-ctl-db-1` laeuft; `docker inspect tessera-ctl-db-1` liefert eine Adresse. Ohne ihn kann das Wegwerf-Werkzeug nichts messen und die Aufgabe ist zu stoppen, nicht zu schaetzen.</precondition>
<files>apps/api/scripts/rls-scratch-check.mjs, docs/mandantentrennung-etappe2-fehlerrichtung.md</files>
<read_first>apps/api/scripts/rls-scratch-check.mjs (Kopf, `forTenantQuery`, `extractPolicySql`, `readRlsWidenMigrationSql`, `runAuthLookupChecks` bis zur Anlage von "User", `runUserAreaChecks` VOLLSTAENDIG (Anlage von "Tenant", Pruefung `tenant-tabelle-ohne-zeilenschutz-bleibt-lesbar`, Fan-out-Pruefung), `readSchemaModelFieldNames`, `runCalendarAreaChecks` (Pruefung 8 und die Client-Pruefungen), `buildInlineExtendedClient`, `main`), apps/api/prisma/migrations/20260618112124_auth_multi_tenancy/migration.sql (CREATE TABLE "Tenant", Zeile 105 Fremdschluessel), apps/api/prisma/migrations/20260618112133_rls_policies/migration.sql (Regel auf "User"), apps/api/prisma/migrations/20260910120000_rls_widen_membership_grant_and_platform_read/migration.sql, apps/api/prisma/schema.prisma (model Tenant, model User), apps/api/src/tenant/tenant.controller.ts, apps/api/src/tenant/tenant.service.ts, apps/api/src/tenant/tenant.guard.ts, apps/api/src/tenant/tenant.middleware.ts, apps/api/src/app.module.ts, apps/api/src/auth/guards/roles.guard.ts, apps/web/src/app/(portal)/admin/tenants/page.tsx, apps/web/src/app/(portal)/marketplace/components/TenantContextSelector.tsx, docs/mandantentrennung-etappe2-fehlerrichtung.md (Abschnitte `## Bereich user` (u1)-(u4) und `## Bereich calendar` vollstaendig, dazu den Absatz zur offenen Architekturfrage in (e))</read_first>
<action>
TEIL 1 — die Messung. Erweitere `apps/api/scripts/rls-scratch-check.mjs` um
einen zwoelften Abschnitt `runTenantAreaChecks(adminUrl, scratchRoleUrl, results)`
und rufe ihn in `main()` NACH `runCalendarAreaChecks` und VOR
`runTransactionShapeMeasurement` auf. Der Abschnitt setzt auf den bereits
vorhandenen Wegwerf-Tabellen `"Tenant"` (aus `runUserAreaChecks`) und
`"User"` (aus `runAuthLookupChecks`, mit Zeilenschutz und wortgleicher Regel)
auf; keine spaetere Pruefung setzt auf seinen Aenderungen auf (Befund M).
Halte diese Reihenfolgebedingung im Kopfkommentar fest, wie `runUserAreaChecks`
es vormacht, und nenne dort in EINEM Satz, warum dieser Abschnitt nicht
"null Pruefungen" hat, obwohl auf `Tenant` nichts zu binden ist: drei der
acht Zugriffsstellen zaehlen ueber eine Relationseinbindung in `User`.
Vorbereitung ueber die Wartungsrolle: (a) `ALTER TABLE "Tenant"` um die vier
fehlenden Spalten `name`, `isActive`, `createdAt`, `updatedAt` mit Typen und
Vorgaben aus dem ausgelieferten `CREATE TABLE` (fuer `name` und `updatedAt`
brauchen die vorhandenen Zeilen einen `DEFAULT`, den die Migration nicht hat
— vermerke das im Kommentar als Abweichung, die nur das Nachruesten
betrifft); (b) den Fremdschluessel `User_tenantId_fkey` WORTGLEICH aus Zeile
105 der Migration 20260618112124 schneiden (zur Laufzeit lesen, nicht
tippen; bricht mit FEHLGESCHLAGEN ab, wenn die Zeile nicht gefunden wird)
und auf die Wegwerf-Tabelle `"User"` anwenden; (c) eine dritte
Mandantenzeile `TENANT-C` ohne Benutzer einfuegen. Alle Pruefungen laufen
unter der Rolle ohne `BYPASSRLS`; Vergleichswerte kommen ueber die
Wartungsrolle.
Mindestens neun namentlich benannte Pruefungen, jede mit einer Belegausgabe,
die die beobachteten Werte nennt:
1. `tenant-keine-regel-in-allen-ausgelieferten-migrationen` — liest zur
Laufzeit JEDE `migration.sql` unter `MIGRATIONS_DIR` und prueft, dass
keine ein `CREATE POLICY` auf `"Tenant"`, kein `ENABLE`/`FORCE ROW LEVEL
SECURITY` auf `"Tenant"` und kein `ALTER TABLE "Tenant"` enthaelt. Die
Belegausgabe nennt die Zahl der gelesenen Dateien und ausdruecklich, dass
`20260910120000_rls_widen_membership_grant_and_platform_read` darunter
ist und `"Tenant"` nicht nennt.
2. `tenant-gebunden-und-ungebunden-liefern-dieselben-zeilen` — Roh-SQL:
`forTenantQuery(prisma, 'TENANT-A', SELECT id FROM "Tenant" ORDER BY id)`,
derselbe Lesezugriff ungebunden, und derselbe ueber die Wartungsrolle —
drei identische Kennungsmengen (A, B, C). Das ist der Beleg "nichts zu
binden".
3. `tenant-wegwerftabelle-deckt-alle-spalten-des-generierten-clients` —
Spaltenmenge der Wegwerf-Tabelle aus `information_schema.columns` ist
identisch mit den Spaltennamen, die zur Laufzeit aus dem
`CREATE TABLE "Tenant"`-Block der Migration 20260618112124 geschnitten
werden (NICHT aus `readSchemaModelFieldNames`, Befund M). Steht VOR den
Client-Pruefungen; faellt sie durch, bricht der Abschnitt ab.
4. `tenant-generierter-client-zeilen-gebunden-und-ungebunden-identisch` —
`buildInlineExtendedClient(prisma, 'TENANT-A').tenant.findMany({ orderBy: { id: 'asc' } })`
und `prisma.tenant.findMany(...)` ungebunden liefern dieselben Kennungen
wie Pruefung 2.
5. `tenant-generierter-client-benutzerzaehler-ungebunden-null-fuer-jeden-mandanten`
— die tragende Belegzeile: die Abfrage, die `findAll` heute stellt
(`findMany` mit Relationszaehler ueber `users`), UNGEBUNDEN auf dem
generierten Client: jeder Zaehler ist 0, waehrend die Wartungsrolle je
Mandant (`SELECT "tenantId", count(*) FROM "User" GROUP BY 1`) fuer
TENANT-A und TENANT-B mehr als 0 zaehlt. Die Belegausgabe nennt beide
Zahlen je Mandant und sagt beim Namen: das ist die Zahl, die
`admin/tenants/page.tsx` als Benutzeranzahl anzeigen wuerde.
6. `tenant-generierter-client-benutzerzaehler-gebunden-nur-eigener-mandant`
— dieselbe Abfrage gebunden unter TENANT-A: Zaehler fuer A gleich der
Wartungszahl, fuer B gleich 0 obwohl die Wartungszahl groesser 0 ist,
fuer C gleich 0.
7. `tenant-loeschriegel-ungebunden-vakuum-fremdschluessel-faengt-laut` — die
Abfrage, die `remove` heute stellt (`findUnique` fuer TENANT-A mit Zaehler
ueber aktive Benutzer), ungebunden: Zaehler 0, obwohl die Wartungsrolle
aktive Benutzer zaehlt — der Riegel T-02-09 liesse das Loeschen durch.
Danach `prisma.tenant.delete({ where: { id: 'TENANT-A' } })` ungebunden
ueber den generierten Client: bestanden genau dann, wenn ein Fehler
geworfen wird UND die Zeile ueber die Wartungsrolle danach noch existiert;
die Belegausgabe nennt KONSTRUKTORNAME und `code` woertlich (rate das
Ergebnis nicht vorweg) und sagt, dass die referentielle Pruefung den
Zeilenschutz umgeht — sonst haette sie die unsichtbaren `User`-Zeilen
nicht sehen koennen.
8. `tenant-loeschen-ohne-benutzer-gelingt-wie-heute` — dasselbe Loeschen
fuer TENANT-C gelingt, die Zeile ist danach ueber die Wartungsrolle weg.
Das falsifiziert die Deutung "Loeschen scheitert immer".
9. `tenant-fan-out-je-mandant-gebundene-zaehlung-stimmt` — die Form, die
Aufgabe 3 einbaut: `prisma.tenant.findMany` ungebunden als Treiber, dann
je verbliebenem Mandanten
`buildInlineExtendedClient(prisma, t.id).user.count({ where: { tenantId: t.id } })`
— jede Zahl gleich der Wartungszahl des Mandanten.
Ergaenzt wird nur, gestrichen wird nicht; nenne im SUMMARY die tatsaechlich
gezaehlte Zahl, nicht diese.
TEIL 2 — die Codeaussagen, jede mit ihrer Reichweite. Fuehre die
Nachpruefungen aus den Befunden B, C, D, E, F, G und L tatsaechlich aus und
notiere jeweils die Anweisung oder Datei-und-Zeile, damit jede Aussage
widerlegbar bleibt: (B) alle Property-Zugriffe auf die Anfrageobjekt-
Eigenschaft, mit Suchumfang; (C) die Nichtverdrahtung der Middleware — die
drei Anweisungen aus Befund C, plus `apps/api/src/main.ts` lesen; (D) die
Leser von `req.tenantId` je Datei und die vier Header-Sender im Frontend;
(E) Klassen- und Handler-Metadaten des Controllers, die Reihenfolge in
`getAllAndOverride`; (F) die drei Zaehlerstellen, die Regel auf `User`, den
Fremdschluessel; (G) BEIDE Listen — alle `_count`-Stellen und alle
`include:`-Stellen — je Stelle: aeusserer Aufruf gebunden oder nicht,
aeussere Tabelle geschuetzt oder nicht, eingebundene Tabelle geschuetzt oder
nicht; (L) Aufrufer von `TenantService.findAll`. Faellt eine Nachpruefung
ANDERS aus als in den Planungsbefunden, gilt die Messung; schreibe sie auf
und benenne die Abweichung ausdruecklich. Findet (G) eine zweite gefaehrliche
Auspraegung, ist das im SUMMARY als eigener Befund zu fuehren und ein
Ledger-Eintrag anzulegen (Befund G).
TEIL 3 — die Kritikschrift. Erweitere
`docs/mandantentrennung-etappe2-fehlerrichtung.md` um einen Abschnitt
`## Bereich tenant` unmittelbar VOR `## Verweis`, in der Form der
vorhandenen Bereichsabschnitte, mit fuenf Unterabschnitten unter dem
Buchstaben `n` (von Ma-n-dant; `t` ist durch `tenders` belegt):
- `### (n1) Die Messung` — die tatsaechlich beobachtete Werkzeugausgabe
woertlich eingerueckt, die tragende Belegzeile (Pruefung 5) benannt, der
Migrationsscan mit ausdruecklicher Nennung von 20260910120000, und der
Satz, warum dieser Bereich trotz "nichts zu binden" Messungen hat. Nenne,
welche Pruefungen ueber den generierten Client laufen und warum
(Relationszaehler ist eine Client-Form, Roh-SQL sieht ihn nicht — Fehler 7
des Vorhabens).
- `### (n2) Signaltabelle je Pfad` — je Handler des Controllers eine Zeile
(`findAll`, `findOne`, `create`, `update`, `remove`), je Dienstmethode
eine (`findAll` ohne Aufrufer, `findById`, `create` samt gebundener
Standardgruppe, `update`), und eine fuer `TenantGuard` (kein
Datenbankzugriff): Verhalten HEUTE nach dem Scharfschalten ohne diesen
Plan, das konkrete Signal, und in eigener Spalte, ob das Frontend es
durchlaesst. Die `remove`-Zeile nennt die gemessene Fehlerklasse aus
Pruefung 7.
- `### (n3) Welcher Code eine falsche Zahl als Wahrheit deutet` — hier nicht
Leere, sondern eine falsche Zahl: Backend `findAll`/`findOne` liefern
`userCount: 0` ohne jedes Signal; `remove` laesst den Riegel passieren und
der Fremdschluessel antwortet laut mit der falschen Botschaft. Frontend,
namentlich mit Stelle: `admin/tenants/page.tsx` zeigt `tenant.userCount`
an (um Zeile 209), verschluckt nicht-OK-Antworten in `fetchTenants` (um
Zeile 44-55) und in `handleDelete` (um Zeile 118-133: bei nicht-OK
passiert NICHTS, der Bestaetigungsdialog bleibt) — ein 500 aus dem
Fremdschluessel sieht fuer den Administrator aus wie ein Knopf, der nicht
reagiert; `TenantContextSelector.tsx` faengt Fehler in eine leere Liste.
Zur Ausfuehrungszeit an den Dateien erneut zu pruefen.
- `### (n4) Was dieser Durchlauf bewusst nicht löst` — (a) die ENTSCHEIDUNG
zur Anfrageobjekt-Eigenschaft in einem Absatz: was gemessen wurde (kein
Leser, Suchumfang; Middleware nie verdrahtet, Anweisung), was entschieden
ist (Guard setzt nur die Mandantenkennung; Middleware geloescht; die
dienst-interne Bindung ist die Konvention), und der Grund (neun Bereiche
Praxis; Kosten ohne Nutzen; tote Verdrahtung, die wie Schutz aussieht) —
dieser Absatz ist die Stelle, auf die Aufgabe 2 und 3 verweisen; (b) die
Erkennungsluecke der Bestandsaufnahme (Befund G) mit BEIDEN Listen aus
TEIL 2 und dem Urteil je Stelle, plus die Entscheidung gegen einen
Ledger-Eintrag mit Grund; (c) der Fremdschluessel als Rueckhalt: er faengt
auch INAKTIVE Benutzer, waehrend der Riegel nur aktive zaehlt — ein
Mandant mit ausschliesslich inaktiven Benutzern ist heute wie nach dem
Plan nicht loeschbar (500 statt 400), bestehendes Verhalten, nicht dieser
Auftrag; (d) `TenantService.findAll` ohne Aufrufer; (e) der unerreichbare
`null`-Zweig des Guards; (f) der Header-Wert wird nicht gegen vorhandene
Mandanten geprueft (D-10 wie entworfen; ein erfundener Wert bindet an
einen leeren Kontext — null Zeilen, kein Leck); (g) die Mehrkosten des
Fan-outs; (h) die Etappe-4-Vorabpruefung: die Benutzerzahl je Mandant ueber
die Wartungsrolle gegen die gebundene Fan-out-Zaehlung — dieselbe Pruefung
wie im Bereich `user`, deshalb kein eigener Eintrag.
- `### (n5) Was dieser Durchlauf bewusst nicht anfasst` — der direkte
Prisma-Zugriff im Controller (Muster wie `user.controller.ts`, bleibt,
Wartbarkeitsvermerk, keine Verschiebung in den Dienst); die redundante
`@UseGuards(RolesGuard)`-Registrierung; das Frontend (nur beschrieben);
`tenant.service.ts` (unveraendert); die veraltete Tabellenliste im
Abschnitt `## Mandantentrennung` von `docs/anleitung-entwicklung.md`
("aktuell nur auf User, PasswordResetToken, ..." — seit 20260909140000
sind es 23 Tabellen; Aufgabe 3 aendert in jener Datei NUR die Absaetze zu
Guard und Middleware, die Liste bleibt und wird hier als Ungenauigkeit
festgehalten); die historische Nennung der Middleware in
`docs/mandantentrennung-datenbankrolle.md:124` (beschreibt den Stand vor
Etappe 1 korrekt); Schema und Migrationen.
Aendere in dieser Aufgabe KEINE Datei unter `apps/api/src`, KEINE unter
`apps/api/prisma` und KEINE unter `apps/web`.
</action>
<verify>
<automated>DB_IP=$(docker inspect tessera-ctl-db-1 --format '{{range $k,$v := .NetworkSettings.Networks}}{{$v.IPAddress}}{{end}}') && OUT=$(TESSERA_SCRATCH_ADMIN_URL="postgresql://tessera:tessera_dev@${DB_IP}:5432/postgres" node apps/api/scripts/rls-scratch-check.mjs) && echo "$OUT" && for K in tenant-keine-regel-in-allen-ausgelieferten-migrationen tenant-gebunden-und-ungebunden-liefern-dieselben-zeilen tenant-wegwerftabelle-deckt-alle-spalten-des-generierten-clients tenant-generierter-client-zeilen-gebunden-und-ungebunden-identisch tenant-generierter-client-benutzerzaehler-ungebunden-null-fuer-jeden-mandanten tenant-generierter-client-benutzerzaehler-gebunden-nur-eigener-mandant tenant-loeschriegel-ungebunden-vakuum-fremdschluessel-faengt-laut tenant-loeschen-ohne-benutzer-gelingt-wie-heute tenant-fan-out-je-mandant-gebundene-zaehlung-stimmt; do echo "$OUT" | grep -q "^$K: bestanden" || { echo "FEHLENDE ODER FEHLGESCHLAGENE PRUEFUNG: $K"; exit 1; }; done && echo "$OUT" | grep -qE '^Alle [0-9]+ Pruefungen bestanden\.$' && N=$(echo "$OUT" | sed -nE 's/^Alle ([0-9]+) Pruefungen bestanden\.$/\1/p') && { test "$N" -ge 110 || { echo "PRUEFUNGSZAHL: $N, erwartet mindestens 110 (101 bisherige plus mindestens 9 neue)"; exit 1; }; } && grep -q 'runTenantAreaChecks' apps/api/scripts/rls-scratch-check.mjs && awk '/await runCalendarAreaChecks\(/{c=NR} /await runTenantAreaChecks\(/{n=NR} /await runTransactionShapeMeasurement\(/{t=NR} END{ if(!(c&&n&&t&&c<n&&n<t)){print "REIHENFOLGE in main(): runTenantAreaChecks muss nach runCalendarAreaChecks und vor runTransactionShapeMeasurement stehen"; exit 1} }' apps/api/scripts/rls-scratch-check.mjs && grep -q 'User_tenantId_fkey' apps/api/scripts/rls-scratch-check.mjs && grep -q '^## Bereich tenant$' docs/mandantentrennung-etappe2-fehlerrichtung.md && for S in n1 n2 n3 n4 n5; do grep -qE "^### \($S\) " docs/mandantentrennung-etappe2-fehlerrichtung.md || { echo "FEHLENDER UNTERABSCHNITT: ($S)"; exit 1; }; done && awk '/^## Bereich tenant$/{f=1; next} /^## /{f=0} f && /20260910120000/{r=1} f && /admin\/tenants\/page\.tsx/{p=1} f && /TenantContextSelector/{s=1} f && /_count/{c=1} f && /User_tenantId_fkey/{k=1} f && /include:/{i=1} f && /findAllForPlatformAdmin/{h=1} f && /MiddlewareConsumer|configure\(/{m=1} END{ if(!r){print "(n1): der Migrationsstand einschliesslich 20260910120000 ist nicht benannt"; exit 1} if(!p||!s){print "(n3): die beiden Frontend-Dateien sind nicht namentlich genannt"; exit 1} if(!c){print "(n1)/(n2): der Relationszaehler ist nicht benannt"; exit 1} if(!k){print "(n2)/(n4): der Fremdschluessel als Rueckhalt ist nicht benannt"; exit 1} if(!i){print "(n4): die Erkennungsluecke fuer Relationseinbindungen ist nicht mit ihrer Liste benannt"; exit 1} if(!h){print "(n4): das Fan-out-Muster aus dem Bereich user ist nicht benannt"; exit 1} if(!m){print "(n4)(a): die Anweisung, mit der die Nichtverdrahtung der Middleware gemessen wurde, fehlt"; exit 1} }' docs/mandantentrennung-etappe2-fehlerrichtung.md && TOUT=$(npm --prefix apps/api run test 2>&1) && echo "$TOUT" | tail -6 && T=$(echo "$TOUT" | sed -nE 's/^ *Tests +([0-9]+) passed.*/\1/p' | head -1) && { test -n "$T" && test "$T" -ge 883 || { echo "TESTZAHL: ${T:-unbekannt}, erwartet mindestens 883"; exit 1; }; } && npm --prefix apps/api run type-check && git rev-parse --verify f1017fa >/dev/null && PRISMA_CHANGED=$(git diff --name-only f1017fa -- apps/api/prisma) && { test -z "$PRISMA_CHANGED" || { printf 'SCHEMA/MIGRATION GEAENDERT — in diesem Plan verboten:\n%s\n' "$PRISMA_CHANGED"; exit 1; }; } && CHANGED=$(git diff --name-only f1017fa) && UNEXPECTED=$(printf '%s\n' "$CHANGED" | grep -v '^$' | grep -vE '^(apps/api/scripts/rls-scratch-check\.mjs|docs/mandantentrennung-etappe2-fehlerrichtung\.md|\.planning/.*)$' || true) && { test -z "$UNEXPECTED" || { printf 'UNERWARTETE AENDERUNGEN AUSSERHALB DES PLANUMFANGS:\n%s\n' "$UNEXPECTED"; exit 1; }; }</automated>
</verify>
<done>`apps/api/scripts/rls-scratch-check.mjs` hat einen zwoelften Abschnitt `runTenantAreaChecks` in der richtigen Reihenfolge mit mindestens neun neuen, namentlich benannten Pruefungen, davon fuenf ueber den generierten Client an einer Wegwerf-Tabelle, deren Spaltenmenge zur Laufzeit gegen das ausgelieferte `CREATE TABLE` geprueft wird, mit dem wortgleich aus der Migration geschnittenen Fremdschluessel; der Migrationsscan laeuft zur Laufzeit; alle Pruefungen des Werkzeugs bestehen (mindestens 110). `docs/mandantentrennung-etappe2-fehlerrichtung.md` hat einen Abschnitt `## Bereich tenant` mit (n1) bis (n5), die tatsaechlich beobachtete Werkzeugausgabe woertlich, die Entscheidung zur Anfrageobjekt-Eigenschaft mit Messung und Grund, beide Listen der Erkennungsluecke mit Urteil je Stelle, die Frontend-Stellen namentlich. Baseline gehalten: mindestens 883 Tests gruen, Typpruefung sauber. Unter `apps/api/src`, `apps/api/prisma`, `apps/web` und den Compose-/Umgebungsdateien ist nichts geaendert.</done>
</task>
<task type="auto" tdd="true">
<name>Aufgabe 2: Die Entscheidung umsetzen — Guard setzt nur noch die Mandantenkennung, die nie verdrahtete Middleware geht, die Testlage fuer den Guard entsteht, drei Kommentare hoeren auf zu luegen</name>
<files>apps/api/src/tenant/tenant.guard.ts, apps/api/src/tenant/tenant.guard.spec.ts, apps/api/src/tenant/tenant.middleware.ts, apps/api/src/prisma/rls-access-inventory.spec.ts, apps/api/src/app.module.ts, apps/api/src/module-registry/module.guard.ts, apps/api/src/dkv/dkv.controller.ts</files>
<read_first>apps/api/src/tenant/tenant.guard.ts, apps/api/src/tenant/tenant.middleware.ts, apps/api/src/app.module.ts, apps/api/src/module-registry/module.guard.spec.ts (`makeContext`), apps/api/src/module-registry/module.guard.ts (Zeile 25), apps/api/src/dkv/dkv.controller.ts (Zeile 33), apps/api/src/prisma/rls-access-inventory.spec.ts (Kopf, `FORTENANT_ASSIGNMENT_EXCEPTIONS`, die Pruefung "jedes forTenant(-Vorkommen entspricht der erkannten Zuweisungsform"), docs/mandantentrennung-etappe2-fehlerrichtung.md (Abschnitt (n4)(a) aus Aufgabe 1), apps/api/src/auth/strategies/jwt.strategy.ts (`validate`)</read_first>
<behavior>
ZUERST die Testlage, DANN der Umbau. Lege `apps/api/src/tenant/tenant.guard.spec.ts`
NEU an, in der Form von `module.guard.spec.ts` (`makeContext(request)` mit
`switchToHttp().getRequest()`); der Guard wird OHNE Argumente konstruiert —
das ist zugleich die Typpruefungs-Aussage, dass er keine Prisma-Abhaengigkeit
mehr hat. Jeder Fall eigenstaendig, jeder mit sprechendem Namen:
- Kein `req.user` (oeffentliche Route): `canActivate` liefert `true`, und
weder `tenantId` noch die alte Anfrageobjekt-Eigenschaft sind als
Schluessel auf dem Anfrageobjekt vorhanden (`'tenantId' in req` ist
falsch).
- Nutzer der Rolle USER mit `tenantId = 't1'`, keine Kopfzeile: `true`,
`req.tenantId === 't1'`.
- Nutzer der Rolle ADMIN mit `tenantId = 't1'` UND Kopfzeile
`x-tenant-id: 't2'`: die Kopfzeile wird IGNORIERT, `req.tenantId === 't1'`
— die T-04-03-Zusicherung, erstmals festgenagelt.
- SUPER_ADMIN mit `tenantId = 't1'` und Kopfzeile `'t2'`:
`req.tenantId === 't2'` (D-10, vom Marktplatz-Frontend benutzt).
- SUPER_ADMIN mit `tenantId = 't1'` ohne Kopfzeile: `req.tenantId === 't1'`.
- SUPER_ADMIN ohne `tenantId` und ohne Kopfzeile: `true`,
`req.tenantId === null` (heutiges Verhalten, Befund D/L — mit dem heutigen
Sitzungsnachweis unerreichbar, trotzdem festgenagelt, damit eine Aenderung
sichtbar wird).
- Nutzer der Rolle USER ohne `tenantId`: wirft
`ForbiddenException('No tenant context')`.
- In JEDEM durchlassenden Fall: der Schluessel der alten
Anfrageobjekt-Eigenschaft ist auf dem Anfrageobjekt NICHT vorhanden
(pruefe ueber den `in`-Operator mit dem Namen als Zeichenkette, nicht ueber
einen Property-Zugriff mit Punkt — das Gate dieser Aufgabe zaehlt den
Punkt-Zugriff im gesamten `apps/api/src` auf null, Kommentare
eingeschlossen).
Die Zahl der Faelle wird am Ende ABGEZAEHLT und im SUMMARY mit der gezaehlten
Zahl genannt.
</behavior>
<action>
Bevor du aenderst: wiederhole die Messung aus Befund C (drei Anweisungen,
dazu `apps/api/src/main.ts` lesen). Ist die Middleware nirgends verdrahtet,
LOESCHE `apps/api/src/tenant/tenant.middleware.ts` (`git rm`). Waere sie
verdrahtet — dann steht das im SUMMARY als Abweichung, die Datei bleibt und
verliert wie der Guard nur die Anfrageobjekt-Eigenschaft und die
Prisma-Abhaengigkeit; der Rest dieser Aufgabe gilt dann sinngemaess fuer
beide Dateien.
Baue `apps/api/src/tenant/tenant.guard.ts` um: entferne die beiden
Zuweisungen der Anfrageobjekt-Eigenschaft (Zeile 41 und 44), den Import des
Bindungshilfsmittels, den Import und die Konstruktor-Injektion von
`PrismaService` (der Guard hat danach KEINEN Konstruktor mit Parametern). Die
Zuweisungen von `req.tenantId` in BEIDEN Zweigen, der SUPER_ADMIN-Wechsel per
`x-tenant-id`, die `ForbiddenException('No tenant context')` und der
Durchlass ohne Nutzer bleiben WORTGLEICH. Schreibe den Kopfkommentar neu: er
nennt, dass der Guard ausschliesslich `req.tenantId` setzt, dass die
Bindung an den Mandanten dienst-intern je Methode ueber das
Bindungshilfsmittel aus `prisma-tenant.extension.ts` geschieht (Konvention
aller umgestellten Bereiche der Etappe 2), dass ein frueherer Entwurf einen
gebundenen Klienten auf dem Anfrageobjekt veroeffentlichte, den nie jemand
las (entfernt mit 260911-e2s, Messung und Grund in
`docs/mandantentrennung-etappe2-fehlerrichtung.md`, (n4)(a)), und dass die
Reihenfolge nach `JwtAuthGuard` tragend bleibt. Der Kommentar darf die alte
Eigenschaft NICHT mit Punkt-Zugriff benennen (siehe Behavior, letzter Punkt)
und den Namen des Bindungshilfsmittels nicht in Codezeilen fuehren — im
Kommentar ist er erlaubt, das Gate filtert Kommentarzeilen.
Leere in `apps/api/src/prisma/rls-access-inventory.spec.ts` die Ausnahmeliste
`FORTENANT_ASSIGNMENT_EXCEPTIONS` (`new Set<string>([])`) und schreibe ihren
Kommentar fort: was die Ausnahme war (zwei Dateien veroeffentlichten den
gebundenen Klienten auf dem Anfrageobjekt statt ihn einer lokalen Konstante
zuzuweisen), dass die Architekturfrage mit 260911-e2s ENTSCHIEDEN ist
(dienst-interne Bindung, Eigenschaft entfernt, Middleware geloescht) und dass
die Liste deshalb leer startet und leer bleibt, bis ein begruendeter neuer
Ausnahmefall auftritt — die Form, die `INTERACTIVE_TRANSACTION_EXCEPTIONS`
bereits hat. Ohne Punkt-Zugriff auf den alten Namen. Ergaenze EINEN neuen
Testfall: jede in `FORTENANT_ASSIGNMENT_EXCEPTIONS` gefuehrte Datei muss
existieren UND tatsaechlich mindestens einen Aufruf ausserhalb der
Zuweisungsform haben — eine Ausnahmeliste, die Dateien nennt, die es nicht
gibt oder die keinen Ausnahmefall mehr enthalten, ist dieselbe tote
Verdrahtung, die diese Aufgabe im Guard entfernt.
Berichtige in drei Fremddateien je EINE Kommentarzeile, sonst nichts:
`apps/api/src/app.module.ts:57` (nennt heute beide Eigenschaften — nur noch
`req.tenantId`), `apps/api/src/module-registry/module.guard.ts:25` und
`apps/api/src/dkv/dkv.controller.ts:33` (nennen die nie verdrahtete
Middleware — es ist der Guard). Keine Codezeile in diesen drei Dateien darf
sich aendern; das Gate prueft die Diffs auf Kommentarzeilen.
Falsifizierungsnachweis am Ende dieser Aufgabe: setze im Guard probeweise
die alte Anfrageobjekt-Eigenschaft in EINEM Zweig wieder (irgendein Wert
genuegt), ueberzeuge dich, dass `tenant.guard.spec.ts` rot wird, notiere
Testname und Fehlermeldung woertlich, und stelle den Zustand wieder her.
Aendere keine Datei ausserhalb der sieben genannten.
</action>
<verify>
<automated>test ! -e apps/api/src/tenant/tenant.middleware.ts && test -f apps/api/src/tenant/tenant.guard.spec.ts && npm --prefix apps/api run test -- src/tenant/tenant.guard.spec.ts && npm --prefix apps/api run test -- src/prisma/rls-access-inventory.spec.ts && TOUT=$(npm --prefix apps/api run test 2>&1) && echo "$TOUT" | tail -6 && T=$(echo "$TOUT" | sed -nE 's/^ *Tests +([0-9]+) passed.*/\1/p' | head -1) && { test -n "$T" && test "$T" -gt 883 || { echo "TESTZAHL: ${T:-unbekannt}, erwartet mehr als 883 (neue Guard-Faelle)"; exit 1; }; } && npm --prefix apps/api run type-check && test 0 -eq "$(grep -rn '\.tenantPrisma' apps/api/src --include=*.ts | wc -l | tr -d ' ')" && test 0 -eq "$(grep -rn 'TenantMiddleware' apps/api/src --include=*.ts | wc -l | tr -d ' ')" && test 2 -eq "$(grep -c 'req.tenantId = ' apps/api/src/tenant/tenant.guard.ts)" && test 0 -eq "$(grep -vE '^\s*(//|\*|/\*)' apps/api/src/tenant/tenant.guard.ts | grep -c 'forTenant')" && test 0 -eq "$(grep -vE '^\s*(//|\*|/\*)' apps/api/src/tenant/tenant.guard.ts | grep -c 'PrismaService')" && grep -q 'x-tenant-id' apps/api/src/tenant/tenant.guard.ts && grep -q 'No tenant context' apps/api/src/tenant/tenant.guard.ts && grep -q '260911-e2s' apps/api/src/tenant/tenant.guard.ts && test 0 -eq "$(awk '/const FORTENANT_ASSIGNMENT_EXCEPTIONS/,/\]\)/' apps/api/src/prisma/rls-access-inventory.spec.ts | grep -c 'apps/api/src')" && grep -q '260911-e2s' apps/api/src/prisma/rls-access-inventory.spec.ts && grep -q "FORTENANT_ASSIGNMENT_EXCEPTIONS\]" apps/api/src/prisma/rls-access-inventory.spec.ts && grep -q "tenantPrisma' in" apps/api/src/tenant/tenant.guard.spec.ts && grep -q 'No tenant context' apps/api/src/tenant/tenant.guard.spec.ts && grep -q 'x-tenant-id' apps/api/src/tenant/tenant.guard.spec.ts && grep -q 'new TenantGuard()' apps/api/src/tenant/tenant.guard.spec.ts && awk '/useClass: JwtAuthGuard/{j=NR} /useClass: TenantGuard/{t=NR} /useClass: RolesGuard/{r=NR} END{ if(!(j&&t&&r&&j<t&&t<r)){print "app.module.ts: Reihenfolge JwtAuthGuard -> TenantGuard -> RolesGuard verletzt"; exit 1} }' apps/api/src/app.module.ts && git rev-parse --verify f1017fa >/dev/null && for F in apps/api/src/app.module.ts apps/api/src/module-registry/module.guard.ts apps/api/src/dkv/dkv.controller.ts; do FDIFF=$(git diff f1017fa -- "$F") || { echo "git diff fuer $F fehlgeschlagen"; exit 1; }; NONCOMMENT=$(printf '%s\n' "$FDIFF" | grep -E '^[+-]' | grep -vE '^(\+\+\+|---)' | grep -vE '^[+-]\s*(//|\*|/\*)' || true); test -z "$NONCOMMENT" || { printf 'NICHT-KOMMENTAR-AENDERUNG in %s (nur Kommentarzeilen erlaubt):\n%s\n' "$F" "$NONCOMMENT"; exit 1; }; done && PRISMA_CHANGED=$(git diff --name-only f1017fa -- apps/api/prisma) && { test -z "$PRISMA_CHANGED" || { printf 'SCHEMA/MIGRATION GEAENDERT — in diesem Plan verboten:\n%s\n' "$PRISMA_CHANGED"; exit 1; }; } && CHANGED=$(git diff --name-only f1017fa) && UNEXPECTED=$(printf '%s\n' "$CHANGED" | grep -v '^$' | grep -vE '^(apps/api/scripts/rls-scratch-check\.mjs|docs/mandantentrennung-etappe2-fehlerrichtung\.md|apps/api/src/tenant/tenant\.guard\.ts|apps/api/src/tenant/tenant\.guard\.spec\.ts|apps/api/src/tenant/tenant\.middleware\.ts|apps/api/src/prisma/rls-access-inventory\.spec\.ts|apps/api/src/app\.module\.ts|apps/api/src/module-registry/module\.guard\.ts|apps/api/src/dkv/dkv\.controller\.ts|\.planning/.*)$' || true) && { test -z "$UNEXPECTED" || { printf 'UNERWARTETE AENDERUNGEN AUSSERHALB DES PLANUMFANGS:\n%s\n' "$UNEXPECTED"; exit 1; }; }</automated>
</verify>
<done>`tenant.middleware.ts` existiert nicht mehr (Nichtverdrahtung zur Ausfuehrungszeit erneut gemessen). `tenant.guard.ts` setzt in beiden Zweigen nur noch `req.tenantId`, hat keine Prisma-Abhaengigkeit und keinen Aufruf des Bindungshilfsmittels mehr, der Kopfkommentar nennt Entscheidung und Grund; `x-tenant-id`-Wechsel und `ForbiddenException` bleiben wortgleich. `tenant.guard.spec.ts` existiert neu und deckt jeden in `<behavior>` genannten Fall ab, einschliesslich der Abwesenheit der alten Eigenschaft in jedem Durchlass-Fall. Im gesamten `apps/api/src` gibt es keinen Punkt-Zugriff auf die alte Eigenschaft und keine Nennung der Middleware mehr, auch nicht in Kommentaren. Die Ausnahmeliste in `rls-access-inventory.spec.ts` ist leer, ihr Kommentar nennt die Entscheidung, ein neuer Testfall verhindert veraltete Eintraege. Die drei Fremddateien sind ausschliesslich in Kommentarzeilen geaendert. Der Falsifizierungsnachweis ist durchgefuehrt, zurueckgenommen und mit Testname und Fehlermeldung notiert. Baseline gehalten, Testzahl gestiegen.</done>
</task>
<task type="auto" tdd="true">
<name>Aufgabe 3: Die drei Zaehler binden (Fan-out je Mandant), die Testlage fuer den Controller anlegen, die SUPER_ADMIN-Beschraenkung festnageln und alle Dokumentstellen nachziehen</name>
<files>apps/api/src/tenant/tenant.controller.ts, apps/api/src/tenant/tenant.controller.spec.ts, docs/mandantentrennung-zugriffsklassifikation.md, docs/anleitung-entwicklung.md</files>
<read_first>apps/api/src/tenant/tenant.controller.ts, apps/api/src/user/user.service.ts (`findAllForPlatformAdmin`, `findByIdForPlatformAdmin` samt Kopfkommentaren), apps/api/src/dkv/dkv.service.spec.ts (Kopf, `vi.mock`, `makeFakePrisma` mit `__makeBoundClient`, `expectBoundCall`), apps/api/src/dashboard/dashboard.service.spec.ts (Zeilen 545-570, Wachhund), apps/api/src/auth/decorators/roles.decorator.ts, apps/api/src/auth/guards/roles.guard.ts, apps/api/src/prisma/rls-access-inventory.spec.ts (`analyzeFile`, `parseDocEntries`), docs/mandantentrennung-zugriffsklassifikation.md (VOLLSTAENDIG: `Zwei belegte Befunde`, Uebersichtstabelle samt Messanweisung, Klassen-Verteilung, Hintergrunddienst-Abschnitt, Kopf und beide `tenant`-Zeilen der Bestandsaufnahme, `Was diese Etappe NICHT entscheidet`), docs/anleitung-entwicklung.md (Abschnitt `## Mandantentrennung`), docs/mandantentrennung-etappe2-fehlerrichtung.md (Abschnitt `## Bereich tenant` aus Aufgabe 1)</read_first>
<behavior>
ZUERST die Testlage, DANN der Umbau. Lege `apps/api/src/tenant/tenant.controller.spec.ts`
NEU an, in der Form von `dkv.service.spec.ts`: `vi.mock` auf das
Bindungshilfsmittel, umgeleitet auf `prisma.__makeBoundClient(tenantId)`;
ein handgerollter Prisma-Nachbau mit In-Memory-Zeilen fuer `tenant`
(`findMany`, `findUnique`, `delete`) und `user` (Zeilen mit `tenantId` und
`isActive`). Der UNGEBUNDENE Nachbau hat KEIN `user`-Modell — ein
ungebundener Zaehler scheitert mit "Cannot read properties of undefined";
der gebundene Klient (`__makeBoundClient(tenantId)`) bietet
`user.count({ where })` an, zaehlt nur Zeilen SEINES Mandanten (zusaetzlich
gefiltert nach `where.tenantId` und `where.isActive`, falls gesetzt) und
schreibt je Aufruf Mandantenkennung, Modell, Methode und `where` in ein
Bindungsprotokoll. `TenantService` ist eine Attrappe (`create`, `findById`,
`update` als `vi.fn`).
Jeder Fall eigenstaendig, jeder mit sprechendem Namen:
- `findAll` mit drei Mandanten (A: zwei Benutzer, B: ein Benutzer, C: keiner):
liefert drei Eintraege mit `userCount` 2/1/0 in der Reihenfolge des
Nachbaus; das Protokoll enthaelt genau DREI gebundene Zaehlaufrufe, je
einen unter der Kennung des jeweiligen Mandanten, jeder mit
`where.tenantId` gleich dieser Kennung; die Mandantenzeilen selbst kamen
vom ungebundenen Nachbau (`tenant.findMany` aufgerufen).
- `findAll` ohne Mandanten: leere Liste, KEIN gebundener Klient erzeugt.
- `findAll`: die Antwort traegt genau die Felder `id`, `name`, `slug`,
`isActive`, `createdAt`, `userCount` (die heutige Form bleibt).
- `findOne` mit unbekannter Kennung: `NotFoundException('Tenant not found')`,
KEIN gebundener Klient.
- `findOne` mit bekannter Kennung: `userCount` aus dem gebundenen Klienten
UNTER DIESER Kennung, `where.tenantId` gleich der Kennung.
- `remove` mit unbekannter Kennung: `NotFoundException`, kein gebundener
Klient, `tenant.delete` nicht aufgerufen.
- `remove` mit aktiven Benutzern: `BadRequestException` mit der heutigen
Meldung ("Cannot delete tenant with active users..."), `tenant.delete`
NICHT aufgerufen; der gebundene Zaehlaufruf traegt `where.isActive === true`
und `where.tenantId` gleich der Kennung.
- `remove` mit ausschliesslich INAKTIVEN Benutzern: der Riegel laesst durch
(Zaehler 0), `tenant.delete` wird ungebunden mit `{ where: { id } }`
aufgerufen, Antwort `{ message: 'Tenant deleted' }` — festgenagelt als
heutiges Verhalten; der Fremdschluessel, der das in der echten Datenbank
abfaengt, existiert im Nachbau nicht und wird hier nicht simuliert (das
misst Pruefung 7 in Aufgabe 1).
- `remove` ohne Benutzer: geloescht, Antwort wie oben.
- `create` und `update`: delegieren an die Dienst-Attrappe; weder ein
gebundener Klient noch ein Aufruf des ungebundenen Nachbaus.
- Rollen-Metadaten (Befund E): `Reflect.getMetadata(ROLES_KEY, TenantController)`
ist genau `[Role.SUPER_ADMIN]`, und fuer JEDEN der fuenf Handler
(`findAll`, `findOne`, `create`, `update`, `remove`) ist
`Reflect.getMetadata(ROLES_KEY, TenantController.prototype.<handler>)`
undefiniert — ein Handler mit eigener, schwaecherer Rolle wuerde die
Klassenrolle ueberschreiben (`getAllAndOverride`), dieser Fall wird dann
rot. Importiere `reflect-metadata` am Kopf der Testdatei.
- Wachhund: `findOne` und `remove` erzeugen je genau EINEN gebundenen
Klienten je Aufruf; `findAll` genau so viele wie Mandanten (Muster
`dashboard.service.spec.ts` Zeile 545, hier mit der erwarteten Zahl je
Handler).
Die Zahl der Faelle wird am Ende ABGEZAEHLT und im SUMMARY mit der gezaehlten
Zahl genannt.
</behavior>
<action>
TEIL 1 — der Controller. Stelle in `apps/api/src/tenant/tenant.controller.ts`
die drei Handler `findAll`, `findOne` und `remove` auf das Fan-out-Muster von
`UserService.findAllForPlatformAdmin` um: der Relationszaehler faellt weg
(kein `include` mehr auf den `tenant`-Abfragen); die `tenant`-Abfragen selbst
bleiben UNGEBUNDEN und wortgleich in ihrer Form (`findMany` mit
`orderBy: { name: 'asc' }`, `findUnique` ueber die Kennung, `delete` ueber
die Kennung) — Tenant traegt keine Regel, gemessen in Aufgabe 1. Je Handler
tritt an die Stelle des Zaehlers EIN gebundener Aufruf in der Zuweisungsform,
die `rls-access-inventory.spec.ts` erkennt und die `user.service.ts`
vormacht: Konstante `tenantPrisma` aus dem Bindungshilfsmittel mit
`this.prisma` und der Mandantenkennung, `as any`, dann `user.count` mit
ausdruecklichem `where: { tenantId }` — in `remove` zusaetzlich
`isActive: true`. In `findAll` steht die Zuweisung INNERHALB der Schleife
ueber die Mandanten (eine Aufrufstelle, ein Klient je Mandant, sequentiell
wie die Vorlage); `findOne` und `remove` binden nach dem 404-Riegel an die
Kennung aus dem Pfad — die Kennung ist hier KEINE neue Vertrauensquelle,
denn sie bezeichnet den zu verwaltenden Mandanten, nicht den des Anfragenden,
und der Anfragende ist per Klassenrolle SUPER_ADMIN. Antwortform, Meldungen
und Statuscodes bleiben unveraendert. Importiere das Bindungshilfsmittel aus
`'../prisma/prisma-tenant.extension'`.
Schreibe den Kopfkommentar der Klasse fort: warum die Zaehler gebunden
werden (Relationszaehler lief unter der Regel von `User`; nach dem
Scharfschalten null fuer jeden Mandanten, Riegel vakuum — 260911-e2s,
Aufgabe 1, Pruefungen 5-7), warum die `tenant`-Zugriffe ungebunden bleiben
(keine Regel, Pruefung 1/2), warum das ausdrueckliche `where: { tenantId }`
heute der einzige Filter ist (Rolle mit `BYPASSRLS`, WINDOWS #18), und dass
der direkte Prisma-Zugriff im Controller bewusst beibehalten wurde (Muster
`user.controller.ts`, Vermerk in (n5)). Kommentare in dieser Datei duerfen
die Zeichenfolge, mit der die Uebersichtstabelle ungebundene Zugriffe zaehlt
(siehe deren Messanweisung), NICHT ausserhalb der vier echten
`tenant`-Zugriffe enthalten — das Gate vergleicht die Rohzaehlung mit und
ohne Kommentare; und sie duerfen einen ungebundenen Zugriff auf `user`
nirgends woertlich nennen.
Falsifizierungsnachweis nach dem Umbau: ersetze in `findOne` den gebundenen
Zaehler probeweise durch einen Zaehler auf dem ungebundenen Basisclient,
ueberzeuge dich, dass `tenant.controller.spec.ts` rot wird (erwartete Form:
der Nachbau hat kein ungebundenes `user`-Modell), notiere Testname und
Fehlermeldung woertlich, und stelle den Zustand wieder her.
TEIL 2 — die Klassifikation. Ziehe `docs/mandantentrennung-zugriffsklassifikation.md`
an ALLEN handgepflegten Stellen nach, jede einzeln nachgesehen, keine
ueberflogen (Fehler 4 des Vorhabens):
1. Abschnitt `## Zwei belegte Befunde`, erster Befund: fortschreiben, nicht
loeschen — der Befund aus Etappe 1 bleibt lesbar, darunter ein Absatz
`**Entschieden (260911-e2s, Aufgabe 2):**` mit Messung (kein Leser,
Suchumfang; keine Tests fuer beide Dateien — der Satz "und ihrer Tests" im
Befund oben war unbelegt; Middleware nie verdrahtet, Anweisung),
Entscheidung (Eigenschaft entfernt, Middleware geloescht, Guard ohne
Prisma, dienst-interne Bindung ist die Konvention) und Grund, mit Verweis
auf (n4)(a). Der Abschnittstitel bleibt.
2. Kopf des Abschnitts `## Bestandsaufnahme` (der Absatz vor der Tabelle):
ein Satz zur Erkennungsluecke — die Bestandsaufnahme sieht (Datei,
Modell)-Paare, aber keine Relationseinbindung (`include`, `_count`) in
eine zweite Tabelle; vermessen in 260911-e2s (Aufgabe 1, (n4)(b)),
einzige gefaehrliche Auspraegung war `tenant.controller.ts`, in Aufgabe 3
behoben.
3. Bestandsaufnahme-Zeilen: die beiden `tenant`-Zeilen behalten Klasse und
Stand (`keine-mandantengebundene-tabelle`, `ungebunden`), ihre
Begruendung wird fortgeschrieben (Messung 260911-e2s: keine Regel in
allen Migrationen, gebunden gleich ungebunden; beim Controller
zusaetzlich: die frueheren Relationszaehler sind durch gebundene Zaehler
ersetzt, siehe neue Zeile). EINE NEUE Zeile
`| apps/api/src/tenant/tenant.controller.ts | user | muss-mandantengebunden | gebunden | ... |`
in alphabetischer Ordnung der Tabelle, Begruendung: Benutzerzaehler je
Mandant fuer die Plattform-Administratorsicht, Fan-out-Muster aus
`user.service.ts`, `where: { tenantId }` bleibt, drei Aufrufstellen.
Ohne diese Zeile ist `rls-access-inventory.spec.ts` am Ende dieser
Aufgabe rot (Lehre aus 260910-exd).
4. Uebersichtszeile `tenant` mit den NEU GEMESSENEN Zahlen aus der im
Dokument genannten Messanweisung (beide Spalten), im etablierten Stil mit
Vermerk des vorherigen Standes (`**war 8/0**`), der drei gebundenen
Zaehler und der Begruendung, warum acht Treffer ungebunden BLEIBEN
(Mandantentabelle ohne Regel — hier ist Ungebundenheit richtig, nicht
geduldet).
5. Summenzeile derselben Tabelle mit fortgeschriebener Herkunftsspur.
6. Klassen-Verteilung samt Zahl in der Ueberschrift: ein neues Paar,
`muss-mandantengebunden` steigt um eins; ein `**Stand 260911-e2s**`-Absatz
sagt, welches Paar hinzukam und dass keine Klasse sich verschob.
7. Hintergrunddienst-Abschnitt: ein PLAIN-Absatz (kein Aufzaehlungspunkt,
keine Zeile der Form "Der ... Fall, anderer Bauart"), der festhaelt, dass
dieser Bereich keinen sechsten Fall hinzufuegt, mit der Anweisung aus
Befund J, und dass `TenantService.create` die Standardgruppe ueber den
bereits gebundenen Weg des Bereichs `groups` anlegt.
8. Abschnitt `Was diese Etappe NICHT entscheidet`, erster Punkt: in der Form
des aufgeloesten #19-Punktes durchstreichen und `Aufgelöst (260911-e2s)`
anfuegen — die Frage ist fuer ALLE Bereiche entschieden, nicht nur fuer
diesen: dienst-intern, ein Klient je Methode, die Anfrageobjekt-Eigenschaft
existiert nicht mehr.
TEIL 3 — die Entwicklungsanleitung. Aendere in `docs/anleitung-entwicklung.md`
im Abschnitt `## Mandantentrennung` NUR den ersten Absatz (Beschreibung des
Guards: setzt `req.tenantId`; die Bindung geschieht je Dienstmethode ueber
das Bindungshilfsmittel; kein Klient auf dem Anfrageobjekt) und ersetze den
Hinweiskasten ueber die Middleware durch EINEN Satz: der nie verdrahtete
Zwilling des Guards wurde mit 260911-e2s entfernt. Der Klassenname der
Middleware darf in der Datei danach nicht mehr vorkommen, die alte
Eigenschaft nicht mehr mit Punkt-Zugriff. Die veraltete Tabellenliste im
Absatz danach bleibt UNVERAENDERT (steht in (n5) als Ungenauigkeit).
TEIL 4 — zwei Falsifizierungsnachweise fuer die Dokument-Gates, jeder
zurueckgenommen und mit Meldung woertlich notiert: (a) setze die neue
Bestandsaufnahme-Zeile probeweise auf `ungebunden` — die maschinelle
Bestandspruefung muss rot werden; (b) setze die Uebersichtszeile probeweise
auf eine falsche Zahl — das herleitende Gate dieser Aufgabe muss
fehlschlagen.
Aendere keine Datei ausserhalb der vier genannten.
</action>
<verify>
<automated>DB_IP=$(docker inspect tessera-ctl-db-1 --format '{{range $k,$v := .NetworkSettings.Networks}}{{$v.IPAddress}}{{end}}') && TESSERA_SCRATCH_ADMIN_URL="postgresql://tessera:tessera_dev@${DB_IP}:5432/postgres" node apps/api/scripts/rls-scratch-check.mjs && test -f apps/api/src/tenant/tenant.controller.spec.ts && npm --prefix apps/api run test -- src/tenant/tenant.controller.spec.ts && npm --prefix apps/api run test -- src/prisma/rls-access-inventory.spec.ts && TOUT=$(npm --prefix apps/api run test 2>&1) && echo "$TOUT" | tail -6 && T=$(echo "$TOUT" | sed -nE 's/^ *Tests +([0-9]+) passed.*/\1/p' | head -1) && { test -n "$T" && test "$T" -gt 883 || { echo "TESTZAHL: ${T:-unbekannt}, erwartet mehr als 883"; exit 1; }; } && npm --prefix apps/api run type-check && grep -q '__makeBoundClient' apps/api/src/tenant/tenant.controller.spec.ts && grep -q "vi.mock('../prisma/prisma-tenant.extension'" apps/api/src/tenant/tenant.controller.spec.ts && grep -q 'ROLES_KEY' apps/api/src/tenant/tenant.controller.spec.ts && grep -q 'reflect-metadata' apps/api/src/tenant/tenant.controller.spec.ts && grep -q 'isActive' apps/api/src/tenant/tenant.controller.spec.ts && grep -q "from '../prisma/prisma-tenant.extension'" apps/api/src/tenant/tenant.controller.ts && SRC=$(grep -vE '^\s*(//|\*|/\*)' apps/api/src/tenant/tenant.controller.ts) && test 0 -eq "$(printf '%s\n' "$SRC" | grep -c '_count')" && test 0 -eq "$(printf '%s\n' "$SRC" | grep -c 'include')" && test 0 -eq "$(grep -c 'this\.prisma\.user' apps/api/src/tenant/tenant.controller.ts)" && B=$(printf '%s\n' "$SRC" | grep -o 'tenantPrisma\.user\.count(' | wc -l | tr -d ' ') && { test "$B" -eq 3 || { echo "BINDUNG: $B gebundene Zaehler in tenant.controller.ts, erwartet genau 3 (findAll, findOne, remove)"; exit 1; }; } && C=$(printf '%s\n' "$SRC" | grep -o 'forTenant(this\.prisma' | wc -l | tr -d ' ') && { test "$C" -eq 3 || { echo "KLIENTEN: $C Aufrufstellen des Bindungshilfsmittels in tenant.controller.ts, erwartet genau 3"; exit 1; }; } && U=$(printf '%s\n' "$SRC" | grep -o 'this\.prisma\.tenant\.' | wc -l | tr -d ' ') && { test "$U" -eq 4 || { echo "TENANT-ZUGRIFFE: $U ungebundene tenant-Zugriffe in tenant.controller.ts, erwartet genau 4 (bleiben ungebunden, Tenant ohne Regel)"; exit 1; }; } && URAW=$(grep -o 'this\.prisma\.[a-zA-Z]*' apps/api/src/tenant/tenant.controller.ts | wc -l | tr -d ' ') && { test "$URAW" -eq "$U" || { echo "KOMMENTARE in tenant.controller.ts nennen die ungebundene Zugriffsform woertlich ($URAW mit, $U ohne Kommentare)"; exit 1; }; } && grep -q 'isActive: true' apps/api/src/tenant/tenant.controller.ts && grep -q '260911-e2s' apps/api/src/tenant/tenant.controller.ts && grep -q 'Role.SUPER_ADMIN' apps/api/src/tenant/tenant.controller.ts && test 0 -eq "$(grep -rn '\.tenantPrisma' apps/api/src --include=*.ts | wc -l | tr -d ' ')" && DU=$(grep -ro "this\.prisma\.[a-zA-Z]*" apps/api/src/tenant | grep -v spec | wc -l | tr -d ' ') && DB=$(grep -ro "tenantPrisma\.[a-zA-Z]*\." apps/api/src/tenant | grep -v spec | wc -l | tr -d ' ') && { test "$DU" -eq 8 || { echo "UEBERSICHTSZEILE: ungebundene Rohtreffer in apps/api/src/tenant sind $DU, erwartet 8"; exit 1; }; } && { test "$DB" -eq 3 || { echo "UEBERSICHTSZEILE: gebundene Rohtreffer in apps/api/src/tenant sind $DB, erwartet 3"; exit 1; }; } && { grep -qE "^\| tenant \| ${DU} \| ${DB} \| \*\*war 8/0\*\*" docs/mandantentrennung-zugriffsklassifikation.md || { echo "UEBERSICHTSZEILE tenant nennt nicht die neu gemessenen Zahlen ${DU}/${DB} im etablierten Stil"; exit 1; }; } && awk -F'|' '$2 ~ /^ *[a-z][a-z-]* *$/ && $3 ~ /^ *[0-9]+ *$/ && $4 ~ /^ *[0-9]+ *$/ { su+=$3; sb+=$4; n++ } $2 ~ /^ *\*\*Summe\*\* *$/ && $4 !~ /^ *$/ { g3=$3; g4=$4; gsub(/[^0-9]/,"",g3); gsub(/[^0-9]/,"",g4); ru=g3+0; rb=g4+0; seen=1 } END { if (!seen || n+0 != 12) { print "UEBERSICHTSTABELLE nicht erkannt, Bereichszeilen: " n; exit 1 } if (su+0 != ru || sb+0 != rb) { print "SUMMENZEILE stimmt nicht: Bereichszeilen ergeben " su "/" sb ", Summenzeile nennt " ru "/" rb; exit 1 } }' docs/mandantentrennung-zugriffsklassifikation.md && awk -F'|' '$2 ~ /^ *apps\/api\/src\// { k=$4; gsub(/^ +| +$/,"",k); cls[k]++; pairs++ } $2 ~ /^ *(muss-mandantengebunden|keine-mandantengebundene-tabelle|beides|bewusst-uebergreifend) *$/ && $3 ~ /^ *[0-9]+ *$/ && $4 ~ /^ *$/ { k=$2; gsub(/^ +| +$/,"",k); v=$3; gsub(/[^0-9]/,"",v); tab[k]=v+0; tn++ } $2 ~ /^ *\*\*Summe\*\* *$/ && $4 ~ /^ *$/ { v=$3; gsub(/[^0-9]/,"",v); tsum=v+0; tseen=1 } /^## Klassen-Verteilung/ { h=$0; gsub(/[^0-9]/,"",h); hp=h+0; hseen=1 } END { if (tn+0 != 4 || !tseen || !hseen) { print "KLASSEN-VERTEILUNG nicht erkannt: Klassenzeilen " tn ", Summenzeile " tseen ", Ueberschrift " hseen; exit 1 } if (tsum != pairs+0) { print "KLASSEN-SUMME stimmt nicht: Bestandsaufnahme hat " pairs " Paare, Tabellensumme nennt " tsum; exit 1 } if (hp != pairs+0) { print "UEBERSCHRIFT der Klassen-Verteilung nennt " hp " Paare, Bestandsaufnahme hat " pairs; exit 1 } s=0; for (k in tab) { if (tab[k] != cls[k]+0) { print "KLASSE " k ": Tabelle nennt " tab[k] ", Bestandsaufnahme zaehlt " cls[k]+0; exit 1 } s+=tab[k] } if (s != pairs+0) { print "KLASSENZEILEN ergeben " s ", Bestandsaufnahme hat " pairs; exit 1 } }' docs/mandantentrennung-zugriffsklassifikation.md && grep -qE '^\| apps/api/src/tenant/tenant\.controller\.ts \| user \| muss-mandantengebunden \| gebunden \|' docs/mandantentrennung-zugriffsklassifikation.md && grep -qE '^\| apps/api/src/tenant/tenant\.controller\.ts \| tenant \| keine-mandantengebundene-tabelle \| ungebunden \|.*260911-e2s' docs/mandantentrennung-zugriffsklassifikation.md && grep -qE '^\| apps/api/src/tenant/tenant\.service\.ts \| tenant \| keine-mandantengebundene-tabelle \| ungebunden \|.*260911-e2s' docs/mandantentrennung-zugriffsklassifikation.md && awk '/^## Klassen-Verteilung/{f=1; next} /^## /{f=0} f && /Stand 260911-e2s/{m=1} END{ if(!m){print "KLASSEN-VERTEILUNG: kein Stand-Vermerk fuer 260911-e2s"; exit 1} }' docs/mandantentrennung-zugriffsklassifikation.md && awk 'BEGIN{split("ein zwei drei vier x sechs sieben acht neun",w," "); w[5]="fünf"} /^## Der Hintergrunddienst als Falle/{seen=1; head=$0; f=1; next} /^## /{f=0} f && /^- \*\*`/{n++} f && /^\*\*Der .* Fall, anderer Bauart/{n++} f && /260911-e2s/{m=1} END{ if(!seen){print "ABSCHNITT Hintergrunddienst nicht gefunden"; exit 1} want="## Der Hintergrunddienst als Falle — " w[n] " Fälle"; if(head != want){printf "HINTERGRUNDDIENST-UEBERSCHRIFT nennt \"%s\", gezaehlt wurden %d Faelle, erwartet \"%s\"\n", head, n, want; exit 1} if(!m){print "ABSCHNITT Hintergrunddienst nennt 260911-e2s nicht — die Abwesenheit eines sechsten Falls ist nicht belegt"; exit 1} }' docs/mandantentrennung-zugriffsklassifikation.md && awk '/^## Zwei belegte Befunde/{f=1; next} /^## /{f=0} f && /260911-e2s/{m=1} f && /[Ee]ntschieden/{e=1} END{ if(!m||!e){print "ABSCHNITT \"Zwei belegte Befunde\": die Entscheidung zu 260911-e2s fehlt"; exit 1} }' docs/mandantentrennung-zugriffsklassifikation.md && awk '/^## Bestandsaufnahme/{f=1; next} /^\| Datei \|/{f=0} f && /_count|include/{m=1} END{ if(!m){print "KOPF der Bestandsaufnahme nennt die Erkennungsluecke fuer Relationseinbindungen nicht"; exit 1} }' docs/mandantentrennung-zugriffsklassifikation.md && awk '/^## Was diese Etappe NICHT entscheidet/{f=1; next} f && /Aufgelöst \(260911-e2s\)/{m=1} END{ if(!m){print "ABSCHNITT \"Was diese Etappe NICHT entscheidet\": der erste Punkt ist nicht als aufgeloest (260911-e2s) markiert"; exit 1} }' docs/mandantentrennung-zugriffsklassifikation.md && test 0 -eq "$(grep -c 'req.tenantPrisma' docs/anleitung-entwicklung.md)" && test 0 -eq "$(grep -c 'TenantMiddleware' docs/anleitung-entwicklung.md)" && awk '/^## Mandantentrennung/{f=1; next} /^## /{f=0} f && /260911-e2s/{m=1} f && /req\.tenantId/{t=1} END{ if(!m||!t){print "ANLEITUNG: Abschnitt Mandantentrennung nennt 260911-e2s oder req.tenantId nicht"; exit 1} }' docs/anleitung-entwicklung.md && test 0 -eq "$(grep -rn '\$transaction(' apps/api/src/tenant --include=*.ts | grep -v spec | wc -l | tr -d ' ')" && git rev-parse --verify f1017fa >/dev/null && PRISMA_CHANGED=$(git diff --name-only f1017fa -- apps/api/prisma) && { test -z "$PRISMA_CHANGED" || { printf 'SCHEMA/MIGRATION GEAENDERT — in diesem Plan verboten:\n%s\n' "$PRISMA_CHANGED"; exit 1; }; } && WEB_CHANGED=$(git diff --name-only f1017fa -- apps/web docker-compose.yml docker-compose.prod.yml) && { test -z "$WEB_CHANGED" || { printf 'FRONTEND/COMPOSE GEAENDERT — in diesem Plan verboten (Umgebungsdateien faengt die Erlaubnisliste unten):\n%s\n' "$WEB_CHANGED"; exit 1; }; } && CHANGED=$(git diff --name-only f1017fa) && UNEXPECTED=$(printf '%s\n' "$CHANGED" | grep -v '^$' | grep -vE '^(apps/api/scripts/rls-scratch-check\.mjs|docs/mandantentrennung-etappe2-fehlerrichtung\.md|apps/api/src/tenant/tenant\.guard\.ts|apps/api/src/tenant/tenant\.guard\.spec\.ts|apps/api/src/tenant/tenant\.middleware\.ts|apps/api/src/prisma/rls-access-inventory\.spec\.ts|apps/api/src/app\.module\.ts|apps/api/src/module-registry/module\.guard\.ts|apps/api/src/dkv/dkv\.controller\.ts|apps/api/src/tenant/tenant\.controller\.ts|apps/api/src/tenant/tenant\.controller\.spec\.ts|docs/mandantentrennung-zugriffsklassifikation\.md|docs/anleitung-entwicklung\.md|\.planning/.*)$' || true) && { test -z "$UNEXPECTED" || { printf 'UNERWARTETE AENDERUNGEN AUSSERHALB DES PLANUMFANGS:\n%s\n' "$UNEXPECTED"; exit 1; }; }</automated>
</verify>
<done>`tenant.controller.ts`: `findAll`, `findOne` und `remove` zaehlen Benutzer je Mandant ueber genau drei gebundene Aufrufstellen (`tenantPrisma.user.count` mit `where: { tenantId }`, in `remove` mit `isActive: true`), kein Relationszaehler und kein `include` mehr, die vier `tenant`-Zugriffe bleiben ungebunden, Antwortform und Meldungen unveraendert, Kopfkommentar nennt Grund und Entscheidung. `tenant.controller.spec.ts` existiert neu mit dem Zwei-Klienten-Nachweis, deckt jeden in `<behavior>` genannten Fall ab — einschliesslich Rollen-Metadaten und Wachhund. Alle handgepflegten Stellen von `docs/mandantentrennung-zugriffsklassifikation.md` sind nachgezogen und maschinell gegatet: `Zwei belegte Befunde` (entschieden), Kopf der Bestandsaufnahme (Erkennungsluecke), drei Bestandsaufnahme-Zeilen (eine neu), Uebersichtszeile mit neu gemessenen Zahlen, Summenzeile, Klassen-Verteilung mit 64 Paaren und Stand-Vermerk, Hintergrunddienst-Abschnitt, `Was diese Etappe NICHT entscheidet` (erster Punkt aufgeloest). `docs/anleitung-entwicklung.md` beschreibt den Guard richtig und nennt die Middleware nicht mehr. Alle drei Falsifizierungsnachweise dieser Aufgabe sind durchgefuehrt, zurueckgenommen und woertlich notiert. Werkzeug alle Pruefungen bestanden, Baseline gehalten, Testzahl gestiegen, Schalter unveraendert aus, keine Regel auf `Tenant`.</done>
</task>
</tasks>
<threat_model>
Konfiguriert: ASVS-Stufe 1, blockierend ab `high`.
## Trust Boundaries
| Boundary | Description |
|----------|-------------|
| Browser/Plattform-Administrator → Mandanten-API | Die Rolle stammt ausschliesslich aus dem validierten Sitzungsnachweis; die Mandantenkennung im Pfad ist frei waehlbare Eingabe, bezeichnet aber den zu VERWALTENDEN Mandanten, nicht den des Anfragenden. |
| Jeder authentifizierte Nutzer → `TenantGuard` | Die Grenze, an der `req.tenantId` entsteht: aus dem Sitzungsnachweis, fuer SUPER_ADMIN wahlweise aus `x-tenant-id`. 22 Lesestellen in 9 Dateien haengen daran. |
| SUPER_ADMIN → `x-tenant-id` | Ein frei waehlbarer Header, der NUR fuer diese Rolle wirkt (T-04-03). Wird vom Marktplatz-Frontend benutzt. |
| API → PostgreSQL, Tabelle `Tenant` | KEINE Zeilenschutz-Grenze — gemessen. Gebunden gleich ungebunden. |
| API → PostgreSQL, Tabelle `User` (ueber den Zaehler) | Die Grenze, die drei Zugriffe dieses Bereichs unbemerkt ueberschritten: der Relationszaehler lief unter der Regel von `User`. |
| Loeschen eines Mandanten → Fremdschluessel `User_tenantId_fkey` | Der Rueckhalt: `ON DELETE RESTRICT`, referentielle Pruefung umgeht den Zeilenschutz (gemessen, Pruefung 7). |
## STRIDE Threat Register
| Threat ID | Category | Component | Severity | Disposition | Mitigation Plan |
|-----------|----------|-----------|----------|-------------|-----------------|
| T-E2S-01 | Elevation of Privilege | `tenant.controller.ts`, alle fuenf Handler | high | mitigate | Mandantenverwaltung durch einen Nicht-SUPER_ADMIN. Heute: klassenweit `@Roles(Role.SUPER_ADMIN)`, `RolesGuard` mit `getAllAndOverride([handler, class])`, kein Handler ueberschreibt (Befund E, gelesen). Ein spaeteres Handler-`@Roles` mit schwaecherer Rolle wuerde die Klassenrolle UEBERSCHREIBEN, ohne dass etwas rot wird — Aufgabe 3 nagelt Klassen- UND Handler-Metadaten als Test fest. |
| T-E2S-02 | Spoofing | `tenant.guard.ts`, `tenant.middleware.ts`, Anfrageobjekt-Eigenschaft | high | mitigate | Tote Verdrahtung, die wie ein Schutz aussieht: ein je Anfrage erzeugter gebundener Klient ohne Leser, in zwei Dateien, eine davon nie verdrahtet. Ein spaeterer Leser koennte annehmen, Controller seien "automatisch gebunden". Entfernt (Aufgabe 2); Guard ohne Prisma; Middleware geloescht; Test asserts Abwesenheit; Gate zaehlt Punkt-Zugriffe in `apps/api/src` auf null, Kommentare eingeschlossen; Entscheidung an vier Dokumentstellen. |
| T-E2S-03 | Denial of Service / Elevation of Privilege | `tenant.guard.ts`, `req.tenantId` | high | mitigate | Der Umbau koennte versehentlich die tragende Zuweisung `req.tenantId` mitreissen — 22 Lesestellen in 9 Dateien (ldap, settings, dkv, groups, module-guard, tenders) wuerden ohne Mandant laufen oder abbrechen. Alle fuenf Zweige als Testfaelle festgenagelt; Gate zaehlt genau zwei Zuweisungen; Guard-Reihenfolge in `app.module.ts` gegatet; Typpruefung ueber den gesamten API-Quelltext. |
| T-E2S-04 | Information Disclosure / Tampering | `tenant.controller.ts` `findAll`/`findOne`/`remove`, Relationszaehler | high | mitigate | Nach dem Scharfschalten: Benutzerzahl 0 fuer jeden Mandanten in der Plattform-Administratorsicht (falsche Zahl ohne Signal), Loeschriegel T-02-09 vakuum. Gemessen ueber den generierten Client (Pruefungen 5-7). Behoben durch Fan-out je Mandant mit gebundenem Zaehler und ausdruecklichem `where: { tenantId }` (heute der einzige Filter, WINDOWS #18); als Testfaelle festgenagelt; Falsifizierung durch Rueckbau eines Zaehlers. |
| T-E2S-05 | Spoofing | `tenant.guard.ts`, `x-tenant-id` | medium | mitigate | Ein Nicht-SUPER_ADMIN schickt den Header und wechselt den Mandanten. Heute korrekt abgewiesen (`user.role === 'SUPER_ADMIN'`, T-04-03), aber nie getestet — Aufgabe 2 nagelt den Fall fest (Header wird ignoriert, `req.tenantId` bleibt der eigene). |
| T-E2S-06 | Information Disclosure | `tenant.guard.ts`, Header-Wert ohne Existenzpruefung | low | accept | SUPER_ADMIN kann eine erfundene Mandantenkennung schicken; sie bindet an einen leeren Kontext — null Zeilen, kein Leck (D-10 wie entworfen). Nicht dieser Auftrag; in (n4)(f) festgehalten. |
| T-E2S-07 | Denial of Service | `tenant.controller.ts` `findAll`, Fan-out | low | accept | Eine gebundene Zaehlabfrage je Mandant statt eines Joins. Mandantenzahl ist einstellig; dieselbe Form wie `UserService.findAllForPlatformAdmin`; in (n4)(g) festgehalten. |
| T-E2S-08 | Tampering | Fremdschluessel `User_tenantId_fkey`, `remove` | low | accept | Der Fremdschluessel faengt auch INAKTIVE Benutzer, der Riegel zaehlt nur aktive — ein Mandant mit ausschliesslich inaktiven Benutzern liefert heute wie nach dem Plan 500 statt 400. Bestehendes Verhalten, gemessen (Pruefung 7/8), in (n4)(c) festgehalten, nicht geaendert. |
| T-E2S-09 | Repudiation / Information Disclosure | `rls-access-inventory.spec.ts`, Erkennungsluecke fuer Relationseinbindungen | medium | accept | Die maschinelle Vollstaendigkeitsaussage der Klassifikation gilt nur fuer (Datei, Modell)-Paare, nicht fuer `include`/Relationszaehler in eine zweite Tabelle. Vermessen (Befund G, alle 19 `include:`- und alle `_count`-Stellen einzeln beurteilt): einzige gefaehrliche Auspraegung war dieser Bereich, hier behoben. Luecke im Kopf der Bestandsaufnahme und in (n4)(b) benannt; kein Ledger-Eintrag, solange die Wiederholung zur Ausfuehrungszeit keine zweite Auspraegung findet. |
| T-E2S-10 | — (Wartbarkeit, keine Bedrohung) | `tenant.controller.ts`, direkter Prisma-Zugriff | low | accept | Controller mit direktem Datenbankzugriff (wie `user.controller.ts`). Bewusst NICHT in den Dienst verschoben (Scope); als Muster in (n5) festgehalten. |
| T-E2S-11 | Spoofing | Sitzungsnachweis | low | accept | Mandanten- oder Rollenangabe aus Rumpf oder Pfad. Bereits in Phase 02/05 behandelt; dieser Plan aendert daran nichts. |
| T-E2S-SC | Tampering | Paketinstallation | low | accept | Dieser Plan installiert kein Paket (npm/pip/cargo) und fuegt keine Abhaengigkeit hinzu. Das Legitimitaets-Gate faellt nicht an; ausdruecklich festgehalten statt schweigend ausgelassen. |
</threat_model>
<verification>
Nach Abschluss aller drei Aufgaben:
1. `node apps/api/scripts/rls-scratch-check.mjs` meldet alle Pruefungen
bestanden (101 bisherige plus die neuen, mindestens 110), Rueckgabewert 0.
2. `npm --prefix apps/api run test` meldet mehr als 883 Tests gruen in
mindestens 59 Dateien (57 bisherige plus zwei neue Testdateien; die
geloeschte Middleware hatte keine).
3. `npm --prefix apps/api run type-check` ist sauber.
4. `npm --prefix apps/api run test -- src/prisma/rls-access-inventory.spec.ts`
ist gruen — 64 Paare, die Bestandsaufnahme stimmt mit dem Quelltext
ueberein, die Ausnahmeliste ist leer und selbstpruefend.
5. `grep -rn '\.tenantPrisma' apps/api/src --include=*.ts` und
`grep -rn 'TenantMiddleware' apps/api/src --include=*.ts` liefern nichts;
`apps/api/src/tenant/tenant.middleware.ts` existiert nicht.
6. Alle handgepflegten Stellen der Klassifikation sind maschinell gegatet und
gruen, die Zaehlgates LEITEN ihre Werte aus den im Dokument genannten
Messanweisungen ab.
7. Der Umfang ist als ERLAUBNISLISTE gegatet: jede Datei, die sich gegenueber
`f1017fa` geaendert hat, ist eine der dreizehn in `files_modified`
genannten (oder liegt unter `.planning/`); die drei Fremddateien nur in
Kommentarzeilen. Unter `apps/api/prisma`, `apps/web` und den
Compose-/Umgebungsdateien hat sich nichts geaendert.
8. `DATABASE_URL` zeigt unveraendert auf die Rolle `tessera`; `Tenant` traegt
keine Regel; nichts in Active Directory.
</verification>
<success_criteria>
- Die Architekturfrage aus Etappe 1 ist entschieden und umgesetzt: kein
gebundener Klient auf dem Anfrageobjekt, keine Middleware, Guard ohne
Prisma — und die Entscheidung steht mit Messung und Grund in Code,
Kritikschrift, Klassifikation und Entwicklungsanleitung.
- `req.tenantId`, der SUPER_ADMIN-Wechsel und die Ausnahme fuer
mandantenlose Nutzer sind unveraendert und erstmals als Tests festgenagelt.
- Auf `Tenant` ist nichts zu binden — gemessen an allen Migrationen und an
der Datenbank, Roh-SQL und generierter Client; die beiden `tenant`-Paare
bleiben `ungebunden`, mit Grund.
- Der Relationszaehler-Befund ist gemessen, behoben (drei gebundene Zaehler,
Fan-out je Mandant) und als Tests festgenagelt; die Erkennungsluecke der
Bestandsaufnahme ist vermessen und benannt.
- Die SUPER_ADMIN-Beschraenkung ist gelesen, bestaetigt und als
Metadaten-Test festgenagelt.
- Alle Falsifizierungsnachweise (Guard, Controller, zwei Dokument-Gates) sind
durchgefuehrt, zurueckgenommen und im SUMMARY mit Testname beziehungsweise
Meldung festgehalten.
- Jede im SUMMARY genannte Zahl (neue Werkzeugpruefungen, neue Testfaelle je
Datei, gebundene Zaehler, Paare) ist ABGEZAEHLT, nicht aus diesem Plan
abgeschrieben; das SUMMARY widerspricht sich nicht (Fehler 8).
- Baseline gehalten am Ende jeder Aufgabe. Der Schalter ist weiterhin AUS.
</success_criteria>
<output>
Create `.planning/quick/260911-e2s-mandantentrennung-etappe-2-bereich-tenan/260911-e2s-SUMMARY.md` when done
</output>