docs(quick-260911-nke): Aktenstand kohaerent — Regelschluss Benutzerdimension, Nachtraege, Ledger
Tessera CI/CD / Lint & Type Check (push) Successful in 46s
Tessera CI/CD / Tests (push) Successful in 52s
Tessera CI/CD / Build & Publish Images (push) Successful in 27s

- Neuer Abschnitt "Regelschluss Benutzerdimension (Etappe 3b, 260911-nke)"
  in der Kritikschrift mit b1 (woertliche Werkzeugausgabe + pg_policies-Liste),
  b2 (Signaltabelle beide Fehlerrichtungen), b3 (NotFound-statt-Forbidden
  je Methode), b4/b5 (bewusst nicht geloest/angefasst). Zehn datierte
  Nachtraege an allen Stellen, die zuvor "keine Benutzerdimension" als
  Stand beschrieben (t1/t4/r4/w1/w4/k1/k4/f1/f4/Abschluss) — historische
  Messung bleibt lesbar.
- Klassifikation: drei Bestandsaufnahme-Zeilen (calendarSource,
  widgetInstance, favoriteLink) mit Zusatz "Benutzerdimension seit
  20260911120000 (260911-nke)"; neuer Punkt "Aufgelöst (260911-nke)" im
  Abschnitt "Was diese Etappe NICHT entscheidet"; neuer Stand-Absatz —
  Paarzahl (72) und Klassen-Verteilung bleiben unveraendert.
- Betriebsanleitung: `forTenant(prisma, tenantId, userId?)` und die zehn/
  vier-Tabellen-Aufteilung nachgezogen.
- Datenbankrolle: neuer Absatz zu `app.current_user`/`current_user_id()`
  neben `app.current_tenant`; SECURITY-DEFINER-Kopfkommentare unangetastet.
- Auftrag: 3b als erledigt markiert (Migrationsname, sechs statt drei
  Umkehrungen, Endzahlen); 3a/3c unveraendert.
- WINDOWS.md: neuer Eintrag #34 (open, deviation) fuer die bewusst offene
  Flanke — Aufrufer ohne userId sieht den ganzen Mandanten, kein Waechter
  gebaut.
- Baseline: 1020/62 Tests, Typpruefung sauber, Werkzeug 203/203 bestanden.
  Erlaubnisliste gegen 8829999 eingehalten, schema.prisma/Compose/.env/3a
  unveraendert.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01AMASaSxv5QMY7RncqZriRR
