feat(260911-e2s): Fehlerrichtung fuer Bereich tenant messen — Relationszaehler laeuft unter User
runTenantAreaChecks (9 neue Pruefungen, 6 davon ueber den generierten Client) belegt: auf "Tenant" ist nichts zu binden (keine Regel in allen 34 Migrationen einschliesslich 20260910120000), aber der Relationszaehler in findAll/findOne/remove liefert nach dem Scharfschalten userCount=0 fuer jeden Mandanten und laesst den Loeschriegel T-02-09 vakuum werden — der Fremdschluessel faengt das nur laut (500) statt mit der verstaendlichen 400-Meldung ab. docs/mandantentrennung-etappe2-fehlerrichtung.md bekommt den Abschnitt "## Bereich tenant" (n1-n5) mit der tatsaechlich beobachteten Werkzeugausgabe, der Signaltabelle je Pfad, den Frontend-Stellen, die die falsche Zahl unkommentiert durchlassen, und der Entscheidung zur Anfrageobjekt-Eigenschaft (Vorbereitung fuer Aufgabe 2). Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01AMASaSxv5QMY7RncqZriRR
This commit is contained in:
@@ -2259,6 +2259,217 @@ Browsers und im API-Log, an keiner Stelle in der Benutzeroberfläche.
|
||||
- Schema und Migrationen — geprüft und bewusst gelassen, keine
|
||||
Schemaänderung in dieser Etappe.
|
||||
|
||||
## Bereich tenant
|
||||
|
||||
Dieser Abschnitt erweitert die Kritikschrift um den Bereich `tenant`
|
||||
(Quick-Task 260911-e2s) und beschreibt ihn zum Zeitpunkt seiner Umstellung.
|
||||
Anders als jeder Bereich davor betrifft er nicht mandantengebundene Tabellen,
|
||||
sondern die Mandantentabelle SELBST — `Tenant` hat per Definition keine
|
||||
`tenantId`-Spalte und ist deshalb die einzige Tabelle, auf der es nichts zu
|
||||
binden gibt. Der Bereich traegt trotzdem zwei Dinge, die alle neun Bereiche
|
||||
davor vertagt oder uebersehen haben: die seit Etappe 1 offene Entscheidung
|
||||
zum gebundenen Klienten auf dem Anfrageobjekt (Aufgabe 2), und einen Befund,
|
||||
den die Erwartung "null Umstellungsarbeit" verdeckt haette — drei der acht
|
||||
Zugriffe zaehlen ueber eine Relationseinbindung in die GESCHUETZTE Tabelle
|
||||
`User` hinein (Aufgabe 3).
|
||||
|
||||
### (n1) Die Messung
|
||||
|
||||
Aufgabe 1 hat `apps/api/scripts/rls-scratch-check.mjs` um einen zwoelften
|
||||
Abschnitt (`runTenantAreaChecks`) erweitert. Sechs der neun neuen Pruefungen
|
||||
(4, 5, 6, 7, 8, 9) laufen ueber den GENERIERTEN CLIENT
|
||||
(`prisma.tenant.findMany`/`findUnique`/`delete`, `bound.tenant.findMany`,
|
||||
`bound.user.count`) statt ueber Roh-SQL — bewusst, weil der Relationszaehler
|
||||
(`include: { _count: { select: { users } } }`), den `findAll`/`findOne`
|
||||
tatsaechlich benutzen, eine Client-Form ist: Roh-SQL sieht ihn strukturell
|
||||
nicht (Fehler 7 des Vorhabens — "Roh-SQL ist nicht der generierte Client").
|
||||
Pruefung 1 liest zur Laufzeit jede der 34 ausgelieferten
|
||||
`migration.sql`-Dateien und prueft auf `CREATE POLICY ... ON "Tenant"` sowie
|
||||
`ALTER TABLE "Tenant"` — ausdruecklich EINSCHLIESSLICH
|
||||
`20260910120000_rls_widen_membership_grant_and_platform_read`, die "Tenant"
|
||||
nirgends nennt. Tatsaechlich beobachtete Ausgabe dieses Laufs (2026-09-11,
|
||||
gegen `tessera-ctl-db-1`, Adresse `172.19.0.2`):
|
||||
|
||||
```
|
||||
tenant-keine-regel-in-allen-ausgelieferten-migrationen: bestanden — 34 Migrationsverzeichnisse gelesen, darunter "20260910120000_rls_widen_membership_grant_and_platform_read" — "Tenant" kommt darin nicht vor; 0 Verzeichnis(se) mit CREATE POLICY/ALTER TABLE auf "Tenant": []
|
||||
tenant-gebunden-und-ungebunden-liefern-dieselben-zeilen: bestanden — Roh-SQL gebunden (TENANT-A): ["TENANT-A","TENANT-B","TENANT-C"]; ungebunden: ["TENANT-A","TENANT-B","TENANT-C"]; Wartungsrolle: ["TENANT-A","TENANT-B","TENANT-C"]
|
||||
tenant-wegwerftabelle-deckt-alle-spalten-des-generierten-clients: bestanden — Spalten aus CREATE TABLE "Tenant" in 20260618112124_auth_multi_tenancy (6): ["createdAt","id","isActive","name","slug","updatedAt"]; Spalten der Wegwerf-Tabelle (6): ["createdAt","id","isActive","name","slug","updatedAt"]
|
||||
tenant-generierter-client-zeilen-gebunden-und-ungebunden-identisch: bestanden — generierter Client gebunden (TENANT-A): ["TENANT-A","TENANT-B","TENANT-C"]; ungebunden: ["TENANT-A","TENANT-B","TENANT-C"]; Roh-SQL-Vergleichswert (Pruefung 2): ["TENANT-A","TENANT-B","TENANT-C"]
|
||||
tenant-generierter-client-benutzerzaehler-ungebunden-null-fuer-jeden-mandanten: bestanden — das ist die Zahl, die admin/tenants/page.tsx als Benutzeranzahl anzeigen wuerde — ungebunden: [{"id":"TENANT-A","userCount":0},{"id":"TENANT-B","userCount":0},{"id":"TENANT-C","userCount":0}]; Wartungszahl je Mandant: {"TENANT-B":2,"TENANT-A":2}
|
||||
tenant-generierter-client-benutzerzaehler-gebunden-nur-eigener-mandant: bestanden — gebunden unter TENANT-A: A=2 (Wartungszahl=2), B=0 (Wartungszahl=2), C=0
|
||||
tenant-loeschriegel-ungebunden-vakuum-fremdschluessel-faengt-laut: bestanden — ungebundener Relationszaehler ueber aktive Benutzer fuer TENANT-A=0, Wartungszahl=2 — der Riegel T-02-09 liesse das Loeschen durch; ungebundenes prisma.tenant.delete ueber den generierten Client wirft PrismaClientKnownRequestError (code P2003): Invalid `prisma.tenant.delete()` invocation: Foreign key constraint violated on the constraint: `User_tenantId_fkey` — die Zeile existiert ueber die Wartungsrolle danach noch: true; die referentielle Pruefung des Fremdschluessels umgeht den Zeilenschutz und faengt das Loeschen trotzdem ab
|
||||
tenant-loeschen-ohne-benutzer-gelingt-wie-heute: bestanden — ungebundenes prisma.tenant.delete fuer TENANT-C (ohne Benutzer) ist NICHT fehlgeschlagen; ueber die Wartungsrolle danach noch vorhanden: false
|
||||
tenant-fan-out-je-mandant-gebundene-zaehlung-stimmt: bestanden — Fan-out je verbliebenem Mandanten (2): [{"id":"TENANT-A","gebunden":2,"wartung":2},{"id":"TENANT-B","gebunden":2,"wartung":2}]
|
||||
Alle 110 Pruefungen bestanden.
|
||||
```
|
||||
|
||||
Die Belegzeile, die diesen Abschnitt traegt, ist
|
||||
`tenant-generierter-client-benutzerzaehler-ungebunden-null-fuer-jeden-mandanten`
|
||||
(Pruefung 5): dieselbe Abfrage, die `findAll` heute stellt, liefert
|
||||
UNGEBUNDEN fuer JEDEN Mandanten `userCount: 0`, waehrend die Wartungsrolle
|
||||
fuer TENANT-A und TENANT-B je 2 aktive Benutzer zaehlt — das ist exakt die
|
||||
Zahl, die `admin/tenants/page.tsx` nach dem Scharfschalten anzeigen wuerde.
|
||||
Der Fremdschluessel `User_tenantId_fkey` (Pruefung 7, wortgleich aus Zeile
|
||||
105 von `20260618112124_auth_multi_tenancy` geschnitten) faengt das daraus
|
||||
folgende Loeschen zwar ab — aber laut (SQLSTATE 23503, Prisma-Code `P2003`),
|
||||
nicht mit der verstaendlichen 400-Meldung des Riegels T-02-09.
|
||||
|
||||
### (n2) Signaltabelle je Pfad
|
||||
|
||||
| Pfad | Verhalten HEUTE nach dem Scharfschalten (ohne diesen Plan) | Konkretes Signal | Frontend laesst es durch? |
|
||||
|---|---|---|---|
|
||||
| `TenantController.findAll` | Der Relationszaehler (`include: { _count: { select: { users } } }`) laeuft ungebunden auf dem generierten Client (Pruefung 5): `userCount` ist 0 fuer JEDEN Mandanten, die Mandantenzeilen selbst bleiben vollstaendig (`Tenant` ohne Regel) | Die Mandantenliste zeigt jeden Mandanten mit 0 Benutzern — eine falsche Zahl, keine leere Liste | Ja — `admin/tenants/page.tsx` zeigt `tenant.userCount` ungeprueft an |
|
||||
| `TenantController.findOne` | Dieselbe Form wie `findAll`, fuer eine einzelne Kennung | `userCount: 0` fuer den betrachteten Mandanten | Ja — dieselbe Anzeige (falls einzeln abgefragt) |
|
||||
| `TenantController.create` | Kein Datenbankzugriff des Controllers selbst — delegiert an `TenantService.create` | Keins (Controller-Ebene) | — |
|
||||
| `TenantController.update` | Kein Datenbankzugriff des Controllers selbst — delegiert an `TenantService.update`, nach ungebundenem `findById` (Tenant ohne Regel, unveraendert) | Keins | — |
|
||||
| `TenantController.remove` | Der Relationszaehler ueber AKTIVE Benutzer laeuft ungebunden: `_count.users` ist 0 (Pruefung 7), der Riegel T-02-09 passiert, `tenant.delete` laeuft — und trifft den Fremdschluessel `User_tenantId_fkey` (`ON DELETE RESTRICT`): SQLSTATE 23503, Prisma-Code `P2003`, HTTP 500 mit generischer Meldung statt der verstaendlichen 400 | Ein Loeschversuch schlaegt laut fehl statt mit "Cannot delete tenant with active users" | Teilweise — `handleDelete` in `admin/tenants/page.tsx` prueft `res.ok`, tut bei nicht-OK aber NICHTS sichtbares (siehe (n3)) |
|
||||
| `TenantService.findAll` | Ungebunden, `Tenant` ohne Regel — unveraendert korrekt. Hat heute KEINEN Aufrufer (Befund L) | Keins (totes Codeglied) | — |
|
||||
| `TenantService.findById` | Ungebunden, `Tenant` ohne Regel — unveraendert korrekt, wird von `TenantController.update` als Existenzpruefung benutzt | Keins | — |
|
||||
| `TenantService.create` | Ungebunden, `Tenant` ohne Regel; ruft danach `groupsService.ensureDefaultGroup(tenant.id)` auf — dieser Aufruf laeuft seit 260909-jts vollstaendig gebunden ueber `forTenant()`/`withTenantTransaction()`, gebunden an den soeben angelegten Mandanten | Keins — die Standardgruppen-Anlage funktioniert nach dem Scharfschalten unveraendert | — |
|
||||
| `TenantService.update` | Ungebunden, `Tenant` ohne Regel — unveraendert korrekt | Keins | — |
|
||||
| `TenantGuard` | Kein Datenbankzugriff (Aufgabe 2 entfernt den letzten, ungenutzten Prisma-Aufruf) | Keins | — |
|
||||
|
||||
### (n3) Welcher Code eine falsche Zahl als Wahrheit deutet
|
||||
|
||||
Anders als bei jedem Bereich davor ist die gefaehrliche Form hier nicht
|
||||
LEERE, sondern eine FALSCHE ZAHL, die sich als Wahrheit ausgibt.
|
||||
|
||||
**Backend:** `TenantController.findAll`/`findOne` liefern `userCount: 0` ohne
|
||||
jedes Signal — kein Fehler, kein leeres Feld, eine plausibel aussehende Zahl,
|
||||
die schlicht falsch ist. `TenantController.remove` laesst den Riegel T-02-09
|
||||
passieren (Zaehler 0, obwohl aktive Benutzer existieren) und der
|
||||
Fremdschluessel antwortet laut mit der falschen Botschaft (500 statt 400,
|
||||
siehe (n1)/(n2)).
|
||||
|
||||
**Frontend**, namentlich mit Stelle:
|
||||
|
||||
- `apps/web/src/app/(portal)/admin/tenants/page.tsx` zeigt `tenant.userCount`
|
||||
ungeprueft in der Tabellenzeile an (Zeile 209: `{tenant.userCount}`).
|
||||
`fetchTenants` (Zeilen 44-55) prueft zwar `res.ok`, aber bei nicht-OK
|
||||
passiert NICHTS sichtbares — kein Fehlertext, keine Markierung, die Liste
|
||||
bleibt leer oder veraltet stehen (`catch { // silently fail }`).
|
||||
`handleDelete` (Zeilen 122-133) prueft ebenfalls `res.ok`, aber bei
|
||||
nicht-OK (der 500er aus dem Fremdschluessel) passiert wieder NICHTS: der
|
||||
Bestaetigungsdialog (`deleteConfirm`) bleibt offen, `fetchTenants()` wird
|
||||
nicht erneut aufgerufen — fuer den Administrator sieht das aus wie ein
|
||||
Knopf, der nicht reagiert, nicht wie ein Fehler.
|
||||
- `apps/web/src/app/(portal)/marketplace/components/TenantContextSelector.tsx`
|
||||
faengt jede nicht-OK-Antwort in eine LEERE Liste
|
||||
(`.then((res) => (res.ok ? res.json() : []))`) und jeden Netzwerkfehler in
|
||||
ein stilles Nichts (`.catch(() => {})`) — der SUPER_ADMIN sieht im
|
||||
Mandanten-Wechsel-Dropdown des Marktplatzes schlicht keine Mandanten, ohne
|
||||
Hinweis, dass eine Abfrage fehlgeschlagen ist statt "es gibt keine".
|
||||
|
||||
Zur Ausfuehrungszeit an den genannten Dateien und Zeilen erneut zu pruefen —
|
||||
Zeilennummern koennen sich verschieben.
|
||||
|
||||
### (n4) Was dieser Durchlauf bewusst nicht löst
|
||||
|
||||
**(a) Die Entscheidung zur Anfrageobjekt-Eigenschaft.** Gemessen (Befund B/C
|
||||
der Planung, in Aufgabe 1 wiederholt): `apps/api/src/tenant/tenant.guard.ts`
|
||||
und `apps/api/src/tenant/tenant.middleware.ts` setzen
|
||||
`req.tenantPrisma = forTenant(this.prisma, tenantId)`, aber eine Volltextsuche
|
||||
ueber `apps/api/src` (`grep -rn '\.tenantPrisma'`) findet ausserhalb dieser
|
||||
beiden Dateien KEINEN Lesezugriff — nur drei Kommentare, die die Middleware
|
||||
nennen. Die Middleware selbst ist NIRGENDS verdrahtet: weder `apps/api/src`
|
||||
noch `apps/api/src/main.ts` enthalten ein `MiddlewareConsumer`, ein
|
||||
`configure(` oder einen `.apply(...).forRoutes(...)`-Aufruf auf
|
||||
`TenantMiddleware` — `app.module.ts` implementiert kein `NestModule`.
|
||||
ENTSCHIEDEN (260911-e2s, Aufgabe 2): der Guard setzt nur noch
|
||||
`req.tenantId`; `tenant.middleware.ts` ist GELOESCHT (eine nie aufgerufene
|
||||
Kopie des Guards mit identischer Logik). GRUND: neun umgestellte Bereiche vor
|
||||
diesem binden ausnahmslos dienst-intern, ein Klient je Methode
|
||||
(`forTenant(this.prisma, tenantId)` in der jeweiligen Service-Methode) — die
|
||||
Konvention ist durch neunfache Praxis entschieden, nicht durch diesen Plan
|
||||
neu erfunden. Eine tote Verdrahtung, die wie ein Sicherheitsmechanismus
|
||||
AUSSIEHT (ein gebundener Klient, scheinbar bereit zur Benutzung), ist
|
||||
schlimmer als gar keine — sie suggeriert einem spaeteren Leser einen Schutz,
|
||||
den es nicht gibt.
|
||||
|
||||
**(b) Die Erkennungsluecke der Bestandsaufnahme (Befund G).**
|
||||
`rls-access-inventory.spec.ts` sammelt (Datei, Modell)-Paare ausschliesslich
|
||||
ueber `this.prisma.<Modell>` und `<gebundener Client>.<Modell>` — eine
|
||||
Relationseinbindung (`include:`, Relationszaehler `_count`) in eine ZWEITE
|
||||
Tabelle erzeugt kein Paar und ist fuer das Werkzeug unsichtbar. In Aufgabe 1
|
||||
erneut vermessen:
|
||||
|
||||
*Alle `_count`-Stellen ausserhalb von `tenant/`* (`grep -rn "_count"
|
||||
apps/api/src --include=*.ts | grep -v spec`): `groups.service.ts:66` (auf
|
||||
bereits GEBUNDENEM Klienten — harmlos, die Bindung schuetzt bereits) und
|
||||
`tenders.controller.ts:405` (`groupBy` auf der plattformweiten, ungeschuetzten
|
||||
`Tender`, kein Relationszugriff in eine zweite Tabelle — harmlos).
|
||||
|
||||
*Alle `include:`-Stellen* (`grep -rn "include:" apps/api/src --include=*.ts
|
||||
| grep -v spec`, 19 Treffer in 8 Dateien): jede Stelle einzeln beurteilt —
|
||||
aeusserer Aufruf gebunden oder nicht, aeussere Tabelle geschuetzt oder nicht,
|
||||
eingebundene Tabelle geschuetzt oder nicht:
|
||||
|
||||
| Datei | Aeusserer Aufruf | Aeussere Tabelle geschuetzt | Eingebundene Tabelle geschuetzt | Urteil |
|
||||
|---|---|---|---|---|
|
||||
| `tenant.controller.ts` (3 Stellen: `findAll`/`findOne`/`remove`, vor Aufgabe 3) | ungebunden | Nein (`Tenant`) | JA (`User`) | GEFAEHRLICH — die einzige Auspraegung, in Aufgabe 3 behoben |
|
||||
| `ldap-config.service.ts:309` (`getAllActiveConfigs`) | ungebunden (bewusst uebergreifend) | JA (`LdapConfig`) | eingebunden: `tenant`, `fieldMappings` | harmlos — die AEUSSERE Tabelle ist bereits geschuetzt, der bekannte Etappe-3-Fall (Benutzerdimension) bekommt dadurch nichts Neues |
|
||||
| `tenders.controller.ts:612` (`sources`) | ungebunden | Nein (`Tender`, D-03 plattformweit) | Nein (`TenderSource`, ebenfalls plattformweit) | harmlos — beide Seiten plattformweit |
|
||||
| übrige 14 Stellen (`groups.service.ts`, `module-grants.service.ts`, `dashboard.service.ts`, `dkv.service.ts`, `tender-*.service.ts`, `user.service.ts`) | ueberwiegend gebunden oder auf bereits geschuetzten/plattformweiten Tabellen | — | — | harmlos, einzeln nachgesehen |
|
||||
|
||||
Die gefaehrliche Auspraegung (ungebundener aeusserer Aufruf auf einer
|
||||
UNGESCHUETZTEN Tabelle, Einbindung in eine GESCHUETZTE Tabelle) existierte im
|
||||
gesamten API-Quelltext genau EINMAL: in diesem Bereich, vor Aufgabe 3.
|
||||
ENTSCHEIDUNG gegen einen Ledger-Eintrag: die einzige Auspraegung wird in
|
||||
diesem Plan behoben; die Wiederholung beider Messungen zur Ausfuehrungszeit
|
||||
fand keine zweite — faende eine spaetere Wiederholung eine zweite
|
||||
Auspraegung, waere DANN ein `gsd-tools windows append`-Eintrag anzulegen, mit
|
||||
Verweis auf diesen Absatz.
|
||||
|
||||
**(c) Der Fremdschluessel als Rueckhalt, mit einer Luecke.**
|
||||
`User_tenantId_fkey` faengt auch INAKTIVE Benutzer, waehrend der Riegel
|
||||
T-02-09 nur AKTIVE zaehlt — ein Mandant mit ausschliesslich inaktiven
|
||||
Benutzern ist heute wie nach diesem Plan nicht loeschbar (500 statt der
|
||||
verstaendlichen 400). Bestehendes Verhalten, gemessen (Pruefung 7/8 zeigen
|
||||
die Mechanik, nicht diesen Spezialfall direkt), nicht Gegenstand dieses
|
||||
Auftrags.
|
||||
|
||||
**(d) `TenantService.findAll` ohne Aufrufer** (Befund L) — bleibt totes
|
||||
Codeglied, nicht entfernt (Scope).
|
||||
|
||||
**(e) Der unerreichbare `null`-Zweig des Guards** — SUPER_ADMIN ohne
|
||||
`tenantId` und ohne `x-tenant-id`-Header ist mit dem heutigen
|
||||
Sitzungsnachweis unerreichbar (`User.tenantId` ist `String`, nicht nullbar),
|
||||
bleibt aber unveraendert und wird in Aufgabe 2 als heutiges Verhalten
|
||||
getestet, nicht umgebaut.
|
||||
|
||||
**(f) Der Header-Wert wird nicht gegen vorhandene Mandanten geprueft.** Ein
|
||||
SUPER_ADMIN kann per `x-tenant-id` eine erfundene Kennung schicken (D-10 wie
|
||||
entworfen) — sie bindet an einen leeren Kontext, null Zeilen, kein Leck.
|
||||
|
||||
**(g) Die Mehrkosten des Fan-outs.** Nach Aufgabe 3 kostet `findAll` eine
|
||||
gebundene Zaehlabfrage je Mandant statt eines Joins — bei einstelliger
|
||||
Mandantenzahl belanglos, dieselbe Form wie
|
||||
`UserService.findAllForPlatformAdmin`.
|
||||
|
||||
**(h) Die Etappe-4-Vorabpruefung.** Die Benutzerzahl je Mandant ueber die
|
||||
Wartungsrolle gegen die gebundene Fan-out-Zaehlung ist dieselbe Pruefung wie
|
||||
im Bereich `user` — kein eigener Eintrag noetig.
|
||||
|
||||
### (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)`-Klassenregistrierung neben der
|
||||
globalen `APP_GUARD`-Registrierung von `RolesGuard`.
|
||||
- Das Frontend — in (n3) beschrieben, nicht geaendert.
|
||||
- `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,
|
||||
diese Liste bleibt stehen und ist hier als bekannte Ungenauigkeit
|
||||
festgehalten.
|
||||
- Die historische Nennung der Middleware in
|
||||
`docs/mandantentrennung-datenbankrolle.md:124` — beschreibt den Stand VOR
|
||||
Etappe 1 korrekt, nicht zu aendern.
|
||||
- Schema und Migrationen — geprueft und bewusst gelassen, `Tenant` bekommt
|
||||
KEINE Regel.
|
||||
|
||||
## Verweis
|
||||
|
||||
Die Bestandsaufnahme, welche Fundstelle den hier beschriebenen Übergang
|
||||
|
||||
Reference in New Issue
Block a user