This commit is contained in:
2026-09-11 17:47:14 +02:00
parent 07fc653f52
commit b62a905adb
6 changed files with 292 additions and 8 deletions
+16 -3
View File
@@ -1,10 +1,10 @@
--- ---
schema_version: 1 schema_version: 1
open_count: 14 open_count: 15
waived_count: 1 waived_count: 1
fixed_count: 18 fixed_count: 18
total_count: 33 total_count: 34
last_updated: 2026-09-11T14:48:15.447Z last_updated: 2026-09-11T15:46:08.295Z
--- ---
# Broken Windows Ledger # Broken Windows Ledger
@@ -48,6 +48,7 @@ last_updated: 2026-09-11T14:48:15.447Z
| 31 | quick-260911-gwh | deviation | apps/web/src/components/dashboard/widgets/favorites-widget.tsx | | Bereich favorites: ein nach dem Scharfschalten (#18) zu klein gebliebenes Leseergebnis auf list() sieht aus wie 'Noch keine Favoriten.' (favorites-widget.tsx Zeile um 212, de.json favorites.empty) -- fetchFavorites (favorites-api.ts) reicht die leere Liste durch. 'Nie einen gespeichert' und 'Zeile unsichtbar' sind fuer das Frontend derselbe Wert []. Etappe-4-Vorabpruefung (f4)(d): fuer einen bekannten Nutzer/Widget die Favoritenzahl ueber die Wartungsrolle und ueber den gebundenen findMany daneben halten. Familie #23/#25/#26/#28. Das Frontend wird von 260911-gwh NICHT geaendert. Siehe docs/mandantentrennung-etappe2-fehlerrichtung.md, Abschnitt 'Bereich favorites' (f3)/(f4). | open | | 2026-09-11T11:57:50.276Z | | | 31 | quick-260911-gwh | deviation | apps/web/src/components/dashboard/widgets/favorites-widget.tsx | | Bereich favorites: ein nach dem Scharfschalten (#18) zu klein gebliebenes Leseergebnis auf list() sieht aus wie 'Noch keine Favoriten.' (favorites-widget.tsx Zeile um 212, de.json favorites.empty) -- fetchFavorites (favorites-api.ts) reicht die leere Liste durch. 'Nie einen gespeichert' und 'Zeile unsichtbar' sind fuer das Frontend derselbe Wert []. Etappe-4-Vorabpruefung (f4)(d): fuer einen bekannten Nutzer/Widget die Favoritenzahl ueber die Wartungsrolle und ueber den gebundenen findMany daneben halten. Familie #23/#25/#26/#28. Das Frontend wird von 260911-gwh NICHT geaendert. Siehe docs/mandantentrennung-etappe2-fehlerrichtung.md, Abschnitt 'Bereich favorites' (f3)/(f4). | open | | 2026-09-11T11:57:50.276Z | |
| 32 | quick-260911-gwh | deviation | apps/web/src/components/settings/smtp-settings-form.tsx | | Bereich settings: getSmtpConfig liefert nach dem Scharfschalten (#18) null, der Controller antwortet 200 mit leerem Rumpf, fetchSmtp (settings-api.ts) laeuft mit res.json() auf den leeren Rumpf und wirft, smtp-settings-form.tsx verschluckt das in .catch(() => {}) -- leeres Formular 'nicht eingerichtet', waehrend die Zugangsdaten physisch da sind. Ein erneutes Speichern unter der ungebundenen Form scheitert am Eindeutigkeitsindex SmtpConfig_tenantId_key (PrismaClientUnknownRequestError, gemessen in Aufgabe 1 Pruefung 8) -- nach diesem Lauf ist saveSmtpConfig gebunden und trifft die eigene Zeile, dieser Rest bestand nur unter der ungebundenen Form vor dieser Aenderung. Dieselbe 200-leerer-Rumpf-Kette wie #28. Etappe-4-Vorabpruefung (s4)(e). Das Frontend wird von 260911-gwh NICHT geaendert. Siehe docs/mandantentrennung-etappe2-fehlerrichtung.md, Abschnitt 'Bereich settings' (s3)/(s4). | open | | 2026-09-11T11:57:50.484Z | | | 32 | quick-260911-gwh | deviation | apps/web/src/components/settings/smtp-settings-form.tsx | | Bereich settings: getSmtpConfig liefert nach dem Scharfschalten (#18) null, der Controller antwortet 200 mit leerem Rumpf, fetchSmtp (settings-api.ts) laeuft mit res.json() auf den leeren Rumpf und wirft, smtp-settings-form.tsx verschluckt das in .catch(() => {}) -- leeres Formular 'nicht eingerichtet', waehrend die Zugangsdaten physisch da sind. Ein erneutes Speichern unter der ungebundenen Form scheitert am Eindeutigkeitsindex SmtpConfig_tenantId_key (PrismaClientUnknownRequestError, gemessen in Aufgabe 1 Pruefung 8) -- nach diesem Lauf ist saveSmtpConfig gebunden und trifft die eigene Zeile, dieser Rest bestand nur unter der ungebundenen Form vor dieser Aenderung. Dieselbe 200-leerer-Rumpf-Kette wie #28. Etappe-4-Vorabpruefung (s4)(e). Das Frontend wird von 260911-gwh NICHT geaendert. Siehe docs/mandantentrennung-etappe2-fehlerrichtung.md, Abschnitt 'Bereich settings' (s3)/(s4). | open | | 2026-09-11T11:57:50.484Z | |
| 33 | quick-260911-mkj | unmet-truth | apps/api/src/tenders/tenders.seed.ts | | Modellaufrufe auf Empfaengern, die weder this.prisma noch eine const X = forTenant(-Zuweisung noch ein Transaktionsparameter sind, sind fuer ALLE vier Erkennungsformen der Bestandsaufnahme unsichtbar. Gemessen 260911-mkj: tenders/tenders.seed.ts (Funktionsparameter prisma: PrismaService, tenderRssFeedSource.findFirst/create, kein Eintrag in der Bestandsaufnahme) und tenders/backfill-tender-source.ts (eigenstaendiges Skript mit new PrismaClient(), tender.findMany/update, durch RELATION_SPEC_EXCEPTIONS laut gehalten). Beide beruehren nur den plattformglobalen Katalog bzw. die plattformweite RSS-Verwaltung (WINDOWS #24), heute ungefaehrlich; OFFEN ist der Mechanismus (ein kuenftiger Dienst mit Parameter-Empfaenger auf einer geschuetzten Tabelle bliebe unsichtbar). Zu schliessen vor Etappe 4 durch eine Zaehlung ALLER <Kennung>.<Modell>.<Operation>(-Anker gegen die bekannte Empfaengermenge, Ueberschuss laut. | open | | 2026-09-11T14:48:09.723Z | | | 33 | quick-260911-mkj | unmet-truth | apps/api/src/tenders/tenders.seed.ts | | Modellaufrufe auf Empfaengern, die weder this.prisma noch eine const X = forTenant(-Zuweisung noch ein Transaktionsparameter sind, sind fuer ALLE vier Erkennungsformen der Bestandsaufnahme unsichtbar. Gemessen 260911-mkj: tenders/tenders.seed.ts (Funktionsparameter prisma: PrismaService, tenderRssFeedSource.findFirst/create, kein Eintrag in der Bestandsaufnahme) und tenders/backfill-tender-source.ts (eigenstaendiges Skript mit new PrismaClient(), tender.findMany/update, durch RELATION_SPEC_EXCEPTIONS laut gehalten). Beide beruehren nur den plattformglobalen Katalog bzw. die plattformweite RSS-Verwaltung (WINDOWS #24), heute ungefaehrlich; OFFEN ist der Mechanismus (ein kuenftiger Dienst mit Parameter-Empfaenger auf einer geschuetzten Tabelle bliebe unsichtbar). Zu schliessen vor Etappe 4 durch eine Zaehlung ALLER <Kennung>.<Modell>.<Operation>(-Anker gegen die bekannte Empfaengermenge, Ueberschuss laut. | open | | 2026-09-11T14:48:09.723Z | |
| 34 | quick-260911-nke | deviation | apps/api/src/prisma/prisma-tenant.extension.ts | | Etappe 3b: ein Nutzer-CRUD-Aufrufer, der den Benutzer an forTenant() vergisst, sieht den ganzen Mandanten (IS-NULL-Form) — gleicher Stand wie vor 20260911120000, keine Verschlechterung, aber kein Netz. Die Bestandsaufnahme unterscheidet nur mandanten-gebunden/ungebunden, nicht benutzer-gebunden; ein Waechter, der jede Methode mit userId-Parameter auf das dritte Argument prueft, ist NICHT gebaut. Bis dahin sind die dreistelligen Spec-Zusicherungen je Dienst das einzige Netz. Vor dem Scharfschalten (Etappe 4, rls-preflight.mjs) zu entscheiden: Waechter bauen oder Rest benennen. | open | | 2026-09-11T15:46:08.295Z | |
````json ````json
[ [
@@ -446,6 +447,18 @@ last_updated: 2026-09-11T14:48:15.447Z
"reason": "", "reason": "",
"recorded_at": "2026-09-11T14:48:09.723Z", "recorded_at": "2026-09-11T14:48:09.723Z",
"resolved_at": null "resolved_at": null
},
{
"id": 34,
"kind": "deviation",
"phase": "quick-260911-nke",
"file": "apps/api/src/prisma/prisma-tenant.extension.ts",
"line": null,
"description": "Etappe 3b: ein Nutzer-CRUD-Aufrufer, der den Benutzer an forTenant() vergisst, sieht den ganzen Mandanten (IS-NULL-Form) — gleicher Stand wie vor 20260911120000, keine Verschlechterung, aber kein Netz. Die Bestandsaufnahme unterscheidet nur mandanten-gebunden/ungebunden, nicht benutzer-gebunden; ein Waechter, der jede Methode mit userId-Parameter auf das dritte Argument prueft, ist NICHT gebaut. Bis dahin sind die dreistelligen Spec-Zusicherungen je Dienst das einzige Netz. Vor dem Scharfschalten (Etappe 4, rls-preflight.mjs) zu entscheiden: Waechter bauen oder Rest benennen.",
"status": "open",
"reason": "",
"recorded_at": "2026-09-11T15:46:08.295Z",
"resolved_at": null
} }
] ]
```` ````
+14 -2
View File
@@ -320,8 +320,20 @@ die vollständige, maschinell geprüfte Liste — von dort ableiten, nicht raten
**Was ein Entwickler nie vergessen darf:** jeder Zugriff auf eine mandantengebundene Tabelle läuft **Was ein Entwickler nie vergessen darf:** jeder Zugriff auf eine mandantengebundene Tabelle läuft
dienst-intern über einen mit `forTenant()` gebundenen Klienten `tenantPrisma` dienst-intern über einen mit `forTenant()` gebundenen Klienten `tenantPrisma`
(`apps/api/src/prisma/prisma-tenant.extension.ts`) — die zusätzlichen `where`-Filter über (`apps/api/src/prisma/prisma-tenant.extension.ts`) — `forTenant(prisma, tenantId, userId?)`
`userId` bleiben bestehen, wo die Regel selbst keine Benutzerdimension kennt (siehe trägt seit Migration `20260911120000_rls_user_dimension_personal_tables` (Etappe 3b,
260911-nke) einen optionalen dritten Parameter: zehn persönliche Tabellen
(CalendarSource, DashboardLayout, FavoriteLink, SearchProvider,
TenderEmailConfig, TenderNotificationPref, TenderRssFeedSource,
TenderSavedSearch, TenderTriage, WidgetInstance) tragen die Benutzerdimension
in der Regel (`current_user_id() IS NULL OR "userId" = current_user_id()`),
vier Tabellen mit `userId`-Spalte aber ohne persönliche Daten
(GroupMembership, ModuleGrant, PasswordResetToken, TenderMatch) nicht. Nur
Nutzer-CRUD-Aufrufer setzen `userId`; Hintergrunddienste und Verwaltungswege
rufen weiterhin ohne ihn — das macht die `IS NULL OR`-Form fuer sie
wirkungslos, keine Verschlechterung. Die zusätzlichen `where`-Filter über
`userId` im Anwendungscode bleiben in JEDEM Fall bestehen — zweites Netz,
kein Ersatz (siehe
`docs/mandantentrennung-etappe2-fehlerrichtung.md`). Bei den Tabellen ohne eigene `tenantId` `docs/mandantentrennung-etappe2-fehlerrichtung.md`). Bei den Tabellen ohne eigene `tenantId`
(oben) filtert die Anwendung stattdessen — wo relevant — über den zutreffenden Bezug (z. B. (oben) filtert die Anwendung stattdessen — wo relevant — über den zutreffenden Bezug (z. B.
plattformweiter Katalog, kein Mandantenfilter nötig); siehe plattformweiter Katalog, kein Mandantenfilter nötig); siehe
+18
View File
@@ -67,6 +67,24 @@ noetig sind:
`20260909140000_rls_remaining_tenant_tables` ergaenzt die bislang `20260909140000_rls_remaining_tenant_tables` ergaenzt die bislang
fehlenden 16 Tabellen; alle 20 Tabellen mit `tenantId` tragen jetzt eine fehlenden 16 Tabellen; alle 20 Tabellen mit `tenantId` tragen jetzt eine
Regel. Regel.
- **Eine zweite Sitzungsvariable fuer die Benutzerdimension.** Neben
`app.current_tenant` (Migration `20260618112133_rls_policies`,
`current_tenant_id()`) setzt die Rolle seit Migration
`20260911120000_rls_user_dimension_personal_tables` (Etappe 3b,
260911-nke) zusaetzlich `app.current_user`. Die zugehoerige Funktion
`current_user_id()` faltet den Leerstring per `NULLIF` auf `NULL` — der
Helfer `forTenant()` sendet "kein Benutzer" ausdruecklich als Leerstring,
nicht als weggelassene Variable, damit ein Aufruf ohne Benutzer nie einen
Benutzer aus einer fruaheren Transaktion derselben Verbindung erben kann.
Kein `GRANT EXECUTE` noetig — wie bei `current_tenant_id()` vergibt
PostgreSQL EXECUTE auf Funktionen standardmaessig an PUBLIC. Die Regeln
der zehn persoenlichen Tabellen (CalendarSource, DashboardLayout,
FavoriteLink, SearchProvider, TenderEmailConfig, TenderNotificationPref,
TenderRssFeedSource, TenderSavedSearch, TenderTriage, WidgetInstance)
pruefen `current_user_id() IS NULL OR "userId" = current_user_id()` —
ein Aufruf OHNE gesetzten Benutzer (Admin, Hintergrunddienst) sieht
weiterhin den ganzen Mandanten, das macht die Aenderung fuer heutige
Aufrufer wirkungslos.
## 3. Der Sperrgrund — warum die Umstellung noch nicht erfolgt ist ## 3. Der Sperrgrund — warum die Umstellung noch nicht erfolgt ist
@@ -460,6 +460,14 @@ Bereich brauchte:
anwendungsseitige `userId`-Filterung, die alle fünf umzustellenden anwendungsseitige `userId`-Filterung, die alle fünf umzustellenden
Dienste bereits führen, bleibt deshalb der einzige Schutz gegen Dienste bereits führen, bleibt deshalb der einzige Schutz gegen
Quer-Lesen zwischen Nutzern und wird bei der Umstellung NICHT entfernt. Quer-Lesen zwischen Nutzern und wird bei der Umstellung NICHT entfernt.
**Nachtrag (260911-nke):** seit Migration `20260911120000_rls_user_dimension_personal_tables`
trägt die Regel auf `TenderSavedSearch` die Benutzerdimension — die alte
Messung bleibt unter dem Namen `tendersavedsearch-ohne-benutzer-sieht-beide-nutzer-desselben-mandanten`
als die gewollte Eigenschaft für Admin/Hintergrunddienst bestehen, die
Umkehrung `tendersavedsearch-benutzer-a-sieht-kollegen-nicht-gebunden`
misst MIT Benutzer und erwartet das Gegenteil. Siehe Abschnitt
"Regelschluss Benutzerdimension (Etappe 3b, 260911-nke)" unten.
- `tenderrssfeed-plattformzeile-unter-jedem-mandanten-unsichtbar` — die - `tenderrssfeed-plattformzeile-unter-jedem-mandanten-unsichtbar` — die
plattformweite RSS-Zeile (`userId`/`tenantId` beide `NULL`, wie der plattformweite RSS-Zeile (`userId`/`tenantId` beide `NULL`, wie der
geseedete `service.bund.de`-Feed) ist unter TENANT-A UND TENANT-B geseedete `service.bund.de`-Feed) ist unter TENANT-A UND TENANT-B
@@ -654,6 +662,15 @@ Warnung wäre Dauerlärm und verlöre ihr Signal.
Zuständigkeitsfrage im Umfeld von WINDOWS #19, kein Auftrag dieser Zuständigkeitsfrage im Umfeld von WINDOWS #19, kein Auftrag dieser
Aufgabe. Aufgabe.
**Nachtrag (260911-nke):** die Benutzerdimension der `TenderRssFeedSource`-Regeln
ist seit Migration `20260911120000_rls_user_dimension_personal_tables`
Teil der vier befehlsgetrennten Regeln (Lesen schließt gemeinsame/eigene
Zeilen ein, Schreiben verlangt weiterhin `userId = current_user_id()`),
gemessen in `tenderrssfeed-gemeinsame-zeile-*`. Befund G (Admin eines
beliebigen Mandanten entfernt eine plattformweite Zeile) bleibt
unverändert — WINDOWS #24 hält das offen, siehe Abschnitt "Regelschluss
Benutzerdimension (Etappe 3b, 260911-nke)" unten.
### (t5) Was dieser Durchlauf bewusst nicht anfasst ### (t5) Was dieser Durchlauf bewusst nicht anfasst
Die zwölf Paare des plattformweiten Ausschreibungskatalogs (D-03, Die zwölf Paare des plattformweiten Ausschreibungskatalogs (D-03,
@@ -1749,6 +1766,13 @@ Tabellen, inklusive der NEUEN Stelle aus Befund F:
eine Benutzerdimension auf Datenbankebene durchzusetzen, ohne eine solche eine Benutzerdimension auf Datenbankebene durchzusetzen, ohne eine solche
Variable erst einzuführen. Nicht gebaut in diesem Durchlauf — die Variable erst einzuführen. Nicht gebaut in diesem Durchlauf — die
anwendungsseitige `userId`-Filterung bleibt der einzige Schutz. anwendungsseitige `userId`-Filterung bleibt der einzige Schutz.
**Nachtrag (260911-nke): ÜBERHOLT — die zweite Sitzungsvariable ist
gebaut.** Migration `20260911120000_rls_user_dimension_personal_tables`
führt `app.current_user`/`current_user_id()` ein und trägt die
Benutzerdimension in die Regeln der zehn persönlichen Tabellen, darunter
`TenderSavedSearch`. Siehe Abschnitt "Regelschluss Benutzerdimension
(Etappe 3b, 260911-nke)" unten.
- **Die plattformweite Eindeutigkeit von Anmeldename und Adresse (WINDOWS - **Die plattformweite Eindeutigkeit von Anmeldename und Adresse (WINDOWS
#22)** — unverändert, nicht Gegenstand dieses Plans. #22)** — unverändert, nicht Gegenstand dieses Plans.
@@ -1809,6 +1833,21 @@ searchprovider-gebundenes-einfuegen-ohne-mandant-abgelehnt: bestanden — gebund
Alle 87 Pruefungen bestanden. Alle 87 Pruefungen bestanden.
``` ```
**Nachtrag (260911-nke):** die drei oben zitierten Ausgabezeilen
(`dashboardlayout-fremder-nutzer-desselben-mandanten-gebunden-sichtbar`,
`widgetinstance-fremder-nutzer-desselben-mandanten-gebunden-sichtbar`,
`searchprovider-fremder-nutzer-desselben-mandanten-gebunden-sichtbar`)
bleiben als historische Messung stehen — sie sind seit Migration
`20260911120000_rls_user_dimension_personal_tables` UMGEDREHT, nicht
gelöscht: die alte Messung lebt unter den neuen Namen
`dashboardlayout-ohne-benutzer-sieht-beide-nutzer-desselben-mandanten`,
`widgetinstance-ohne-benutzer-sieht-beide-nutzer-desselben-mandanten` und
`searchprovider-ohne-benutzer-sieht-beide-nutzer-desselben-mandanten`
weiter (jetzt als gewollte Eigenschaft für Admin/Hintergrunddienst), dazu je
eine neue Umkehrung `<tabelle>-benutzer-a-sieht-kollegen-nicht-gebunden` MIT
Benutzer. Siehe Abschnitt "Regelschluss Benutzerdimension (Etappe 3b,
260911-nke)" unten.
Dreizehn neue Prüfungen, nicht zwölf wie in der Aufzählung des Plans Dreizehn neue Prüfungen, nicht zwölf wie in der Aufzählung des Plans
namentlich vorgezeichnet — die dreizehnte namentlich vorgezeichnet — die dreizehnte
(`widgetinstance-fremder-nutzer-desselben-mandanten-gebunden-sichtbar`) (`widgetinstance-fremder-nutzer-desselben-mandanten-gebunden-sichtbar`)
@@ -1984,6 +2023,15 @@ Abweichung von seiner eigenen Auswahl liest, bleibt das unbemerkt.
gebunden wie #18 — er wird erst gebunden wie #18 — er wird erst
nach dem Scharfschalten beobachtbar. nach dem Scharfschalten beobachtbar.
**Nachtrag (260911-nke):** die Benutzerdimension der Regeln auf
`DashboardLayout`/`WidgetInstance`/`SearchProvider` (Lesen UND Schreiben
je Kollegenzeile) ist seit Migration `20260911120000_rls_user_dimension_personal_tables`
geschlossen — siehe (w1) oben und Abschnitt "Regelschluss
Benutzerdimension (Etappe 3b, 260911-nke)" unten. Die plattformweite
Eindeutigkeit von `DashboardLayout.userId` (Befund K, dieser Punkt) ist
davon UNBERÜHRT und bleibt für Etappe 3 vorgemerkt — 3b ändert keine
Unique-Constraints.
### (w5) Was dieser Durchlauf bewusst nicht anfasst ### (w5) Was dieser Durchlauf bewusst nicht anfasst
- **Das Frontend** — geprüft (Befund I/J, (w3) oben) und bewusst gelassen, - **Das Frontend** — geprüft (Befund I/J, (w3) oben) und bewusst gelassen,
@@ -2086,6 +2134,16 @@ calendarsource-generierter-client-gebundenes-anlegen-eigener-mandant-gelingt: be
Alle 101 Pruefungen bestanden. Alle 101 Pruefungen bestanden.
``` ```
**Nachtrag (260911-nke):** `calendarsource-fremder-nutzer-desselben-mandanten-gebunden-sichtbar`
ist seit Migration `20260911120000_rls_user_dimension_personal_tables`
UMGEDREHT — die alte Messung lebt unter dem Namen
`calendarsource-ohne-benutzer-sieht-beide-nutzer-desselben-mandanten`
weiter (gewollte Eigenschaft ohne Benutzer), die neue Umkehrung
`calendarsource-benutzer-a-sieht-kollegen-nicht-gebunden` misst MIT
Benutzer und bestätigt, dass `encryptedPassword` eines Kollegen jetzt NICHT
mehr lesbar ist. Siehe Abschnitt "Regelschluss Benutzerdimension (Etappe
3b, 260911-nke)" unten.
Dreizehn neue Prüfungen (101 = 88 + 13), nicht zwölf wie in der Aufzählung Dreizehn neue Prüfungen (101 = 88 + 13), nicht zwölf wie in der Aufzählung
des Plans namentlich vorgezeichnet — die dreizehnte des Plans namentlich vorgezeichnet — die dreizehnte
(`calendarsource-regelstand-eindeutig`) wurde ergänzt, weil sie die (`calendarsource-regelstand-eindeutig`) wurde ergänzt, weil sie die
@@ -2235,6 +2293,14 @@ Browsers und im API-Log, an keiner Stelle in der Benutzeroberfläche.
aufnimmt (`CalendarSource` steht dort ausdrücklich in der Liste). Bis aufnimmt (`CalendarSource` steht dort ausdrücklich in der Liste). Bis
dahin bleiben der `userId`-Filter in `getSources`/`fetchAndCacheEvents` dahin bleiben der `userId`-Filter in `getSources`/`fetchAndCacheEvents`
und die drei Besitzprüfungen der EINZIGE Schutz. und die drei Besitzprüfungen der EINZIGE Schutz.
**Nachtrag (260911-nke): ÜBERHOLT — die Benutzerdimension ist in der
Regel.** Migration `20260911120000_rls_user_dimension_personal_tables`
trägt `current_user_id() IS NULL OR "userId" = current_user_id()` in die
`CalendarSource`-Regel; `calendarsource-benutzer-a-sieht-kollegen-nicht-gebunden`
bestätigt, dass die verschlüsselten Zugangsdaten eines Kollegen jetzt
NICHT mehr lesbar sind. Der `userId`-Filter und die Besitzprüfungen
bleiben zusätzlich bestehen (zweites Netz, kein Ersatz).
- **(d) 403 statt 404 als Existenzpreisgabe zwischen Kollegen (Befund D):** - **(d) 403 statt 404 als Existenzpreisgabe zwischen Kollegen (Befund D):**
`updateSource`, `deleteSource` und `testConnection` werfen bei fremdem `updateSource`, `deleteSource` und `testConnection` werfen bei fremdem
Besitz `ForbiddenException('Not your calendar source')` (403), bei Besitz `ForbiddenException('Not your calendar source')` (403), bei
@@ -2749,6 +2815,17 @@ favoritelink-gebundenes-anlegen-eigener-mandant-gelingt: bestanden — bound.fav
Alle 137 Pruefungen bestanden. Alle 137 Pruefungen bestanden.
``` ```
**Nachtrag (260911-nke):** die Doppelaussage in
`favoritelink-liste-generierter-client-gebunden-eigener-mandant-liefert-eigene-zeilen`
ist GETRENNT — die Prüfung behält nur die erste Hälfte (die eigenen Zeilen
kommen). Die zweite Hälfte (Kollege sichtbar) lebt jetzt als eigene Prüfung
`favoritelink-ohne-benutzer-sieht-beide-nutzer-desselben-mandanten` (alte
Messung, gewollte Eigenschaft ohne Benutzer) plus die Umkehrung
`favoritelink-benutzer-a-sieht-kollegen-nicht-gebunden` (MIT Benutzer,
Kollege NICHT mehr sichtbar) — seit Migration
`20260911120000_rls_user_dimension_personal_tables`. Siehe Abschnitt
"Regelschluss Benutzerdimension (Etappe 3b, 260911-nke)" unten.
Die tragende Belegzeile ist `favoritelink-liste-generierter-client-ungebunden-liefert-leere-liste`: der Die tragende Belegzeile ist `favoritelink-liste-generierter-client-ungebunden-liefert-leere-liste`: der
IDENTISCHE `findMany`, den `list` heute stellt, liefert UNGEBUNDEN `[]`, IDENTISCHE `findMany`, den `list` heute stellt, liefert UNGEBUNDEN `[]`,
während die Wartungsrolle zwei Zeilen sieht — das ist der Wert, aus dem während die Wartungsrolle zwei Zeilen sieht — das ist der Wert, aus dem
@@ -2817,6 +2894,14 @@ Scharfschalten neu entsteht.
`userId`-Filterung bleibt bestehen und ist bis zur Etappe-3-Entscheidung `userId`-Filterung bleibt bestehen und ist bis zur Etappe-3-Entscheidung
(2) der einzige Schutz gegen Quer-Lesen zwischen Nutzern desselben (2) der einzige Schutz gegen Quer-Lesen zwischen Nutzern desselben
Mandanten. Mandanten.
**Nachtrag (260911-nke): ÜBERHOLT — die Benutzerdimension ist in der
Regel.** Migration `20260911120000_rls_user_dimension_personal_tables`
trägt `current_user_id() IS NULL OR "userId" = current_user_id()` in die
`FavoriteLink`-Regel; `favoritelink-benutzer-a-sieht-kollegen-nicht-gebunden`
bestätigt, dass die Kollegenzeile über die `widgetId` jetzt NICHT mehr
sichtbar ist. Die anwendungsseitige `userId`-Filterung bleibt zusätzlich
bestehen (zweites Netz, kein Ersatz).
- **(b) Das Frontend.** Die `list`-Kette aus (f3) wird nicht geändert; - **(b) Das Frontend.** Die `list`-Kette aus (f3) wird nicht geändert;
Ledger-Eintrag in Aufgabe 3. Ledger-Eintrag in Aufgabe 3.
- **(c) Die Mandantenquelle — warum der dashboard-Präzedenzfall und nicht - **(c) Die Mandantenquelle — warum der dashboard-Präzedenzfall und nicht
@@ -3029,6 +3114,118 @@ unverändert und steht nicht in der Erlaubnisliste.
(lokal gibt es keinen `mailhog`); in der neuen Testdatei per (lokal gibt es keinen `mailhog`); in der neuen Testdatei per
`vi.mock('nodemailer')` ersetzt. `vi.mock('nodemailer')` ersetzt.
## Regelschluss Benutzerdimension (Etappe 3b, 260911-nke)
Reiner Datenbank- und Helfer-Umbau: Migration `20260911120000_rls_user_dimension_personal_tables`
bringt die Funktion `current_user_id()` und die Benutzerdimension in die
Regeln der zehn persönlichen Tabellen. `forTenant(prisma, tenantId, userId?)`
bekommt einen optionalen dritten Parameter; 34 Nutzer-CRUD-Aufrufstellen in
acht Diensten reichen ihn durch. Der Schalter bleibt AUS — nichts hiervon
wirkt, bis Etappe 4 scharfschaltet.
### (b1) Die Messung
Wörtliche Werkzeugausgabe der drei Funktionsfälle:
```
current-user-id-ungesetzt-ist-null: bestanden — current_user_id() ohne gesetzte Variable=null
current-user-id-leer-ist-null: bestanden — current_user_id() nach set_config('app.current_user', '', true)=null
current-user-id-gesetzt-liefert-wert: bestanden — current_user_id() nach set_config('app.current_user', 'user-a1', true)="user-a1"
```
Wörtliche Werkzeugausgabe zweier der sechs Umkehrungen (eine Ein-Regel-Tabelle,
eine der beiden vier-Regel-Tabellen):
```
tendersavedsearch-benutzer-a-sieht-kollegen-nicht-gebunden: bestanden — forTenant(TENANT-A, user-a1) liefert sichtbare Nutzer: ["user-a1"]
calendarsource-benutzer-a-sieht-kollegen-nicht-gebunden: bestanden — forTenant(TENANT-A, user-a1) liefert die Quelle von 'user-a2' mit: undefined — encryptedPassword des Kollegen ist damit auf Datenbankebene nicht mehr lesbar
searchprovider-gemeinsame-zeile-als-benutzer-a-nicht-entfernbar: bestanden — bound(TENANT-A, user-a1).searchProvider.deleteMany({ id: 'search-shared-a' }) liefert count=0 — eine Regel ohne Befehlstrennung wuerde hier 1 liefern
tenderrssfeed-gemeinsame-zeile-ohne-benutzer-weiterhin-entfernbar: bestanden — bound(TENANT-A, ohne Benutzer).tenderRssFeedSource.deleteMany({ id: 'rss-platform' }) liefert count=0 — WINDOWS #24: die plattformweite Zeile hat keinen Mandanten, die Schreibregel verlangt aber einen; das gilt VOR wie NACH dieser Migration unveraendert und ist kein neu entdecktes Loch
Alle 203 Pruefungen bestanden.
```
`pg_policies` der lebenden Datenbank (`tessera-ctl-db-1`) für die zehn
umgestellten und die vier bewusst unveränderten Tabellen, gemessen nach dem
Anwenden von `20260911120000` (Tabelle#Regelname#Befehl#USING#WITH CHECK):
```
CalendarSource#tenant_isolation_policy#ALL#(("tenantId" = current_tenant_id()) AND ((current_user_id() IS NULL) OR ("userId" = current_user_id())))#
DashboardLayout#tenant_isolation_policy#ALL#(("tenantId" = current_tenant_id()) AND ((current_user_id() IS NULL) OR ("userId" = current_user_id())))#
FavoriteLink#tenant_isolation_policy#ALL#(("tenantId" = current_tenant_id()) AND ((current_user_id() IS NULL) OR ("userId" = current_user_id())))#
GroupMembership#tenant_isolation_policy#ALL#(("groupId" IN ( SELECT "Group".id FROM "Group" WHERE ("Group"."tenantId" = current_tenant_id()))) AND ("userId" IN ( SELECT "User".id FROM "User" WHERE ("User"."tenantId" = current_tenant_id()))))#
ModuleGrant#tenant_isolation_policy#ALL#(("tenantId" = current_tenant_id()) AND (("groupId" IS NULL) OR ("groupId" IN ( SELECT "Group".id FROM "Group" WHERE ("Group"."tenantId" = current_tenant_id())))) AND (("userId" IS NULL) OR ("userId" IN ( SELECT "User".id FROM "User" WHERE ("User"."tenantId" = current_tenant_id())))))#
PasswordResetToken#tenant_isolation_policy#ALL#("userId" IN ( SELECT "User".id FROM "User" WHERE ("User"."tenantId" = current_tenant_id())))#
SearchProvider#tenant_user_delete_policy#DELETE#(("tenantId" = current_tenant_id()) AND ((current_user_id() IS NULL) OR ("userId" = current_user_id())))#
SearchProvider#tenant_user_insert_policy#INSERT##(("tenantId" = current_tenant_id()) AND ((current_user_id() IS NULL) OR ("userId" = current_user_id())))
SearchProvider#tenant_user_read_policy#SELECT#(("tenantId" = current_tenant_id()) AND ((current_user_id() IS NULL) OR ("userId" IS NULL) OR ("userId" = current_user_id())))#
SearchProvider#tenant_user_update_policy#UPDATE#(("tenantId" = current_tenant_id()) AND ((current_user_id() IS NULL) OR ("userId" = current_user_id())))#(("tenantId" = current_tenant_id()) AND ((current_user_id() IS NULL) OR ("userId" = current_user_id())))
TenderEmailConfig#tenant_isolation_policy#ALL#(("tenantId" = current_tenant_id()) AND ((current_user_id() IS NULL) OR ("userId" = current_user_id())))#
TenderMatch#tenant_isolation_policy#ALL#("tenantId" = current_tenant_id())#
TenderNotificationPref#tenant_isolation_policy#ALL#(("tenantId" = current_tenant_id()) AND ((current_user_id() IS NULL) OR ("userId" = current_user_id())))#
TenderRssFeedSource#tenant_delete_policy#DELETE#(("tenantId" = current_tenant_id()) AND ((current_user_id() IS NULL) OR ("userId" = current_user_id())))#
TenderRssFeedSource#tenant_insert_policy#INSERT##(("tenantId" = current_tenant_id()) AND ((current_user_id() IS NULL) OR ("userId" = current_user_id())))
TenderRssFeedSource#tenant_platform_read_policy#SELECT#((("tenantId" = current_tenant_id()) OR ("tenantId" IS NULL)) AND ((current_user_id() IS NULL) OR ("userId" IS NULL) OR ("userId" = current_user_id())))#
TenderRssFeedSource#tenant_update_policy#UPDATE#(("tenantId" = current_tenant_id()) AND ((current_user_id() IS NULL) OR ("userId" = current_user_id())))#(("tenantId" = current_tenant_id()) AND ((current_user_id() IS NULL) OR ("userId" = current_user_id())))
TenderSavedSearch#tenant_isolation_policy#ALL#(("tenantId" = current_tenant_id()) AND ((current_user_id() IS NULL) OR ("userId" = current_user_id())))#
TenderTriage#tenant_isolation_policy#ALL#(("tenantId" = current_tenant_id()) AND ((current_user_id() IS NULL) OR ("userId" = current_user_id())))#
WidgetInstance#tenant_isolation_policy#ALL#(("tenantId" = current_tenant_id()) AND ((current_user_id() IS NULL) OR ("userId" = current_user_id())))#
```
Werkzeug-Endstand nach Aufgabe 2: `Alle 203 Pruefungen bestanden.` (Baseline vor
diesem Lauf: 137). Tests am Ende von Aufgabe 2: 1020 bestanden / 62 Dateien,
Typprüfung sauber.
### (b2) Signaltabelle — beide Fehlerrichtungen je Regel
| Fehlerrichtung | Erwartung | Gemessen | Befund |
|---|---|---|---|
| Zu streng: ein Aufruf OHNE Benutzer (Admin, Hintergrunddienst) sähe nur EINEN Nutzer statt des ganzen Mandanten | Regel müsste beide Nutzer weiterhin liefern | `<tabelle>-ohne-benutzer-sieht-beide` (zehn Tabellen) — JEDE Tabelle liefert beide Nutzer | NICHT der Fall — die `IS NULL OR`-Form wirkt wie entworfen |
| Zu locker: ein Aufruf MIT Benutzer sähe die Zeile eines Kollegen DESSELBEN Mandanten | Regel müsste die Kollegenzeile ausblenden | `<tabelle>-benutzer-a-sieht-kollegen-nicht(-gebunden)` (zehn Tabellen) — JEDE Tabelle blendet aus; `<tabelle>-schreiben-als-a-mit-kennung-b-abgelehnt` (SQLSTATE 42501) für alle zehn | NICHT der Fall — Lesen UND Schreiben sind durchgesetzt |
| Bewusst offene Flanke: ein Nutzer-CRUD-Aufrufer, der `userId` VERGISST | Sähe den ganzen Mandanten, keine Fehlermeldung | Nicht durch einen Test erzwungen — kein Wächter über das dritte Argument gebaut | Akzeptiert, aufgezeichnet (T-NKE-02, WINDOWS-Eintrag unten) — heute exakt der Stand vor dieser Migration |
### (b3) Welcher Code Leere anders deutet als vorher
Erst wirksam NACH dem Scharfschalten (Etappe 4) — heute mit BYPASSRLS ohne
Wirkung, hier vorab aufgezeichnet, weil der Codepfad schon jetzt geschrieben
ist. Methoden, die eine Zeile per `findUnique({ where: { id } })` holen und
danach `userId` gegen den Aufrufer vergleichen, sehen nach dem Scharfschalten
die Kollegenzeile bereits als `null` (die Regel blendet sie aus, BEVOR die
Anwendung überhaupt vergleicht) — die Anwendung meldet dann `NotFoundException`
statt der heutigen `Forbidden`-artigen Abweisung über den `userId`-Vergleich.
Betroffen: `dashboard.service.ts` (`removeWidget`, `updateWidgetConfig`,
`removeSearchProvider`), `calendar.service.ts` (`updateSource`,
`deleteSource`), `favorites.service.ts` (`update`, `remove`). Beides ist eine
Abweisung — kein Informationsleck entsteht, nur die Fehlerart ändert sich von
Forbidden zu NotFound.
### (b4) Was dieser Durchlauf bewusst nicht löst
- Systemkontext für Hintergrunddienste (Etappe 3c) — `tender-digest.scheduler.ts`
bleibt bewusst zweistellig, mit Kommentar.
- Anmeldenamen pro Mandant (Etappe 3a) — nicht Teil dieses Laufs.
- Kein Wächter, der jede Nutzer-CRUD-Aufrufstelle auf das dritte Argument von
`forTenant()` prüft. Die Bestandsaufnahme (`rls-access-inventory.spec.ts`)
unterscheidet heute nur mandanten-gebunden/ungebunden, nicht
benutzer-gebunden — ein Aufrufer, der `userId` vergisst, ist für sie
unsichtbar. Netz bis dahin: die dreistelligen Spec-Zusicherungen je Dienst.
Siehe der neue WINDOWS-Eintrag unten.
- WINDOWS #24 (Admin-Erstellung/-Entfernen plattformweiter `TenderRssFeedSource`-Zeilen
bleibt ungebunden) bleibt unverändert offen — diese Migration ändert daran
nichts, `tenderrssfeed-gemeinsame-zeile-ohne-benutzer-weiterhin-entfernbar`
bestätigt das lediglich erneut.
### (b5) Was dieser Durchlauf bewusst nicht anfasst
- Die vier Tabellen mit `userId`-Spalte, die KEINE persönlichen Daten tragen
(`GroupMembership`, `ModuleGrant`, `PasswordResetToken`, `TenderMatch`) —
Verwaltungsobjekte, Anmelde-Artefakt, Hintergrunddienst-Schreibweg,
begründet im Kopf der Migration.
- Die drei SECURITY-DEFINER-Anmeldefunktionen (`auth_lookup_user_by_username`,
`auth_lookup_user_by_email`, `auth_lookup_reset_token`) — unangetastet.
- `schema.prisma` — unverändert, Gate gegen `8829999` in jeder Aufgabe.
- Der Schalter (`DATABASE_URL` → Rolle `tessera`, BYPASSRLS) — bleibt AUS.
- Compose-/Umgebungsdateien — unangetastet.
## Etappe 2 — Abschluss ## Etappe 2 — Abschluss
Etappe 2 der Mandantentrennung ist mit diesem Lauf (260911-gwh) vollständig: Etappe 2 der Mandantentrennung ist mit diesem Lauf (260911-gwh) vollständig:
@@ -3111,6 +3308,15 @@ dieser Aufgabe.
Etappe-3-Entscheidung (1)) — siehe (h4)(a). Etappe-3-Entscheidung (1)) — siehe (h4)(a).
- Die Benutzerdimension der Regeln (Etappe-3-Entscheidung (2)) — siehe - Die Benutzerdimension der Regeln (Etappe-3-Entscheidung (2)) — siehe
(k4)/(f4)(a) und die übrigen Bereiche mit derselben Beobachtung. (k4)/(f4)(a) und die übrigen Bereiche mit derselben Beobachtung.
**Nachtrag (260911-nke): ERLEDIGT.** Migration
`20260911120000_rls_user_dimension_personal_tables` (Etappe 3b) trägt die
Benutzerdimension in die Regeln aller zehn persönlichen Tabellen; 34
Nutzer-CRUD-Aufrufstellen reichen `userId` an `forTenant()` durch. Siehe
Abschnitt "Regelschluss Benutzerdimension (Etappe 3b, 260911-nke)" oben.
Was davon bewusst offenbleibt: ein Aufrufer, der `userId` vergisst, sieht
weiterhin den ganzen Mandanten (kein Wächter gebaut, siehe
`.planning/WINDOWS.md`); Systemkontext für Hintergrunddienste (Etappe 3c)
und Anmeldenamen pro Mandant (Etappe 3a) bleiben offen.
- Die Modulkatalog-Regel für `Module` (Befund E, `module-registry`) — sobald - Die Modulkatalog-Regel für `Module` (Befund E, `module-registry`) — sobald
eine Regel eingeführt wird, müssen die heute bewusst ungebundenen eine Regel eingeführt wird, müssen die heute bewusst ungebundenen
Katalogzugriffe nachgezogen werden. Katalogzugriffe nachgezogen werden.
+13
View File
@@ -38,6 +38,19 @@ der Grundlagen beginnen koennen. Alles hier ist gemessen, nicht erinnert.
### 3b zuerst: Benutzerdimension in den Regeln ### 3b zuerst: Benutzerdimension in den Regeln
**Erledigt (260911-nke, f0b531b/07fc653 plus der Dokumentationscommit dieser
Aufgabe):** Migration
`20260911120000_rls_user_dimension_personal_tables` bringt `current_user_id()`
und die Benutzerdimension in die Regeln der zehn persoenlichen Tabellen;
`forTenant(prisma, tenantId, userId?)` bekommt den optionalen dritten
Parameter, 34 Nutzer-CRUD-Aufrufstellen in acht Diensten reichen ihn durch.
Gemessene Zahl der umgedrehten Loch-Pruefungen: SECHS, nicht drei wie unten
noch angenommen. Endzahlen: Tests 1020/62 Dateien, Werkzeug
`rls-scratch-check.mjs` 203/203 bestanden (Baseline vor diesem Lauf: 137).
Siehe `docs/mandantentrennung-etappe2-fehlerrichtung.md`, Abschnitt
"Regelschluss Benutzerdimension (Etappe 3b, 260911-nke)". Der ursprüngliche
Auftragstext unten bleibt unveraendert stehen (historische Planungsgrundlage).
Warum zuerst: reiner Datenbank- und Helfer-Umbau, beruehrt den Anmeldeweg Warum zuerst: reiner Datenbank- und Helfer-Umbau, beruehrt den Anmeldeweg
NICHT, und schliesst die Klasse von Befunden, die in sieben Bereichen als NICHT, und schliesst die Klasse von Befunden, die in sieben Bereichen als
"Policy hat keine Benutzerdimension" festgehalten wurde. "Policy hat keine Benutzerdimension" festgehalten wurde.
@@ -278,6 +278,15 @@ Unterabfrage-Form, die WINDOWS #27 aufgedeckt hat.
Die Zahl 72 ist der Ausgabe von `rls-access-inventory.spec.ts` entnommen, Die Zahl 72 ist der Ausgabe von `rls-access-inventory.spec.ts` entnommen,
nicht geschaetzt. nicht geschaetzt.
**Stand 260911-nke:** die Paarzahl (72) und die Klassen-Verteilung sind
UNVERÄNDERT — Etappe 3b (Benutzerdimension in den Regeln, `forTenant()`
bekommt ein drittes Argument) fügt keine neue Fundstelle hinzu und ändert
keine bestehende von mandanten-gebunden auf mandanten-ungebunden oder
umgekehrt; benutzer-gebunden ist keine eigene Klasse in diesem Schema. Die
Benutzerdimension steht stattdessen in der Begründungsspalte der drei
betroffenen Bestandsaufnahme-Zeilen (`calendarSource`, `widgetInstance`,
`favoriteLink`) oben und im Abschnitt "Was diese Etappe NICHT entscheidet".
| Klasse | Anzahl Paare | | Klasse | Anzahl Paare |
|---|---| |---|---|
| muss-mandantengebunden | 35 | | muss-mandantengebunden | 35 |
@@ -572,15 +581,15 @@ werden.
|---|---|---|---|---| |---|---|---|---|---|
| apps/api/src/auth/auth.service.ts | passwordResetToken | muss-mandantengebunden | gebunden | Kein eigenes `tenantId`, RLS ueber Join auf `User` (Migration 20260618112133). `requestPasswordReset`/`resetPassword` laufen vollstaendig ueber `forTenant()` (Etappe 1, WINDOWS #20, Aufgabe 1) — von der alten, nur `this.prisma.*` erkennenden Suche nie erfasst, weil bereits gebunden; die erweiterte Erkennung aus Aufgabe 2 (260909-ipc) macht diese Fundstelle erstmals sichtbar. | | apps/api/src/auth/auth.service.ts | passwordResetToken | muss-mandantengebunden | gebunden | Kein eigenes `tenantId`, RLS ueber Join auf `User` (Migration 20260618112133). `requestPasswordReset`/`resetPassword` laufen vollstaendig ueber `forTenant()` (Etappe 1, WINDOWS #20, Aufgabe 1) — von der alten, nur `this.prisma.*` erkennenden Suche nie erfasst, weil bereits gebunden; die erweiterte Erkennung aus Aufgabe 2 (260909-ipc) macht diese Fundstelle erstmals sichtbar. |
| apps/api/src/auth/auth.service.ts | user | muss-mandantengebunden | gebunden | Klassenkorrektur (260911-fh9, Aufgabe 2/3): wechselt von `gemischt` auf `gebunden` — `getMe`, `changePassword`, `adminResetPassword` binden seit Aufgabe 2 je über GENAU EINEN Klienten `tenantPrisma` an den Mandanten aus dem Sitzungsnachweis (`@CurrentUser().tenantId`); für die oberste Rolle (SUPER_ADMIN) löst der Controller den Mandanten des ZIELS über den gebundenen Fan-out `UserService.findByIdForPlatformAdmin` auf. `adminResetPassword` verweigert zusätzlich einem Nicht-SUPER_ADMIN das Kennwort eines SUPER_ADMIN (T-FH9-04). Die drei Anmeldesuchen (`validateUser`, `requestPasswordReset`, `resetPassword`) laufen weiterhin über die drei SECURITY-DEFINER-Funktionen (`$queryRaw`, keine Modellzugriffe — `$` liegt nicht in `[a-zA-Z]`) und bleiben unverändert auf dem ungebundenen Klienten. Etappe-3-Vorbehalt: die Bindung hängt am Claim `tenantId` und an `User.id` (plattformweite UUID), nicht an `username`/`email` — der Anmeldeweg-Umbau für je Mandant eindeutige Anmeldenamen betrifft diese Bindung nicht, siehe `docs/mandantentrennung-etappe2-fehlerrichtung.md`, Abschnitt "## Bereich auth", (h4)(a). | | apps/api/src/auth/auth.service.ts | user | muss-mandantengebunden | gebunden | Klassenkorrektur (260911-fh9, Aufgabe 2/3): wechselt von `gemischt` auf `gebunden` — `getMe`, `changePassword`, `adminResetPassword` binden seit Aufgabe 2 je über GENAU EINEN Klienten `tenantPrisma` an den Mandanten aus dem Sitzungsnachweis (`@CurrentUser().tenantId`); für die oberste Rolle (SUPER_ADMIN) löst der Controller den Mandanten des ZIELS über den gebundenen Fan-out `UserService.findByIdForPlatformAdmin` auf. `adminResetPassword` verweigert zusätzlich einem Nicht-SUPER_ADMIN das Kennwort eines SUPER_ADMIN (T-FH9-04). Die drei Anmeldesuchen (`validateUser`, `requestPasswordReset`, `resetPassword`) laufen weiterhin über die drei SECURITY-DEFINER-Funktionen (`$queryRaw`, keine Modellzugriffe — `$` liegt nicht in `[a-zA-Z]`) und bleiben unverändert auf dem ungebundenen Klienten. Etappe-3-Vorbehalt: die Bindung hängt am Claim `tenantId` und an `User.id` (plattformweite UUID), nicht an `username`/`email` — der Anmeldeweg-Umbau für je Mandant eindeutige Anmeldenamen betrifft diese Bindung nicht, siehe `docs/mandantentrennung-etappe2-fehlerrichtung.md`, Abschnitt "## Bereich auth", (h4)(a). |
| apps/api/src/calendar/calendar.service.ts | calendarSource | muss-mandantengebunden | gebunden | Kalenderquellen eines Nutzers je Mandant gebunden (encryptedPassword traegt Zugangsdaten zu externen Exchange-/CalDAV-Servern), `tenantId`-Spalte vorhanden. Seit 260911-cwh (Aufgabe 2) laufen alle zwoelf Zugriffe (`getSources`, `addSource`, beide Abfragen von `updateSource`/`deleteSource`, alle drei Abfragen von `testConnection`, Laden plus beide Synchronstatus-Rueckschreibungen von `fetchAndCacheEvents`) ueber `forTenant()`, ein Klient je Methode; `fetchAndCacheEvents`/`refreshCacheInBackground` nehmen die Mandantenkennung als Parameter, Letztere traegt die Kennung der urspruenglichen Anfrage. Die drei Besitzpruefungen (`updateSource`/`deleteSource`/`testConnection`, Vergleich gegen `userId` aus dem Sitzungsnachweis) bleiben zusaetzlich bestehen — die Regel auf `CalendarSource` kennt keine Benutzerdimension (260911-cwh, Aufgabe 1, gemessen), sie sind bis zur Etappe-3-Entscheidung (2) der einzige Schutz zwischen Kollegen DESSELBEN Mandanten. | | apps/api/src/calendar/calendar.service.ts | calendarSource | muss-mandantengebunden | gebunden | Kalenderquellen eines Nutzers je Mandant gebunden (encryptedPassword traegt Zugangsdaten zu externen Exchange-/CalDAV-Servern), `tenantId`-Spalte vorhanden. Seit 260911-cwh (Aufgabe 2) laufen alle zwoelf Zugriffe (`getSources`, `addSource`, beide Abfragen von `updateSource`/`deleteSource`, alle drei Abfragen von `testConnection`, Laden plus beide Synchronstatus-Rueckschreibungen von `fetchAndCacheEvents`) ueber `forTenant()`, ein Klient je Methode; `fetchAndCacheEvents`/`refreshCacheInBackground` nehmen die Mandantenkennung als Parameter, Letztere traegt die Kennung der urspruenglichen Anfrage. Die drei Besitzpruefungen (`updateSource`/`deleteSource`/`testConnection`, Vergleich gegen `userId` aus dem Sitzungsnachweis) bleiben zusaetzlich bestehen — die Regel auf `CalendarSource` kennt keine Benutzerdimension (260911-cwh, Aufgabe 1, gemessen), sie sind bis zur Etappe-3-Entscheidung (2) der einzige Schutz zwischen Kollegen DESSELBEN Mandanten. Benutzerdimension seit 20260911120000 (260911-nke). |
| apps/api/src/dashboard/dashboard.service.ts | dashboardLayout | muss-mandantengebunden | gebunden | Widget-Anordnung eines Nutzers, `tenantId`-Spalte vorhanden. Seit 260910-krx (Aufgabe 2) laufen `getLayout`/`saveLayout` GEMEINSAM ueber `forTenant()`, ein Klient je Methode; `saveLayout` uebersetzt eine `PrismaClientUnknownRequestError` (RLS-Konflikt auf der plattformweit eindeutigen `userId`, gemessen in Aufgabe 1 — NICHT die `P2002`-Form, die der Bereich `tenders` abfaengt) in eine deutsche Konfliktmeldung. | | apps/api/src/dashboard/dashboard.service.ts | dashboardLayout | muss-mandantengebunden | gebunden | Widget-Anordnung eines Nutzers, `tenantId`-Spalte vorhanden. Seit 260910-krx (Aufgabe 2) laufen `getLayout`/`saveLayout` GEMEINSAM ueber `forTenant()`, ein Klient je Methode; `saveLayout` uebersetzt eine `PrismaClientUnknownRequestError` (RLS-Konflikt auf der plattformweit eindeutigen `userId`, gemessen in Aufgabe 1 — NICHT die `P2002`-Form, die der Bereich `tenders` abfaengt) in eine deutsche Konfliktmeldung. |
| apps/api/src/dashboard/dashboard.service.ts | module | keine-mandantengebundene-tabelle | ungebunden | Modulkatalog ist plattformweit, kein `tenantId` (Migration 20260909140000, Gruppe b). MESSUNG (260910-krx, Aufgabe 1, uebernommen aus `module-registry`-Pruefung `module-tabelle-traegt-keinen-zeilenschutz`): die Tabelle traegt heute keinen Zeilenschutz, eine Bindung waere heute wirkungslos, nicht katastrophal. BEDINGUNG: katastrophal wuerde sie erst, WENN Etappe 3 dieser Tabelle eine Regel gibt. Die Katalogaufloesung fuer den Widget-Modulfilter (`ModuleAccessService.getAccessibleModuleIds`) bindet bereits seit 260910-exd in ihrem eigenen Dienst — hier NICHT ein zweites Mal gebunden. | | apps/api/src/dashboard/dashboard.service.ts | module | keine-mandantengebundene-tabelle | ungebunden | Modulkatalog ist plattformweit, kein `tenantId` (Migration 20260909140000, Gruppe b). MESSUNG (260910-krx, Aufgabe 1, uebernommen aus `module-registry`-Pruefung `module-tabelle-traegt-keinen-zeilenschutz`): die Tabelle traegt heute keinen Zeilenschutz, eine Bindung waere heute wirkungslos, nicht katastrophal. BEDINGUNG: katastrophal wuerde sie erst, WENN Etappe 3 dieser Tabelle eine Regel gibt. Die Katalogaufloesung fuer den Widget-Modulfilter (`ModuleAccessService.getAccessibleModuleIds`) bindet bereits seit 260910-exd in ihrem eigenen Dienst — hier NICHT ein zweites Mal gebunden. |
| apps/api/src/dashboard/dashboard.service.ts | searchProvider | muss-mandantengebunden | gebunden | `tenantId` nullbar. WINDOWS #19 geschlossen (260910-jab) als **widerlegte Prämisse** für dieses Modell. In diesem Durchlauf (260910-krx, Aufgabe 1) EIGENSTAENDIG nachgeprueft, nicht aus 260910-jab abgeschrieben: `grep -rn "searchProvider\|SearchProvider" apps packages prisma --include=*.ts --include=*.mjs --include=*.js --include=*.sql --include=*.json` (ohne `node_modules`, `dist/`, `.next/`) findet weiterhin genau einen Schreibweg, `dashboard.service.ts:addSearchProvider` (`create`), mit `tenantId: string` als Pflichtparameter — keine Seed-Datei, kein Skript. Seit Aufgabe 2/3 laufen `getSearchProviders`/`addSearchProvider`/`removeSearchProvider` ueber `forTenant()`; die Regel auf `SearchProvider` bleibt UNVERAENDERT streng, zusaetzlich datenbankseitig verteidigt durch `searchprovider-gebundenes-einfuegen-ohne-mandant-abgelehnt` (Aufgabe 1). | | apps/api/src/dashboard/dashboard.service.ts | searchProvider | muss-mandantengebunden | gebunden | `tenantId` nullbar. WINDOWS #19 geschlossen (260910-jab) als **widerlegte Prämisse** für dieses Modell. In diesem Durchlauf (260910-krx, Aufgabe 1) EIGENSTAENDIG nachgeprueft, nicht aus 260910-jab abgeschrieben: `grep -rn "searchProvider\|SearchProvider" apps packages prisma --include=*.ts --include=*.mjs --include=*.js --include=*.sql --include=*.json` (ohne `node_modules`, `dist/`, `.next/`) findet weiterhin genau einen Schreibweg, `dashboard.service.ts:addSearchProvider` (`create`), mit `tenantId: string` als Pflichtparameter — keine Seed-Datei, kein Skript. Seit Aufgabe 2/3 laufen `getSearchProviders`/`addSearchProvider`/`removeSearchProvider` ueber `forTenant()`; die Regel auf `SearchProvider` bleibt UNVERAENDERT streng, zusaetzlich datenbankseitig verteidigt durch `searchprovider-gebundenes-einfuegen-ohne-mandant-abgelehnt` (Aufgabe 1). |
| apps/api/src/dashboard/dashboard.service.ts | widgetInstance | muss-mandantengebunden | gebunden | Platzierte Dashboard-Widgets eines Nutzers, `tenantId`-Spalte vorhanden. Seit 260910-krx (Aufgabe 2) laufen `getWidgets`, `addWidget` sowie beide Paare aus Besitzpruefung und Schreibzugriff (`updateWidgetConfig`/`removeWidget`) ueber `forTenant()`; die vorgeschalteten Besitzpruefungen ueber die Benutzerkennung bleiben zusaetzlich bestehen (die Regeln dieses Bereichs kennen keine Benutzerdimension). | | apps/api/src/dashboard/dashboard.service.ts | widgetInstance | muss-mandantengebunden | gebunden | Platzierte Dashboard-Widgets eines Nutzers, `tenantId`-Spalte vorhanden. Seit 260910-krx (Aufgabe 2) laufen `getWidgets`, `addWidget` sowie beide Paare aus Besitzpruefung und Schreibzugriff (`updateWidgetConfig`/`removeWidget`) ueber `forTenant()`; die vorgeschalteten Besitzpruefungen ueber die Benutzerkennung bleiben zusaetzlich bestehen (die Regeln dieses Bereichs kennen keine Benutzerdimension). Benutzerdimension seit 20260911120000 (260911-nke). |
| apps/api/src/dkv/dkv.service.ts | dkvInvoiceHistory | muss-mandantengebunden | gebunden | DKV-Rechnungshistorie je Mandant, `tenantId`-Spalte vorhanden. Seit 260909-mir (Aufgabe 3) laufen beide Historien-Schreibzugriffe der Verarbeitungsstrecke, beide parallelen Lesezugriffe von `getHistory` und der neue Riegel vor dem Ausfuhrdatei-Download vollstaendig ueber `forTenant()`. | | apps/api/src/dkv/dkv.service.ts | dkvInvoiceHistory | muss-mandantengebunden | gebunden | DKV-Rechnungshistorie je Mandant, `tenantId`-Spalte vorhanden. Seit 260909-mir (Aufgabe 3) laufen beide Historien-Schreibzugriffe der Verarbeitungsstrecke, beide parallelen Lesezugriffe von `getHistory` und der neue Riegel vor dem Ausfuhrdatei-Download vollstaendig ueber `forTenant()`. |
| apps/api/src/dkv/dkv.service.ts | dkvModuleConfig | muss-mandantengebunden | gemischt | Postfach-/Zugangsdaten des DKV-Moduls je Mandant. Seit 260909-mir (Aufgabe 2) laufen `loadConfig`, `getConfigForApi`, `saveConfig`, `testConnection` und der Konfigurations-Lesezugriff der Verarbeitungsstrecke ueber `forTenant()`. Die Mischung stammt ausschliesslich vom einen benannten, bewusst ungebundenen Planer-Startpfad `loadAnyActiveConfigForScheduler()` (WINDOWS #21) — keine uebersehene Fundstelle. | | apps/api/src/dkv/dkv.service.ts | dkvModuleConfig | muss-mandantengebunden | gemischt | Postfach-/Zugangsdaten des DKV-Moduls je Mandant. Seit 260909-mir (Aufgabe 2) laufen `loadConfig`, `getConfigForApi`, `saveConfig`, `testConnection` und der Konfigurations-Lesezugriff der Verarbeitungsstrecke ueber `forTenant()`. Die Mischung stammt ausschliesslich vom einen benannten, bewusst ungebundenen Planer-Startpfad `loadAnyActiveConfigForScheduler()` (WINDOWS #21) — keine uebersehene Fundstelle. |
| apps/api/src/dkv/dkv.service.ts | dkvVehicleMaster | muss-mandantengebunden | gebunden | Fahrzeugstammdaten des DKV-Moduls je Mandant. Seit 260909-mir (Aufgabe 3) laufen Fahrzeugliste, Anlegen, beide Paare aus Besitzpruefung und Schreibzugriff (Aendern/Loeschen), beide Zweige des CSV-Imports und der gebuendelte Lesezugriff beim Aufbau der Ausfuhrzeilen vollstaendig ueber `forTenant()`; die vorgeschalteten Besitzpruefungen bei Aendern/Loeschen bleiben zusaetzlich bestehen (Befund G — ein gebundenes UPDATE ueber die Kennung allein trifft eine fremde Zeile still, nicht laut). | | apps/api/src/dkv/dkv.service.ts | dkvVehicleMaster | muss-mandantengebunden | gebunden | Fahrzeugstammdaten des DKV-Moduls je Mandant. Seit 260909-mir (Aufgabe 3) laufen Fahrzeugliste, Anlegen, beide Paare aus Besitzpruefung und Schreibzugriff (Aendern/Loeschen), beide Zweige des CSV-Imports und der gebuendelte Lesezugriff beim Aufbau der Ausfuhrzeilen vollstaendig ueber `forTenant()`; die vorgeschalteten Besitzpruefungen bei Aendern/Loeschen bleiben zusaetzlich bestehen (Befund G — ein gebundenes UPDATE ueber die Kennung allein trifft eine fremde Zeile still, nicht laut). |
| apps/api/src/favorites/favorites.service.ts | favoriteLink | muss-mandantengebunden | gebunden | Favoriten-Links eines Nutzers, `tenantId`-Spalte vorhanden. Seit 260911-gwh (Aufgabe 2) laufen `list`, `create`, `update`, `remove`, `getIconBytes` vollstaendig ueber `forTenant()`, je Methode EIN Klient `tenantPrisma`; die Besitzpruefungen (`findUnique`, Vergleich `link.userId !== userId`, dann Schreibzugriff auf DEMSELBEN Klienten) bleiben zusaetzlich bestehen — die Regel auf `FavoriteLink` kennt keine Benutzerdimension (Aufgabe 1, Pruefung 4), die `userId`-Filter sind bis zur Etappe-3-Entscheidung (2) der einzige Schutz gegen Quer-Lesen zwischen Nutzern DESSELBEN Mandanten. Die Mandantenquelle ist dieselbe wie bei `dashboard` (`extractContext` im Controller), nicht das Claim wie bei `auth`. | | apps/api/src/favorites/favorites.service.ts | favoriteLink | muss-mandantengebunden | gebunden | Favoriten-Links eines Nutzers, `tenantId`-Spalte vorhanden. Seit 260911-gwh (Aufgabe 2) laufen `list`, `create`, `update`, `remove`, `getIconBytes` vollstaendig ueber `forTenant()`, je Methode EIN Klient `tenantPrisma`; die Besitzpruefungen (`findUnique`, Vergleich `link.userId !== userId`, dann Schreibzugriff auf DEMSELBEN Klienten) bleiben zusaetzlich bestehen — die Regel auf `FavoriteLink` kennt keine Benutzerdimension (Aufgabe 1, Pruefung 4), die `userId`-Filter sind bis zur Etappe-3-Entscheidung (2) der einzige Schutz gegen Quer-Lesen zwischen Nutzern DESSELBEN Mandanten. Die Mandantenquelle ist dieselbe wie bei `dashboard` (`extractContext` im Controller), nicht das Claim wie bei `auth`. Benutzerdimension seit 20260911120000 (260911-nke). |
| apps/api/src/favorites/favorites.service.ts | widgetInstance | muss-mandantengebunden | gebunden | NEUE Fundstelle (260911-gwh, Aufgabe 2): `create()` prueft ueber einen gebundenen `widgetInstance.findUnique` (`select: { userId: true }`), dass das Ziel-Widget (`dto.widgetId`) dem Aufrufer gehoert, BEVOR die Zeile angelegt wird — der Fremdschluessel `FavoriteLink.widgetId` prueft an der Zeilenschutz-Regel von `WidgetInstance` VORBEI (dokumentiertes PostgreSQL-Verhalten, Aufgabe 1 Pruefung 7 hat das GELINGEN eines gebundenen `create` mit einer fremdmandantigen `widgetId` bestaetigt); ohne den Riegel waere der Unterschied zwischen "Widget existiert nicht" (FK-Verletzung) und "gehoert einem fremden Mandanten" (gelingt) ein Existenzorakel ueber Mandantengrenzen (T-GWH-05). | | apps/api/src/favorites/favorites.service.ts | widgetInstance | muss-mandantengebunden | gebunden | NEUE Fundstelle (260911-gwh, Aufgabe 2): `create()` prueft ueber einen gebundenen `widgetInstance.findUnique` (`select: { userId: true }`), dass das Ziel-Widget (`dto.widgetId`) dem Aufrufer gehoert, BEVOR die Zeile angelegt wird — der Fremdschluessel `FavoriteLink.widgetId` prueft an der Zeilenschutz-Regel von `WidgetInstance` VORBEI (dokumentiertes PostgreSQL-Verhalten, Aufgabe 1 Pruefung 7 hat das GELINGEN eines gebundenen `create` mit einer fremdmandantigen `widgetId` bestaetigt); ohne den Riegel waere der Unterschied zwischen "Widget existiert nicht" (FK-Verletzung) und "gehoert einem fremden Mandanten" (gelingt) ein Existenzorakel ueber Mandantengrenzen (T-GWH-05). |
| apps/api/src/groups/groups.service.ts | group | muss-mandantengebunden | gebunden | Gruppen sind je Mandant, `tenantId`-Spalte vorhanden. Alle 12 Methoden laufen seit 260909-jts (Aufgabe 2) ueber `forTenant()` bzw. `withTenantTransaction()`. | | apps/api/src/groups/groups.service.ts | group | muss-mandantengebunden | gebunden | Gruppen sind je Mandant, `tenantId`-Spalte vorhanden. Alle 12 Methoden laufen seit 260909-jts (Aufgabe 2) ueber `forTenant()` bzw. `withTenantTransaction()`. |
| apps/api/src/groups/groups.service.ts | groupMembership | muss-mandantengebunden | gebunden | Kein eigenes `tenantId`, RLS über Join auf `Group` (Migration 20260618112133-Nachfolger) — braucht trotzdem `forTenant()`, damit der Join-Kontext gesetzt ist. Seit 260909-jts gebunden, einschliesslich der drei Zugriffe innerhalb des Standardgruppen-Aufbaus (`ensureDefaultGroup`), die zuvor ueber den Transaktionsparameter liefen und fuer keine Pruefung dieses Projekts sichtbar waren (Befund B). | | apps/api/src/groups/groups.service.ts | groupMembership | muss-mandantengebunden | gebunden | Kein eigenes `tenantId`, RLS über Join auf `Group` (Migration 20260618112133-Nachfolger) — braucht trotzdem `forTenant()`, damit der Join-Kontext gesetzt ist. Seit 260909-jts gebunden, einschliesslich der drei Zugriffe innerhalb des Standardgruppen-Aufbaus (`ensureDefaultGroup`), die zuvor ueber den Transaktionsparameter liefen und fuer keine Pruefung dieses Projekts sichtbar waren (Befund B). |
@@ -693,6 +702,19 @@ werden.
an `username`/`email`. Siehe an `username`/`email`. Siehe
`docs/mandantentrennung-etappe2-fehlerrichtung.md`, "## Bereich auth", `docs/mandantentrennung-etappe2-fehlerrichtung.md`, "## Bereich auth",
(h4)(a). (h4)(a).
- **Wie die Benutzerdimension in die Regeln der zehn persönlichen Tabellen
kommt (Etappe-3-Entscheidung (2)).** **Aufgelöst (260911-nke):** Migration
`20260911120000_rls_user_dimension_personal_tables` (Etappe 3b) bringt
`app.current_user`/`current_user_id()` und die `IS NULL OR`-Form in die
Regeln aller zehn persönlichen Tabellen; `forTenant(prisma, tenantId,
userId?)` bekommt den optionalen dritten Parameter, 34 Nutzer-CRUD-
Aufrufstellen reichen ihn durch. Siehe
`docs/mandantentrennung-etappe2-fehlerrichtung.md`, Abschnitt
"Regelschluss Benutzerdimension (Etappe 3b, 260911-nke)". Was weiterhin
offen ist: ein Aufrufer, der `userId` vergisst, sieht den ganzen
Mandanten (kein Wächter gebaut, siehe `.planning/WINDOWS.md`);
Systemkontext (Etappe 3c) und Anmeldenamen pro Mandant (Etappe 3a) bleiben
offen.
- **Wie das Mailmodul künftig je Mandant versendet (260911-gwh).** Der - **Wie das Mailmodul künftig je Mandant versendet (260911-gwh).** Der
Startpfad `loadAnySmtpConfigForStartupTransport()` bleibt bewusst Startpfad `loadAnySmtpConfigForStartupTransport()` bleibt bewusst
ungebunden (sechster Fall der Hintergrunddienst-Falle, WINDOWS #30, siehe ungebunden (sechster Fall der Hintergrunddienst-Falle, WINDOWS #30, siehe