b62a905adb
- 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
3361 lines
248 KiB
Markdown
3361 lines
248 KiB
Markdown
# Mandantentrennung, Etappe 2 — Die Fehlerrichtung dreht sich um
|
||
|
||
Dieses Dokument gehört zusammen mit
|
||
`docs/mandantentrennung-zugriffsklassifikation.md` zur Vorbereitung der
|
||
Mandantentrennung auf Datenbankebene. Während die Klassifikation festhält,
|
||
**welche** Fundstelle umgestellt wird, hält dieses Dokument fest, **woran**
|
||
man merkt, wenn eine umgestellte Fundstelle nach dem Scharfschalten
|
||
(Etappe 4) zu wenig liefert. Es ist etappenbezogen und wird nicht laufend
|
||
nachgezogen wie die Bestandsaufnahme — es beschreibt den Bereich `ldap` zum
|
||
Zeitpunkt seiner Umstellung (Etappe 2, Quick-Task 260909-ipc).
|
||
|
||
## (a) Die Leitfrage
|
||
|
||
Bis heute war der Fehlerfall einer Mandantentrennung "sieht zu viel": die
|
||
Rolle `tessera` läuft mit `BYPASSRLS`, jede Abfrage sieht alle Zeilen aller
|
||
Mandanten, unabhängig davon, ob sie an `forTenant()` gebunden ist oder nicht.
|
||
Ein vergessener `forTenant()`-Aufruf blieb bisher unsichtbar, weil die Policy
|
||
gar nicht griff.
|
||
|
||
Nach dem Scharfschalten (Etappe 4, wenn `DATABASE_URL` auf eine Rolle ohne
|
||
`BYPASSRLS` zeigt) kehrt sich das um. Jede Abfrage, die **nicht** gebunden
|
||
ist, sieht nicht mehr alle Zeilen, sondern **keine** — die Policy vergleicht
|
||
gegen `current_tenant_id()`, und ohne vorheriges `set_config` ist dieser Wert
|
||
`NULL`. `NULL = "tenantId"` ist in SQL nie wahr, auch wenn `"tenantId"`
|
||
selbst nicht `NULL` ist. Der Fehlerfall ist damit nicht mehr "ein
|
||
Administrator sieht die Konfiguration eines fremden Mandanten", sondern "der
|
||
Abgleich-Dienst sieht gar keine Konfiguration mehr, für niemanden, und tut
|
||
so, als sei nichts zu tun."
|
||
|
||
Diese Frage wird deshalb VOR der Umstellung gestellt, nicht erst beim
|
||
Scharfschalten entdeckt: **Woran würde ich merken, dass eine umgestellte
|
||
Abfrage jetzt zu WENIG liefert statt zu viel?**
|
||
|
||
## (b) Die Messung
|
||
|
||
Task 1 dieses Plans hat `apps/api/scripts/rls-scratch-check.mjs` um einen
|
||
dritten Abschnitt erweitert, der exakt das im Bereich `ldap` verwendete
|
||
Muster (Tabellen `LdapConfig`/`LdapFieldMapping`, Policies wortgleich aus der
|
||
ausgelieferten Migration `20260618112133_rls_policies/migration.sql`
|
||
herausgeschnitten) gegen eine Wegwerf-Datenbank unter einer Rolle **ohne**
|
||
`BYPASSRLS` prüft. Tatsächlich beobachtete Ausgabe dieses Laufs
|
||
(2026-09-09, gegen `tessera-ctl-db-1`, Adresse `172.19.0.2`):
|
||
|
||
```
|
||
ldapconfig-gebunden-nur-eigene-zeile: bestanden — forTenant(TENANT-A) liefert 1 Zeile(n): ["TENANT-A"]
|
||
ldapconfig-ungebunden-null-zeilen: bestanden — ungebundener SELECT auf "LdapConfig" liefert 0 Zeile(n)
|
||
fieldmapping-folgt-join-auf-ldapconfig: bestanden — forTenant(TENANT-A) liefert 1 Feldzuordnung(en): ["cfg-a"]
|
||
fieldmapping-schreiben-eigene-konfiguration-erlaubt: bestanden — INSERT mit eigener ldapConfigId erfolgreich
|
||
fieldmapping-schreiben-fremde-konfiguration-abgelehnt: bestanden — INSERT mit fremder ldapConfigId abgewiesen: ERROR: new row violates row-level security policy for table "LdapFieldMapping"
|
||
Alle 13 Pruefungen bestanden.
|
||
```
|
||
|
||
Der Beleg, der diese Kritikschrift trägt, ist die Zeile
|
||
`ldapconfig-ungebunden-null-zeilen`: der IDENTISCHE `SELECT "tenantId" FROM
|
||
"LdapConfig"` ohne vorheriges `set_config` liefert **0 Zeilen**, nicht etwa
|
||
alle 2 vorhandenen. Das ist an der echten, ausgelieferten Policy gemessen,
|
||
nicht an einer im Werkzeug nachgebauten Hilfstabelle — die Fehlerrichtung
|
||
"sieht nichts" ist damit kein aus dem Code abgeleiteter Schluss, sondern eine
|
||
beobachtete Tatsache.
|
||
|
||
## (c) Signaltabelle je umgestelltem Pfad des Bereichs `ldap`
|
||
|
||
| Pfad | Verhalten bei zu wenig Ergebnis | Konkretes Signal |
|
||
|---|---|---|
|
||
| `LdapConfigService.getConfig/createConfig/updateConfig` | `forTenant()` liefert für den anfragenden Mandanten 0 Zeilen statt der eigenen Konfiguration | `GET /ldap/config` liefert `null`; die Admin-Oberfläche zeigt "keine Konfiguration" für einen Mandanten, der tatsächlich eine hat |
|
||
| `LdapConfigService.addFieldMapping/removeFieldMapping` | Schreiben/Lesen einer Feldzuordnung läuft gebunden leer statt auf die eigene Zuordnung | `POST`/`DELETE /ldap/config/mappings` scheitert mit 404 bzw. legt scheinbar nichts an, obwohl die Konfiguration existiert |
|
||
| `LdapService.listGroups`/`searchUsers` (Markierung "bereits importiert") | Die Markierungsabfrage liefert 0 vorhandene Konten/Gruppen statt der tatsächlich vorhandenen | Die Kennzeichnung "bereits importiert" in den Auswahllisten von Gruppen und Benutzern fehlt für Einträge, die tatsächlich schon importiert sind — ein zweiter Import würde eine Dublette anlegen (bzw. bei Gruppen an der eigenen `ldapObjectGuid`-Eindeutigkeit scheitern) |
|
||
| `LdapService.upsertMappedUser`/`importUsersByDn` (Identitätssuche über `ldapDn`/`username`) | Die Suche nach dem bestehenden Konto liefert 0 Treffer statt des vorhandenen Kontos | Der Abgleich-Bericht zählt das Konto unter `created` statt `updated` — sichtbar in den Zählern created/updated/deactivated des Sync-Berichts |
|
||
| `LdapService.syncUsersForTenant` (Deaktivierungs-Kandidatenliste) | Die Kandidatenliste liefert 0 lokale LDAP-Konten statt der tatsächlich vorhandenen | Der Zähler `deactivated` bleibt 0, obwohl ein Konto im Verzeichnis entfernt wurde — harmlose Richtung: es wird zu WENIG deaktiviert, nie zu viel |
|
||
| `LdapService.syncUsersForTenant` (`lastSyncAt`-Fortschreibung) | Das `UPDATE` trifft 0 Zeilen statt der eigenen Konfiguration | Das Feld `lastSyncAt` der Konfiguration bleibt stehen, obwohl der Sync gerade lief — sichtbar in der Admin-Oberfläche als "nie synchronisiert" trotz laufendem Betrieb |
|
||
| `LdapService.importGroupsByDn` (Idempotenzprüfung über `ldapObjectGuid`) | Die Prüfung liefert 0 Treffer statt der bereits importierten Gruppe | Ein zweiter Import derselben AD-Gruppe würde eine Dublette anlegen, statt sie als "übersprungen" zu zählen — im gebundenen Zustand nicht mehr erreichbar, weil `group.create` an der eigenen `ldapObjectGuid`-Unique-Bedingung ohnehin scheitert |
|
||
| `LdapConfigScheduler` (Planer, `getAllActiveConfigs()`) | Der Planer liest 0 Konfigurationen statt aller aktiven Konfigurationen ALLER Mandanten | Die Protokollzeile "Starting LDAP sync for tenant ..." des Planers erscheint für KEINEN Mandanten mehr — siehe Abschnitt (d), Befund E |
|
||
|
||
## (d) Welcher Code deutet Leere als Abwesenheit
|
||
|
||
Diese vier Stellen sind die still gefährlichsten Orte des Bereichs `ldap`,
|
||
weil sie ein zu kleines Datenbankergebnis nicht als Fehler, sondern als
|
||
gültigen Zustand ("nichts zu tun", "Objekt existiert nicht mehr")
|
||
interpretieren:
|
||
|
||
1. **`syncGroupMembershipsForTenant`, `deleteMany` mit `notIn`** — gefährlich,
|
||
zerstörend. Liefert die Benutzerausfrage zu wenig (weil ungebunden nach
|
||
dem Scharfschalten 0 Zeilen zurückkommen), entfernt der anschließende
|
||
`deleteMany({ where: { NOT: { userId: { in: [...] } } } })`-Aufruf ALLE
|
||
LDAP-Mitgliedschaften der Gruppe, weil die Vergleichsliste leer ist und
|
||
jede vorhandene Mitgliedschaft als "nicht mehr in der Liste" gilt. Dieser
|
||
Pfad ist bereits gebunden (`tenantPrisma` seit Etappe 1, Zeile 1179 vor
|
||
dieser Umstellung); die Gefahr besteht nur, falls diese Bindung jemals
|
||
entfernt würde.
|
||
|
||
2. **Die Deaktivierungsschleife in `syncUsersForTenant`** — harmlose
|
||
Richtung, trotzdem festgehalten. Liefert die Kandidatenliste
|
||
(`localLdapUsers`) zu wenig, wird zu WENIG deaktiviert, nie zu viel: ein
|
||
Konto, das eigentlich deaktiviert werden müsste, bleibt aktiv. Das ist
|
||
unerwünscht, aber nicht destruktiv — es verliert keine Daten und sperrt
|
||
niemanden fälschlich aus.
|
||
|
||
3. **Der Löschzweig in `syncBoundGroupsForTenant`** — die eigentliche
|
||
Löschentscheidung fällt am VERZEICHNIS ("kein Treffer mehr für
|
||
`objectGUID`"), nicht an der Datenbank; ein zu kleines Datenbankergebnis
|
||
führt hier zu WENIGER Löschungen, nicht zu mehr. Gefährlich ist
|
||
stattdessen die Übergabe unmittelbar davor:
|
||
`this.groupsService.reassignDefaultBeforeDelete(tenantId, group.id)` und
|
||
`ensureDefaultGroup(tenantId)` liegen in `groups.service.ts` und sind
|
||
NICHT Teil dieser Umstellung. Nach dem Scharfschalten liefert
|
||
`reassignDefaultBeforeDelete` still `false` (kein Ersatzkandidat
|
||
sichtbar), der Standard-Marker wandert nicht mit, und die Gruppe wird
|
||
trotzdem gelöscht — der Mandant bleibt ohne Standardgruppe zurück
|
||
(Befund D, siehe (e)).
|
||
|
||
4. **`getAllActiveConfigs`** — die stillste Stelle im gesamten Bereich.
|
||
Liefert diese Abfrage nach dem Scharfschalten 0 Zeilen (sie ist bewusst
|
||
übergreifend und bleibt ungebunden, siehe (e)), stellt der LDAP-Abgleich
|
||
für JEDEN Mandanten ohne Fehlermeldung, ohne Protokolleintrag und ohne
|
||
sichtbare Änderung die Arbeit ein (Befund E). Eine Laufzeitwarnung bei
|
||
"0 aktive Konfigurationen" wurde erwogen und VERWORFEN: der Planer läuft
|
||
jede Minute, und auf einer frischen Installation ohne LDAP ist 0 der
|
||
Normalfall — eine Warnung wäre Dauerlärm, der nach kurzer Zeit ignoriert
|
||
wird und sein Signal verliert. Das Signal gehört deshalb hierhin und in
|
||
die Vorabprüfung von Etappe 4 (`rls-preflight.mjs`), nicht in den
|
||
Minutentakt des Planers.
|
||
|
||
## (e) Was dieser Durchlauf bewusst nicht löst
|
||
|
||
- **Befund A — `resolveEmailForWrite` muss übergreifend bleiben.** `email`
|
||
und `username` sind in `prisma/schema.prisma` plattformweit eindeutig
|
||
(`@unique`), nicht je Mandant. Würde diese Abfrage mitgebunden, sähe sie
|
||
einen fremden Halter der Adresse nicht mehr, meldete "Adresse frei", und
|
||
der anschließende Schreibvorgang liefe in die plattformweite
|
||
Eindeutigkeitsbedingung der Datenbank — aus einer sauber berichteten
|
||
Kollision (WINDOWS #15/T-Q3-01) würde ein P2002-Abbruch des gesamten
|
||
Sync-Laufs. Nach dem Scharfschalten liefert diese ungebundene Abfrage
|
||
IMMER "frei" (0 Zeilen unter jedem Mandantenkontext außer dem der
|
||
Adresse selbst) — ein bekannter, hier bewusst offen gelassener Punkt.
|
||
Die Lösung gehört nach Etappe 3, vermutlich als vierte
|
||
SECURITY-DEFINER-Funktion nach dem Muster der drei Funktionen des
|
||
Anmeldewegs (`auth_lookup_user_by_username`,
|
||
`auth_lookup_reset_token`, plus die dritte aus Etappe 1).
|
||
|
||
- **Befund D — die Standardgruppen-Übergabe an den Bereich `groups`.** Wie
|
||
in (d.3) beschrieben, ist der Löschzweig selbst bereits gebunden, aber
|
||
die Übergabe an `reassignDefaultBeforeDelete`/`ensureDefaultGroup` in
|
||
`groups.service.ts` ist es nicht. Das ist eine Reihenfolgebedingung für
|
||
Etappe 4: der Bereich `groups` muss umgestellt sein, bevor scharf
|
||
geschaltet wird, sonst bleibt ein Mandant nach einer Gruppen-Löschung
|
||
ohne Standardgruppe zurück. `groups` ist ohnehin als nächster Bereich der
|
||
Etappe 2 vorgesehen.
|
||
|
||
**Nachtrag (260909-jts, Aufgabe 3): GESCHLOSSEN.** Der Bereich `groups`
|
||
ist umgestellt — `reassignDefaultBeforeDelete` und `ensureDefaultGroup`
|
||
laufen seit Aufgabe 2 dieses Plans vollständig über `forTenant()` bzw.
|
||
`withTenantTransaction()` (siehe Abschnitt "Bereich groups" unten und
|
||
`docs/mandantentrennung-zugriffsklassifikation.md`, Zeile
|
||
`groups.service.ts`/`group`, Stand `gebunden`). Die Reihenfolgebedingung
|
||
für Etappe 4 ist damit erfüllt. Der Befund oben bleibt unverändert stehen
|
||
— er beschreibt korrekt den Zustand zum Zeitpunkt der ldap-Umstellung.
|
||
|
||
- **Die offene Architekturfrage `req.tenantPrisma`.** `tenant.middleware.ts`
|
||
und `tenant.guard.ts` setzen `req.tenantPrisma = forTenant(...)`, aber
|
||
kein Controller liest diesen Wert je. Dieser Durchlauf entscheidet NICHT,
|
||
ob Controller künftig darüber gehen sollten statt eines erneuten
|
||
`forTenant()`-Aufrufs im Service — der Bereich `ldap` bindet weiterhin
|
||
dienst-intern, wie die vier Bestandsstellen in `ldap.service.ts` und die
|
||
drei in `auth.service.ts` es vormachen. Die Frage bleibt für die übrigen
|
||
Bereiche der Etappe 2 offen (siehe
|
||
`docs/mandantentrennung-zugriffsklassifikation.md`, Abschnitt "Was diese
|
||
Etappe NICHT entscheidet").
|
||
|
||
## Bereich groups
|
||
|
||
Dieser Abschnitt erweitert die Kritikschrift um den Bereich `groups`
|
||
(Quick-Task 260909-jts) und beschreibt ihn zum Zeitpunkt seiner Umstellung.
|
||
Die Leitfrage aus Abschnitt (a) gilt unverändert weiter — dieser Abschnitt
|
||
beantwortet sie erneut, aber für einen Bereich, der die Berechtigungsschicht
|
||
selbst ist: Gruppenmitgliedschaft und Modulfreigaben entscheiden, wer welches
|
||
Modul sehen darf.
|
||
|
||
### (g1) Die Messung
|
||
|
||
Aufgabe 1 hat `apps/api/scripts/rls-scratch-check.mjs` um einen vierten
|
||
Abschnitt (`runGroupsAreaChecks`) und eine eigene Transaktionsmessung
|
||
(`runTransactionShapeMeasurement`) erweitert, beide gegen die Wegwerf-Datenbank
|
||
unter der Rolle ohne `BYPASSRLS`, mit den vier Policies für `Group`,
|
||
`GroupMembership`, `ModuleGrant` (aus `20260804130918_groups_rls_policies`)
|
||
und `TenantModuleActivation` (aus `20260909140000_rls_remaining_tenant_tables`)
|
||
WORTGLEICH aus den ausgelieferten Migrationen extrahiert. Tatsächlich
|
||
beobachtete Ausgabe dieses Laufs (2026-09-09, gegen `tessera-ctl-db-1`,
|
||
Adresse `172.19.0.2`):
|
||
|
||
```
|
||
group-gebunden-nur-eigene-zeile: bestanden — forTenant(TENANT-A) liefert 1 Zeile(n): ["TENANT-A"]
|
||
group-ungebunden-null-zeilen: bestanden — ungebundener SELECT auf "Group" liefert 0 Zeile(n)
|
||
groupmembership-folgt-join-auf-group: bestanden — forTenant(TENANT-A) liefert 1 Mitgliedschaft(en): ["group-a"]
|
||
groupmembership-schreiben-fremde-gruppe-abgelehnt: bestanden — INSERT mit fremder groupId abgewiesen: ERROR: new row violates row-level security policy for table "GroupMembership"
|
||
groupmembership-schreiben-fremder-benutzer-nicht-verhindert: bestanden — INSERT mit A-eigener Gruppe, aber einer Benutzerkennung, die es in A nicht gibt, ist GELUNGEN — die Policy auf GroupMembership prueft nur die Gruppenseite, nicht die Benutzerseite (Befund E)
|
||
modulegrant-gebunden-nur-eigene-zeile: bestanden — forTenant(TENANT-A) liefert 1 Zeile(n): ["TENANT-A"]
|
||
modulegrant-fremde-gruppe-trotz-eigener-mandantenkennung-erlaubt: bestanden — INSERT mit korrekter eigener tenantId, aber fremder groupId ist GELUNGEN — die Policy auf ModuleGrant prueft nur die Mandantenkennung der Zeile, nicht die referenzierte Gruppe (Befund F)
|
||
tenantmoduleactivation-gebunden-nur-eigene-zeile: bestanden — forTenant(TENANT-A) liefert 1 Zeile(n): ["TENANT-A"]
|
||
```
|
||
|
||
**NACHTRAG (260910-jab):** die beiden fett hervorgehobenen Zeilen oben
|
||
(`groupmembership-schreiben-fremder-benutzer-nicht-verhindert` und
|
||
`modulegrant-fremde-gruppe-trotz-eigener-mandantenkennung-erlaubt`) sind ein
|
||
Messprotokoll vom 2026-09-09 und bleiben UNVERÄNDERT stehen — sie belegen,
|
||
dass die beiden Löcher existierten. Seit Migration
|
||
`20260910120000_rls_widen_membership_grant_and_platform_read` sind beide
|
||
Aussagen ÜBERHOLT: die Prüfungen sind umgekehrt (`groupmembership-schreiben-
|
||
fremder-benutzer-abgelehnt`, `modulegrant-fremde-gruppe-abgelehnt`), das
|
||
INSERT wird jetzt jeweils ABGEWIESEN statt zu gelingen. Siehe den neuen
|
||
Abschnitt "Regelschluss T-JTS-02, T-JTS-03 und WINDOWS #19" unten für die
|
||
aktuell beobachtete Ausgabe.
|
||
|
||
Die Belegzeile, die diesen Abschnitt trägt, ist `group-ungebunden-null-zeilen`:
|
||
der IDENTISCHE `SELECT "tenantId" FROM "Group"` ohne vorheriges `set_config`
|
||
liefert **0 Zeilen**, nicht etwa die 2 tatsächlich vorhandenen — an der
|
||
echten, ausgelieferten Policy gemessen, nicht an einer im Werkzeug
|
||
nachgebauten Hilfstabelle.
|
||
|
||
**Die Transaktionsmessung — namentliches Ergebnis.** Drei Formen wurden
|
||
gegen einen Client beobachtet, der die Erweiterungsform aus
|
||
`prisma-tenant.extension.ts` wortgleich nachbaut, jeweils mit
|
||
`pg_backend_pid()` und `current_tenant_id()` in jeder Teilabfrage plus einem
|
||
echten Lesezugriff auf `"Group"`:
|
||
|
||
```
|
||
[beobachtet] Form (i) — Array-Form auf gebundenem Client: {"step1":{"pid":276749,"t":"TENANT-A"},"step2":{"pid":276750,"t":"TENANT-A","rows":1}}
|
||
[beobachtet] Form (ii) — interaktive Callback-Form auf gebundenem Client: {"step1":{"pid":276752,"t":"TENANT-A"},"step2":{"pid":276752,"t":"TENANT-A","rows":1}}
|
||
[beobachtet] Form (iii) — interaktive Callback-Form auf ungebundenem Client (set_config auf tx): {"step1":{"pid":276753,"applied":"TENANT-A","t":"TENANT-A"},"step2":{"pid":276753,"t":"TENANT-A","rows":1}}
|
||
mindestens-eine-transaktionsform-traegt-den-mandantenkontext: bestanden — bestanden: [Form (ii) ; Form (iii)] — nicht bestanden: [Form (i)]
|
||
```
|
||
|
||
Form (i) (Array-Form auf dem gebundenen Client) versagt eindeutig: `step1`
|
||
lief auf Verbindung 276749, `step2` auf Verbindung 276750 — zwei
|
||
verschiedene physische Verbindungen, obwohl beide Schritte denselben
|
||
Mandantenkontext lasen. Das bestätigt wortgetreu den Vorbehalt aus dem
|
||
Kopfkommentar von `prisma-tenant.extension.ts`: jede Modell-Operation eines
|
||
`$transaction`-Arrays auf dem gebundenen Client dispatcht durch
|
||
`$allOperations` und bekommt dadurch ihre EIGENE Ein-Element-Transaktion —
|
||
mehrere solche Operationen laufen auf mehreren Teiltransaktionen statt
|
||
einer gemeinsamen. In diesem konkreten Fall lieferte jede Teiltransaktion
|
||
zwar noch den korrekten Mandantenkontext (kein Datenleck), aber die
|
||
Atomarität der äußeren Transaktion ist nicht mehr gegeben — bei einem
|
||
Absturz zwischen den beiden Teiltransaktionen bliebe der Zustand
|
||
inkonsistent.
|
||
|
||
Form (ii) und Form (iii) bestanden beide die im Plan festgelegte
|
||
Einzelmessung (gleiche Verbindungskennung, korrekter Kontext, korrekte
|
||
Zeilenzahl). Über diese Einzelmessung hinaus wurde als zusätzliche,
|
||
sicherheitsrelevante Sorgfaltsprüfung (nicht durch den Plan verlangt, aber
|
||
durch die Tragweite dieses Bereichs geboten) beide Formen unter echter
|
||
Nebenläufigkeit erneut gemessen — 40 parallele Aufrufe über EINEN
|
||
gemeinsamen Client, alternierend TENANT-A/TENANT-B.
|
||
|
||
Diese Belastungsprobe lief zunächst gegen eine separate Experiment-Datenbank
|
||
und war damit **nicht nachvollziehbar** — eine Zahl, die eine Entscheidung
|
||
trug, ohne dass jemand sie hätte nachprüfen können. Genau das Anti-Muster,
|
||
das dieses Projekt sich selbst verboten hat. Sie ist deshalb als
|
||
`runConcurrencyProbe` in `apps/api/scripts/rls-scratch-check.mjs`
|
||
nachgereicht worden und läuft seither bei jedem Werkzeuglauf mit. Als
|
||
Verletzung zählt beides: ein Aufruf, der einen fremden oder gar keinen
|
||
Mandantenkontext sieht, und ein Aufruf, der abbricht.
|
||
|
||
Gemessen wird, nicht behauptet:
|
||
|
||
- **Form (ii)** bricht unter dieser Last ab, mit Fehlern der Familie
|
||
`PrismaClientKnownRequestError: Transaction API error` (Code `P2028`).
|
||
Ursache: jede
|
||
`tx.$queryRaw`-Anweisung innerhalb der interaktiven Transaktion auf dem
|
||
gebundenen Client löst selbst wieder eine VERSCHACHTELTE
|
||
Array-Transaktion auf dem äußeren, ungebundenen Client aus (weil
|
||
`$allOperations` bei jedem Aufruf erneut feuert) — die äußere
|
||
interaktive Transaktion UND jede innere Verschachtelung belegen
|
||
gleichzeitig eine Verbindung aus demselben, endlichen Pool. Unter Last
|
||
reicht der Pool nicht mehr aus.
|
||
- **Form (iii)** besteht dieselbe Belastung ohne Verletzung — sie belegt
|
||
pro Aufruf genau eine Verbindung, ohne Verschachtelung.
|
||
|
||
Die konkreten Zahlen eines einzelnen Laufs stehen bewusst NICHT in diesem
|
||
Dokument, sondern fallen bei jeder Ausführung neu an; der Werkzeuglauf vom
|
||
2026-09-09 ergab 24 Verletzungen von 40 für Form (ii) und 0 von 40 für
|
||
Form (iii). **Geprüft** wird nur die Eigenschaft, auf die sich der Code
|
||
stützt — Form (iii) ohne Verletzung. Das Verhalten von Form (ii) läuft
|
||
daneben als ausgedruckte Beobachtung mit und ist bewusst KEINE Bedingung
|
||
für einen grünen Lauf: ab welcher Last sie bricht, hängt an
|
||
Verbindungsvorrat und Maschine.
|
||
|
||
Das ist der entscheidende Befund für die Werkzeugentscheidung in Aufgabe 2:
|
||
obwohl Form (ii) die im Plan geforderte EINZELMESSUNG technisch besteht,
|
||
ist sie unter echter Nebenläufigkeit strukturell fragil und ein
|
||
Denial-of-Service-Risiko genau an der Stelle, die T-JTS-08 benennt (die
|
||
Startreparatur ruft `ensureDefaultGroup` für mehrere Mandanten auf). Form
|
||
(iii) ist die einzige der drei Formen, die sowohl die Einzelmessung als
|
||
auch die Belastungsprobe besteht — sie ist deshalb die Grundlage des neuen
|
||
Hilfsmittels `withTenantTransaction` in `prisma-tenant.extension.ts`.
|
||
|
||
### (g2) Signaltabelle je umzustellendem Pfad
|
||
|
||
| Pfad | Verhalten bei zu wenig Ergebnis | Konkretes Signal, Ort |
|
||
|---|---|---|
|
||
| `GroupsService.listForTenant` | Liefert 0 Gruppen statt der tatsächlich vorhandenen | Die Gruppenliste in der Verwaltung ist leer (Mitgliederzahl je Zeile fehlt ganz, weil die Zeile fehlt) |
|
||
| `GroupsService.getImpact` | Liefert `{memberCount:0, grantCount:0}` statt der tatsächlichen Zahlen | Der Löschdialog zeigt seine zwei Zahlen als 0/0 — der Administrator entscheidet über eine kaskadierende Löschung auf falscher Grundlage |
|
||
| `GroupsService.listMembers` | Liefert 0 Mitglieder statt der tatsächlich vorhandenen | Die Mitgliederliste im Gruppen-Detail ist leer |
|
||
| `ModuleGrantsService.getMatrix` | Liefert 0 Module und/oder 0 Gruppen statt der tatsächlich aktiven/vorhandenen | Die Freigabe-Matrix zeigt weder Modul- noch Gruppenachse vollständig — eine leere Zelle sieht identisch aus wie eine bewusst nicht erteilte Freigabe |
|
||
| `ModuleGrantsService.getUserAccess` | Liefert 0 Module bzw. 0 Gruppen statt der tatsächlichen | Das Benutzer-Detail zeigt für beide unabhängigen Antworten (Gruppenmitgliedschaften, Modulzugriff) fälschlich "keine" |
|
||
| `LdapService.syncBoundGroupsForTenant` (Übergabe an `reassignDefaultBeforeDelete`/`ensureDefaultGroup`) | Der Zähler `defaultMarkerMoved` im Abgleich-Bericht bleibt bei 0, obwohl tatsächlich verschoben wurde — oder eine Gruppe verliert ihre Standardmarkierung ersatzlos | Der Abgleich-Bericht des Verzeichnis-Syncs (D-05/D-06) |
|
||
| `GroupsService.addUserToDefaultGroup` | Findet die Standardgruppe nicht (0 Zeilen statt der einen vorhandenen) und tut nichts | Ein frisch angelegter Benutzer sieht nach seiner ersten Anmeldung KEIN Modul (leere Modulkacheln) |
|
||
|
||
### (g3) Welcher Code deutet Leere als Abwesenheit — Bereich groups
|
||
|
||
Ausgangspunkt ist Befund I aus der Planung, ergänzt um eine erneute Sichtung
|
||
beider Dateien:
|
||
|
||
- **`GroupsService.reassignDefaultBeforeDelete`** — zerstörend und still.
|
||
Zwei getrennte Stellen liefern `false`: die Gruppe selbst ist nicht
|
||
sichtbar (`findFirst` auf `Group` liefert 0 Zeilen), oder es ist kein
|
||
Ersatzkandidat sichtbar (beide `findFirst`-Fallbacks liefern 0 Zeilen).
|
||
Der Aufrufer im Verzeichnis-Sync (`syncBoundGroupsForTenant`) löscht die
|
||
Gruppe danach in BEIDEN Fällen trotzdem, und die Löschung nimmt über die
|
||
Kaskadenregeln aus 15-01 Mitgliedschaften und Modulfreigaben mit. Das ist
|
||
Befund D aus der ldap-Kritik (Abschnitt (e) oben); ihn zu schließen ist
|
||
ein Hauptzweck dieses Durchlaufs.
|
||
- **`GroupsService.ensureDefaultGroup`** — die einzige Stelle des Bereichs,
|
||
an der zu wenig Lesen zu ZU VIEL Schreiben führt. Der Wächter ist
|
||
UMGEKEHRT gepolt: null gelesene Gruppen (`group.count` liefert 0 statt
|
||
der tatsächlichen Anzahl) heißt hier nicht "nichts zu tun", sondern
|
||
"alles neu aufbauen". Bliebe der Zähler ungebunden, während der
|
||
Schreibteil (die Transaktion) gebunden liefe, legte die Methode für einen
|
||
Mandanten, der bereits Gruppen hat, eine ZWEITE Standardgruppe an, nähme
|
||
ALLE seine Benutzer als Mitglieder auf und verteilte Freigaben für ALLE
|
||
aktiven Module — eine stille Ausweitung von Berechtigungen, ausgelöst
|
||
durch ein zu kleines Leseergebnis. Der partielle Eindeutigkeitsindex
|
||
`Group_one_default_per_tenant` fängt einen Teil der Fälle ab (P2002 beim
|
||
`group.create`, abgefangen und in `null` übersetzt) — aber nur, wenn der
|
||
Mandant BEREITS eine markierte Standardgruppe hat. Einen Mandanten mit
|
||
Gruppen, aber OHNE markierte Standardgruppe, fängt der Index nicht ab.
|
||
Genau deshalb müssen Zähler und Transaktion GEMEINSAM gebunden werden,
|
||
nie einzeln (T-JTS-05).
|
||
- **`GroupsService.getImpact`** — die Zahlen des Löschdialogs. Zwei
|
||
Zählungen (`groupMembership.count`, `moduleGrant.count`) ohne
|
||
Mandantenfilter, die bei Leere 0 und 0 melden. Der Administrator
|
||
entscheidet auf dieser Grundlage über eine kaskadierende Löschung und
|
||
bekommt "keine Mitglieder, keine Freigaben" für eine tatsächlich volle
|
||
Gruppe angezeigt.
|
||
- **`GroupsService.addUserToDefaultGroup`** — stilles Zurückkehren ohne
|
||
sichtbare Standardgruppe (`group.findFirst` liefert 0 Zeilen statt der
|
||
einen vorhandenen). Jeder neu angelegte Benutzer landet dann in KEINER
|
||
Gruppe und sieht nach seiner ersten Anmeldung kein einziges Modul. Nicht
|
||
zerstörend, aber lautlos und in der Wirkung ein Berechtigungsverlust.
|
||
|
||
Gegenrichtung, ebenfalls festgehalten: `ModuleGrantsService.grant` (die
|
||
Aktivierungsprüfung: kein aktives `TenantModuleActivation` wirft
|
||
`BadRequestException`, statt still zu erteilen) und
|
||
`ModuleGrantsService.assertTargetBelongsToTenant` (die
|
||
Mandanten-Gegenprüfung: kein Treffer wirft `NotFoundException`) werfen bei
|
||
Leere LAUT und sind damit die harmlosen Stellen des Bereichs.
|
||
|
||
Entlastung, ausdrücklich am Frontend nachgesehen statt aus dem Backend
|
||
geschlossen: `apps/web/src/app/(portal)/admin/modules/grants/page.tsx`
|
||
schaltet die Freigabe-Matrix je Zelle einzeln (ein `POST` bzw. `DELETE` pro
|
||
Klick) — es gibt keinen Sammel-Speichern-Knopf, der einen Abgleich gegen den
|
||
gelesenen Zustand fährt. Ein zu kleines Leseergebnis führt dort also zu
|
||
einer leeren Anzeige, nicht zu einem Massen-Entzug. Das ist der Unterschied
|
||
zum `deleteMany`-mit-`notIn` des ldap-Bereichs.
|
||
|
||
### (g4) Was dieser Durchlauf bewusst nicht löst
|
||
|
||
- **Die offene Architekturfrage `req.tenantPrisma`.** Auch der Bereich
|
||
`groups` entscheidet sie nicht — er bindet dienst-intern, wie `ldap` und
|
||
`auth.service.ts` es vormachen.
|
||
- **Die Policies auf `GroupMembership` und `ModuleGrant` prüfen jeweils nur
|
||
eine Seite.** Gemessen in (g1): `GroupMembership` prüft ausschließlich die
|
||
Gruppenseite (Befund E, T-JTS-02) — eine Mitgliedschaft mit einer
|
||
Benutzerkennung, die es im Mandanten der Gruppe nicht gibt, verletzt die
|
||
Policy NICHT. `ModuleGrant` prüft ausschließlich die Mandantenkennung der
|
||
Zeile selbst (Befund F, T-JTS-03) — eine Freigabe mit korrekter eigener
|
||
Mandantenkennung, aber einer fremden Gruppenkennung, verletzt die Policy
|
||
ebenfalls NICHT. Die Anwendungsprüfungen (die Benutzerfilterung in
|
||
`addUserToDefaultGroup`, `assertTargetBelongsToTenant`) bleiben deshalb
|
||
der primäre Schutz gegen diese beiden Formen der Rechteausweitung und
|
||
werden durch diesen Durchlauf NICHT durch die Datenbank ersetzt.
|
||
**NACHTRAG (260910-jab):** ÜBERHOLT — seit Migration
|
||
`20260910120000_rls_widen_membership_grant_and_platform_read` prüft
|
||
`GroupMembership` BEIDE Seiten (T-JTS-02 geschlossen) und `ModuleGrant`
|
||
zusätzlich beide möglichen Ziele (T-JTS-03 geschlossen, beide Zweige des
|
||
Entweder-oder D-04). Die Anwendungsprüfungen bleiben trotzdem bestehen —
|
||
sie sind bis zum Scharfschalten (#18) der einzige tatsächlich wirksame
|
||
Schutz, siehe den neuen Abschnitt "Regelschluss T-JTS-02, T-JTS-03 und
|
||
WINDOWS #19" unten.
|
||
|
||
### (g5) Fortschreibung des ldap-Abschnitts
|
||
|
||
Der in Abschnitt (e) oben als offen geführte Befund D — die Übergabe der
|
||
Standardgruppe vor einer Gruppenlöschung — wird durch diesen Durchlauf
|
||
geschlossen. Der Vermerk selbst steht am Ende von Abschnitt (e), gesetzt
|
||
in Aufgabe 3 dieses Plans, weil die Schließung erst zu diesem Zeitpunkt
|
||
tatsächlich vorliegt.
|
||
|
||
## Bereich tenders
|
||
|
||
Dieser Abschnitt erweitert die Kritikschrift um den Bereich `tenders`
|
||
(Quick-Task 260909-laa) und beschreibt ihn zum Zeitpunkt seiner Umstellung.
|
||
Die Leitfrage aus Abschnitt (a) gilt unverändert weiter — dieser Abschnitt
|
||
beantwortet sie erneut, aber für einen Bereich, der überwiegend NICHT
|
||
umgestellt wird: von 23 Paaren sind fünf umzustellen, zehn bleiben bewusst
|
||
der plattformweite Ausschreibungskatalog (D-03), zwei sind bewusste
|
||
Fan-outs, und sechs zerfallen in eine übergreifende Hälfte (Etappe 3) und
|
||
eine mandantengebundene Hälfte (hier). Dieser Bereich trägt außerdem eine
|
||
Fehlerform, die `ldap` und `groups` nicht hatten: zwei Benachrichtigungswege,
|
||
die bei zu kleinem Leseergebnis nicht falsch handeln, sondern GAR NICHT.
|
||
|
||
### (t1) Die Messung
|
||
|
||
Aufgabe 1 hat `apps/api/scripts/rls-scratch-check.mjs` um einen fünften
|
||
Abschnitt (`runTendersAreaChecks`) erweitert, mit den fünf Policies für
|
||
`TenderEmailConfig`, `TenderNotificationPref`, `TenderRssFeedSource`,
|
||
`TenderSavedSearch` und `TenderTriage` (alle aus der ausgelieferten
|
||
Migration `20260909140000_rls_remaining_tenant_tables`) WORTGLEICH
|
||
extrahiert, nicht im Werkzeug nachgetippt. Tatsächlich beobachtete Ausgabe
|
||
dieses Laufs (2026-09-09, gegen `tessera-ctl-db-1`, Adresse `172.19.0.2`):
|
||
|
||
```
|
||
tendersavedsearch-gebunden-nur-eigener-mandant: bestanden — forTenant(TENANT-A) liefert 2 Zeile(n): ["TENANT-A","TENANT-A"]
|
||
tendersavedsearch-ungebunden-null-zeilen: bestanden — ungebundener SELECT auf "TenderSavedSearch" liefert 0 Zeile(n)
|
||
tendersavedsearch-fremder-nutzer-desselben-mandanten-sichtbar: bestanden — forTenant(TENANT-A) liefert AUCH die Zeile des zweiten Nutzers (user-a2) — die Policy auf TenderSavedSearch prueft nur die Mandantenkennung, nicht die Benutzerkennung; die anwendungsseitige userId-Filterung bleibt der einzige Schutz gegen Quer-Lesen zwischen Nutzern desselben Mandanten und darf nicht entfallen
|
||
tenderemailconfig-gebunden-nur-eigener-mandant: bestanden — forTenant(TENANT-A) liefert 1 Zeile(n): ["TENANT-A"]
|
||
tendertriage-gebunden-nur-eigener-mandant: bestanden — forTenant(TENANT-A) liefert 1 Zeile(n): ["TENANT-A"]
|
||
tendernotificationpref-gebunden-nur-eigener-mandant: bestanden — forTenant(TENANT-A) liefert 1 Zeile(n): ["TENANT-A"]
|
||
tenderrssfeed-plattformzeile-unter-jedem-mandanten-unsichtbar: bestanden — forTenant(TENANT-A) sieht die Platform-Zeile: false, forTenant(TENANT-B) sieht sie: false — WINDOWS #19: eine plattformweite RSS-Quelle ist unter JEDEM Mandantenkontext unsichtbar; listForUser/createPlatform/remove duerfen deshalb nicht gebunden werden, solange die Policy-Semantik unveraendert ist
|
||
tenderrssfeed-gebundenes-einfuegen-ohne-mandant-abgelehnt: bestanden — gebundenes INSERT mit tenantId=NULL abgewiesen: ERROR: new row violates row-level security policy for table "TenderRssFeedSource" — createPlatform darf deshalb nicht gebunden werden
|
||
tendertriage-einfuegen-auf-unsichtbare-zeile-verletzt-eindeutigkeit: bestanden — gebundenes INSERT auf die unsichtbare (userId,tenderId)-Kombination scheitert an der Eindeutigkeitsbedingung, nicht an der Policy: Code 23505, Unique constraint failed — Befund F: aus einem stillen Ueberschreiben wird bei Aufgabe 2 ein harter, verstaendlich uebersetzter Fehler
|
||
Alle 32 Pruefungen bestanden.
|
||
```
|
||
|
||
**NACHTRAG (260910-jab):** die fett hervorgehobene Zeile oben
|
||
(`tenderrssfeed-plattformzeile-unter-jedem-mandanten-unsichtbar`) ist ein
|
||
Messprotokoll vom 2026-09-09 und bleibt UNVERÄNDERT stehen — sie belegt,
|
||
dass WINDOWS #19 existierte. Seit Migration
|
||
`20260910120000_rls_widen_membership_grant_and_platform_read` ist diese
|
||
Aussage ÜBERHOLT: die Prüfung ist umgekehrt
|
||
(`tenderrssfeed-plattformzeile-gebunden-sichtbar`), die plattformweite Zeile
|
||
ist jetzt unter BEIDEN Mandantenkontexten SICHTBAR. Siehe den neuen
|
||
Abschnitt "Regelschluss T-JTS-02, T-JTS-03 und WINDOWS #19" unten für die
|
||
aktuell beobachtete Ausgabe.
|
||
|
||
Die Belegzeile, die diesen Abschnitt trägt, ist
|
||
`tendersavedsearch-ungebunden-null-zeilen`: der IDENTISCHE
|
||
`SELECT "tenantId" FROM "TenderSavedSearch"` ohne vorheriges `set_config`
|
||
liefert **0 Zeilen**, nicht etwa die 3 tatsächlich vorhandenen — an der
|
||
echten, ausgelieferten Policy gemessen, nicht an einer im Werkzeug
|
||
nachgebauten Hilfstabelle.
|
||
|
||
Zwei Messungen dieses Laufs tragen eine Entscheidung, die kein bisheriger
|
||
Bereich brauchte:
|
||
|
||
- `tendersavedsearch-fremder-nutzer-desselben-mandanten-sichtbar` — das
|
||
GELINGEN (beide Nutzer von TENANT-A sind sichtbar) IST das bestandene
|
||
Ergebnis. Alle fünf Policies dieses Bereichs lauten schlicht
|
||
`"tenantId" = current_tenant_id()`, ohne Benutzerdimension — zwei Nutzer
|
||
DESSELBEN Mandanten sind füreinander vollständig sichtbar. Die
|
||
anwendungsseitige `userId`-Filterung, die alle fünf umzustellenden
|
||
Dienste bereits führen, bleibt deshalb der einzige Schutz gegen
|
||
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
|
||
plattformweite RSS-Zeile (`userId`/`tenantId` beide `NULL`, wie der
|
||
geseedete `service.bund.de`-Feed) ist unter TENANT-A UND TENANT-B
|
||
gebunden unsichtbar, weil `NULL = current_tenant_id()` in SQL nie wahr
|
||
ist. Das ist WINDOWS #19, hier nicht als ferne Sorge, sondern als der
|
||
Grund, warum `listForUser`, `createPlatform` und `remove` in
|
||
`tender-rss-feed.service.ts` NICHT gebunden werden — eine Bindung würde
|
||
die plattformweite Quelle für JEDEN Mandanten verschwinden lassen.
|
||
`tenderrssfeed-gebundenes-einfuegen-ohne-mandant-abgelehnt` bestätigt die
|
||
Kehrseite: ein gebundenes `INSERT` mit `tenantId = NULL` wird von der
|
||
ausgelieferten Policy abgewiesen — `createPlatform` würde in genau diese
|
||
Abweisung laufen, würde man es binden.
|
||
**NACHTRAG (260910-jab):** ÜBERHOLT für `listForUser` — seit Migration
|
||
`20260910120000_rls_widen_membership_grant_and_platform_read` bindet
|
||
dieser Pfad (die neue Leseregel schließt Zeilen ohne Mandant ausdrücklich
|
||
ein). `createPlatform`/`remove` bleiben unverändert ungebunden, aus dem
|
||
jetzt ausdrücklichen Grund einer eigenen Schreibregel je Befehl (WINDOWS
|
||
#24).
|
||
|
||
Eine dritte Messung trägt die Fehlerbehandlung von Aufgabe 2:
|
||
`tendertriage-einfuegen-auf-unsichtbare-zeile-verletzt-eindeutigkeit` zeigt,
|
||
dass ein gebundenes `INSERT` auf ein `(userId, tenderId)`-Paar, dessen Zeile
|
||
existiert, aber einem anderen Mandanten gehört und deshalb unsichtbar ist,
|
||
an der Eindeutigkeitsbedingung scheitert (Postgres prüft Unique-Indizes
|
||
gegen die physischen Zeilen, unabhängig von der RLS-Sichtbarkeit) — NICHT
|
||
an einer Policy-Abweisung. Aus einem stillen Überschreiben wird dadurch ein
|
||
harter Fehler, der in Aufgabe 2 als verständliche deutsche Meldung
|
||
herauskommen muss, nicht als roher 500er.
|
||
|
||
TEIL 2 dieser Aufgabe hat zusätzlich nachgemessen, dass dieser Bereich
|
||
keine mandantengebundene Transaktion enthält (Befund A):
|
||
`grep -rn '\$transaction(' apps/api/src/tenders --include=*.ts | grep -v spec`
|
||
liefert genau EINEN Treffer, `tender-fingerprint-backfill.service.ts:89`,
|
||
die Array-Form auf der plattformweiten Tabelle `Tender` (D-03), außerhalb
|
||
jeder Mandantenbindung. Der im Kopf von `prisma-tenant.extension.ts`
|
||
verlangte erneute Test vor jedem neuen `forTenant()`-Fall mit eigener
|
||
Transaktion ist damit für diesen Bereich beantwortet: es fällt kein neuer
|
||
Fall an, `withTenantTransaction()` wird hier nicht gebraucht und auch
|
||
nicht eingeführt.
|
||
|
||
### (t2) Signaltabelle je umgestelltem Pfad
|
||
|
||
| Pfad | Verhalten bei zu wenig Ergebnis | Konkretes Signal |
|
||
|---|---|---|
|
||
| `TenderSavedSearchService.list` | Liefert 0 gespeicherte Suchprofile statt der tatsächlich vorhandenen | Die Suchprofil-Leiste im Ausschreibungs-Radar ist leer für einen Nutzer, der tatsächlich Profile gespeichert hat |
|
||
| `TenderSavedSearchService.create/update/remove` | Die Besitzprüfung (`findUnique` gebunden) liefert 0 Zeilen statt der eigenen Zeile | `PATCH`/`DELETE /saved-searches/:searchId` scheitert mit 404, obwohl das Profil existiert; `POST` legt scheinbar erfolgreich ein neues Profil an (unkritisch, betrifft nur den Lesepfad danach) |
|
||
| `TenderTriageService.listForUser` | Die Markierungsabfrage liefert 0 Treffer statt der tatsächlich vorhandenen | Die Trefferliste zeigt für bereits gelesene/favorisierte Ausschreibungen keine Markierung mehr — ein bereits gelesener Treffer erscheint wieder als ungelesen |
|
||
| `TenderTriageService.favoriteIds` | Liefert 0 favorisierte tenderIds statt der tatsächlich vorhandenen | Der Merklisten-Filter (`favOnly`) zeigt eine leere Liste für einen Nutzer, der tatsächlich Favoriten hat |
|
||
| `TenderNotificationPrefService.getForUser` | **Sonderfall**: kein Treffer bedeutet hier nicht "leer", sondern der Vorgabewert `daily` (D-01) | Ein Nutzer, der `off` gewählt hat, sieht in der Oberfläche wieder `daily` — ein zu kleines Leseergebnis setzt die Einstellung stillschweigend auf täglich zurück, statt sie leer zu lassen |
|
||
| `TenderEmailConfigService.getConfigForApi` | Der gebundene `findUnique` liefert 0 Zeilen statt der eigenen Konfiguration | `GET /email-config` liefert `null`; die Oberfläche zeigt "kein Postfach verbunden" für einen Nutzer, der tatsächlich eines hat |
|
||
| `TenderEmailConfigService.testConnection` | Der gebundene Rückgriff auf gespeicherte Zugangsdaten liefert 0 Zeilen statt der eigenen | Ein Verbindungstest mit leer gelassenem Formular (Rückgriff auf gespeicherte Zugangsdaten) schlägt fehl, obwohl gespeicherte Zugangsdaten existieren |
|
||
| `TenderRssFeedSourceService.listForUser`/`createPlatform`/`remove` (bewusst UNGEBUNDEN, WINDOWS #19) | Betrifft nicht diese drei Pfade selbst — sie binden nicht und liefern deshalb weiterhin die plattformweite Zeile korrekt. Das Risiko liegt in einer KÜNFTIGEN Bindung (Etappe 3), nicht in dieser Umstellung | Würde man sie binden: leere Feed-Liste bzw. 404 beim Entfernen einer plattformweiten Quelle — hier ausdrücklich als Grenze festgehalten, nicht als heute beobachtbares Verhalten. **NACHTRAG (260910-jab):** ÜBERHOLT für `listForUser` — dieser Pfad ist seit Migration `20260910120000_rls_widen_membership_grant_and_platform_read` GEBUNDEN (WINDOWS #19 geschlossen, Befund F: ungebunden hätte die Reparatur ihn sonst still auf nur die plattformweiten Zeilen reduziert). `createPlatform`/`remove` bleiben unverändert ungebunden (WINDOWS #24). |
|
||
| `TenderRssFeedSourceService.createForUser` (gebunden) | Der Zähler (`count`, gebunden) liefert 0 statt der tatsächlichen Anzahl eigener Feeds | Ein Nutzer, der bereits am Limit von 20 eigenen Feeds ist, könnte scheinbar unbegrenzt neue anlegen (harmlose Richtung: die Kappung aus T-17-10 wirkt nicht mehr, kein Datenverlust) |
|
||
|
||
### (t3) Welcher Code Leere als Abwesenheit deutet
|
||
|
||
**Sichtbare Formen** (Anzeige bleibt leer oder fällt auf einen Vorgabewert
|
||
zurück, jemand merkt es beim nächsten Blick auf die Oberfläche): siehe
|
||
Tabelle (t2) oben — Suchprofilliste, Triage-Markierung, Favoriten-Filter,
|
||
der `daily`-Sonderfall der Benachrichtigungseinstellung, und die
|
||
Postfach-Anzeige.
|
||
|
||
**Lautlose Formen — die zusätzliche Fehlerform dieses Bereichs.** Fünf
|
||
Stellen in den beiden Hintergrunddiensten deuten ein zu kleines
|
||
Leseergebnis nicht als Fehler, sondern als "nichts zu tun", und
|
||
protokollieren dabei NICHTS:
|
||
|
||
1. **`tender-digest.scheduler.ts`, `if (!candidates.length) return;`** —
|
||
liefert die gebundene Kandidatenabfrage innerhalb eines Profildurchlaufs
|
||
zu wenig, bricht der GESAMTE Digest für diesen Lauf ab, für ALLE
|
||
Mandanten, ohne Protokolleintrag.
|
||
2. **`tender-digest.scheduler.ts`, `if (!matches.length) continue;`** —
|
||
liefert die gebundene Treffer-Abfrage für einen Kandidaten zu wenig,
|
||
bekommt dieser eine Nutzer keine Post, der Lauf macht mit dem nächsten
|
||
Kandidaten weiter, ohne Protokolleintrag.
|
||
3. **`tender-digest.scheduler.ts`, `if (!user || !user.email) continue;`**
|
||
— liefert die gebundene Benutzer-Abfrage zu wenig, bekommt dieser
|
||
Nutzer keine Post, ohne Protokolleintrag.
|
||
4. **`tender-matching.service.ts`, `if (!fresh.length) continue;`** —
|
||
liefert die gebundene Abfrage der noch nicht benachrichtigten Treffer
|
||
innerhalb eines Profildurchlaufs zu wenig, bekommt dieser Nutzer keinen
|
||
Sofort-Alarm, ohne Protokolleintrag.
|
||
5. **`tender-matching.service.ts`, `if (!user || !user.email) continue;`**
|
||
— dieselbe Form wie Stelle 3, für den Sofort-Alarm-Pfad.
|
||
|
||
Keine dieser fünf Stellen protokolliert etwas — eine ausbleibende Warnung
|
||
erzeugt keine Fehlermeldung, keinen Protokolleintrag und keine Beschwerde,
|
||
außer der stillen Abwesenheit einer E-Mail, die niemand erwartet, weil
|
||
niemand wusste, dass sie hätte kommen sollen.
|
||
|
||
Entlastung in dieselbe Richtung, ebenfalls nachgesehen statt geschlossen
|
||
behauptet: weil `notifiedAt` auf `TenderMatch` nur nach ERFOLGREICHEM
|
||
Versand gestempelt wird (D-06), bleiben die betroffenen Zeilen auf
|
||
`notifiedAt IS NULL` stehen und werden beim nächsten Lauf erneut versucht.
|
||
Es geht also nichts verloren — es kommt nur nichts an, solange die Ursache
|
||
(z. B. ein nach dem Scharfschalten ungebunden gebliebener Lesepfad)
|
||
fortbesteht. Daraus ergibt sich das einzige nachprüfbare Signal dieser
|
||
Fehlerform: eine wachsende Zahl von `TenderMatch`-Zeilen mit
|
||
`notifiedAt IS NULL` bei gleichzeitig fehlendem Versandprotokoll. Dieses
|
||
Signal gehört in die Vorabprüfung von Etappe 4 (`rls-preflight.mjs`), NICHT
|
||
in diesen Durchlauf — Aufgabe 3 hält es hier fest, löst es aber nicht.
|
||
|
||
Eine Laufzeitwarnung an den fünf Stellen wurde erwogen und VERWORFEN, aus
|
||
demselben Grund wie bei `getAllActiveConfigs` im `ldap`-Durchlauf: beide
|
||
Hintergrunddienste laufen regelmäßig, und "kein passender Kandidat"/"kein
|
||
Konto mit Adresse" ist ein regulärer Zustand, kein Fehlerfall — eine
|
||
Warnung wäre Dauerlärm und verlöre ihr Signal.
|
||
|
||
### (t4) Was dieser Durchlauf bewusst nicht löst
|
||
|
||
- **WINDOWS #19 — nullbares `tenantId` bei `TenderRssFeedSource`.**
|
||
Gemessen in (t1): eine plattformweite Zeile ist unter JEDEM
|
||
Mandantenkontext unsichtbar, ein gebundenes Einfügen ohne Mandant wird
|
||
abgewiesen. `listForUser`, `createPlatform` und `remove` bleiben deshalb
|
||
bewusst UNGEBUNDEN, mit einem Codekommentar, der die Grenze benennt. Die
|
||
Policy-Semantik selbst (eine `tenantId IS NULL OR tenantId =
|
||
current_tenant_id()`-Lesevariante) gehört zu Etappe 3 und wird hier nicht
|
||
angefasst.
|
||
**NACHTRAG (260910-jab): ÜBERHOLT — WINDOWS #19 ist geschlossen.** Migration
|
||
`20260910120000_rls_widen_membership_grant_and_platform_read` ersetzt die
|
||
einfache Regel durch vier nach Befehl getrennte Regeln
|
||
(`tenant_platform_read_policy` schließt Zeilen ohne Mandant beim Lesen
|
||
ausdrücklich ein, die drei Schreibregeln verlangen weiterhin einen
|
||
Mandanten). `listForUser` ist seither GEBUNDEN (Befund F: ungebunden hätte
|
||
die Reparatur ihn sonst still auf nur die plattformweiten Zeilen
|
||
reduziert); `createPlatform`/`remove` bleiben bewusst ungebunden — beide
|
||
Pfade lassen sich unter der Anwendungsrolle grundsätzlich nicht
|
||
anlegen/entfernen (WINDOWS #24, eigener offener Punkt, verschwindet nicht
|
||
mit #19). Siehe den neuen Abschnitt "Regelschluss T-JTS-02, T-JTS-03 und
|
||
WINDOWS #19" unten.
|
||
- **Die übergreifenden Hälften der beiden Hintergrunddienste.** Aufgabe 3
|
||
bindet nur die Je-Treffer-Hälften von `tender-digest.scheduler.ts` und
|
||
`tender-matching.service.ts`; die Kandidatenabfrage
|
||
(`tenderMatch.findMany`/`tenderSavedSearch.findMany`) bleibt bewusst
|
||
über alle Mandanten hinweg ungebunden und ist als Etappe-3-Übergabe
|
||
kommentiert — siehe `docs/mandantentrennung-zugriffsklassifikation.md`,
|
||
Abschnitt "Der Hintergrunddienst als Falle".
|
||
|
||
**Nachtrag (260909-laa, Aufgabe 3): Je-Treffer-Hälften GESCHLOSSEN,
|
||
übergreifende Hälften ausdrücklich an Etappe 3 übergeben.** Innerhalb der
|
||
Kandidatenschleife von `tender-digest.scheduler.ts`
|
||
(`tenderNotificationPref.findUnique`, `tenderMatch.findMany`/`updateMany`,
|
||
`user.findUnique`) und der Profilschleife von `tender-matching.service.ts`
|
||
(`tenderMatch.upsert` in der Treffer-Anlage, sowie im nachgelagerten
|
||
Instant-Dispatch `tenderMatch.findMany`/`updateMany` und
|
||
`user.findUnique`) laufen jetzt alle Zugriffe über `forTenant()`,
|
||
gebunden an den Mandanten der jeweiligen Kandidaten-/Profilzeile — je EIN
|
||
gebundener Client pro Zeile, nicht neu je Modellzugriff. Belegt durch
|
||
Bindungstests je Methode UND einen Falsifizierungsnachweis (ein
|
||
probeweiser Rückbau der `user.findUnique`-Bindung im Instant-Dispatch von
|
||
`tender-matching.service.ts` machte genau den erwarteten Test rot, danach
|
||
zurückgenommen — siehe 260909-laa-SUMMARY.md). Die übergreifenden
|
||
Kandidaten-/Profilabfragen selbst
|
||
(`tenderMatch.findMany({distinct:['userId']})` bzw.
|
||
`tenderSavedSearch.findMany()`) bleiben UNVERÄNDERT ungebunden und tragen
|
||
im Code einen Kommentar, der sie als Etappe-3-Übergabe benennt — die
|
||
Trennlinie zwischen Etappe 2 und Etappe 3 wird hier gezogen, nicht
|
||
verwischt. Der Sonderfall, dass ein Nutzer Treffer unter zwei
|
||
verschiedenen Mandanten haben könnte (der Digest wählt das
|
||
denormalisierte `tenantId` der Kandidatenzeile über `distinct`, was bei
|
||
einem Mandantenwechsel veraltet sein kann), ist NICHT gelöst, sondern im
|
||
Code und hier benannt — Gegenstand von Etappe 3.
|
||
- **Befund K — die Abhängigkeit vom noch nicht umgestellten Bereich
|
||
`settings`.** `tender-mail.service.ts` holt die SMTP-Angaben über
|
||
`SettingsService.getDecryptedSmtpConfig(tenantId)`; `settings.service.ts`
|
||
ist noch vollständig unbound (4 Rohtreffer, siehe Übersichtstabelle in
|
||
`docs/mandantentrennung-zugriffsklassifikation.md`). Nach dem
|
||
Scharfschalten fände diese ungebundene Abfrage keine SMTP-Zeile mehr —
|
||
Ergebnis: kein Versand für niemanden, mit Wiederholung bei jedem Lauf
|
||
(dieselbe Entlastung wie in (t3)). Das ist eine Reihenfolgebedingung für
|
||
Etappe 4, genau wie Befund D des `ldap`-Durchlaufs es für `groups` war —
|
||
hier festgehalten, nicht gelöst.
|
||
|
||
**Nachtrag (260911-gwh):** `getDecryptedSmtpConfig(tenantId)` läuft seit
|
||
Aufgabe 2 dieses Laufs über `forTenant()` (GENAU EIN Klient `tenantPrisma`
|
||
je Aufruf). Die Reihenfolgebedingung ist damit ERFÜLLT — siehe
|
||
`docs/mandantentrennung-zugriffsklassifikation.md`, Bestandsaufnahme-Zeile
|
||
`settings.service.ts`/`smtpConfig`, und den Hintergrunddienst-Abschnitt
|
||
dort. Die Etappe-4-Vorabprüfung muss diese Bedingung ab jetzt NICHT mehr
|
||
führen.
|
||
- **Die offene Architekturfrage `req.tenantPrisma`.** Auch der Bereich
|
||
`tenders` entscheidet sie nicht — er bindet dienst-intern, wie `ldap`
|
||
und `groups` es vormachen.
|
||
- **Befund G — ein Administrator eines beliebigen Mandanten kann eine
|
||
plattformweite RSS-Quelle entfernen.** `TenderRssFeedSourceService.remove`
|
||
ist bereits heute ein einziger bedingter `deleteMany` mit der
|
||
Besitzbedingung in der Datenbank — das ist das gewünschte Muster, keine
|
||
Wiederholung des `ldap`-Fundes (Auflösung über die Kennung allein). Die
|
||
eine Beobachtung, die trotzdem festgehalten gehört: ein Administrator
|
||
EINES beliebigen Mandanten kann über diesen Pfad eine plattformweite
|
||
Quelle entfernen, die ALLE Mandanten speist — eine Produkt-/
|
||
Zuständigkeitsfrage im Umfeld von WINDOWS #19, kein Auftrag dieser
|
||
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
|
||
|
||
Die zwölf Paare des plattformweiten Ausschreibungskatalogs (D-03,
|
||
`tender-dedup.service.ts`, `tender-fingerprint-backfill.service.ts`,
|
||
`tender-ingestion.service.ts`, `tender-matching.service.ts`/`tender`,
|
||
`tender-scheduler.service.ts`, `tenders.controller.ts`, `tenders.module.ts`)
|
||
und der beiden bewussten Fan-out-Adapter
|
||
(`adapters/email-alert.adapter.ts`, `adapters/rss.adapter.ts`) sind
|
||
geprüft und deliberat ungebunden — nicht übersehen. Jede der zwölf trägt
|
||
ihre Begründung im eigenen Dateikopf bzw. in D-03.
|
||
|
||
Befund I gehört ausdrücklich hierher: `tender-ingestion.service.spec.ts`
|
||
enthält eine Schutzprüfung, die den QUELLTEXT von
|
||
`tender-ingestion.service.ts` liest und gegen ein Vorkommen des
|
||
Bezeichners `forTenant` prüft ("never calls forTenant()"). In dieser einen
|
||
Datei darf deshalb auch kein ERKLÄRENDER Kommentar diesen Bezeichner
|
||
nennen — die Datei selbst steht ohnehin auf der Nicht-Anfassen-Liste, hier
|
||
nur festgehalten, damit niemand sie beim Nachziehen der Begründungen
|
||
"freundlich kommentiert" und den Lauf rot macht.
|
||
|
||
## Bereich dkv
|
||
|
||
Dieser Abschnitt erweitert die Kritikschrift um den Bereich `dkv`
|
||
(Quick-Task 260909-mir) und beschreibt ihn zum Zeitpunkt seiner Umstellung.
|
||
Die Leitfrage aus Abschnitt (a) gilt unverändert weiter — dieser Abschnitt
|
||
beantwortet sie erneut, für einen Bereich mit einer dritten, eigenen
|
||
Fehlerform: nicht "eine Liste ist leer" (wie bei `ldap`/`groups`) und nicht
|
||
"ein Benachrichtigungsweg handelt gar nicht" (wie bei `tenders`), sondern
|
||
"ein einzelnes Objekt wird `null`, und `null` hat an dieser Stelle bereits
|
||
eine gültige, harmlose Bedeutung". Ein eingerichtetes Modul sieht danach
|
||
aus wie ein nie eingerichtetes.
|
||
|
||
### (d1) Die Messung
|
||
|
||
Aufgabe 1 hat `apps/api/scripts/rls-scratch-check.mjs` um einen sechsten
|
||
Abschnitt (`runDkvAreaChecks`) erweitert, mit den drei Policies für
|
||
`DkvInvoiceHistory`, `DkvModuleConfig` und `DkvVehicleMaster` (alle aus der
|
||
ausgelieferten Migration `20260909140000_rls_remaining_tenant_tables`)
|
||
WORTGLEICH extrahiert, nicht im Werkzeug nachgetippt. Tatsächlich
|
||
beobachtete Ausgabe dieses Laufs (2026-09-09, gegen `tessera-ctl-db-1`,
|
||
Adresse `172.19.0.2`):
|
||
|
||
```
|
||
dkvmoduleconfig-gebunden-nur-eigene-zeile: bestanden — forTenant(TENANT-A) liefert 1 Zeile(n): ["TENANT-A"]
|
||
dkvmoduleconfig-ungebunden-null-zeilen: bestanden — ungebundener SELECT auf "DkvModuleConfig" liefert 0 Zeile(n)
|
||
dkvmoduleconfig-ungebundene-einzelabfrage-liefert-keine-zeile: bestanden — ungebundenes SELECT ... LIMIT 1 ohne jede Bedingung liefert 0 Zeile(n), obwohl 2 existieren — die Form, die der Planer-Startpfad heute benutzt: aus einer beliebigen-aber-vorhandenen Zeile wird KEINE Zeile, und der aufrufende Code liest das als "dieses Modul ist nicht eingerichtet"
|
||
dkvinvoicehistory-gebunden-nur-eigener-mandant: bestanden — forTenant(TENANT-A) liefert 1 Zeile(n): ["TENANT-A"]
|
||
dkvvehiclemaster-gebunden-nur-eigener-mandant: bestanden — forTenant(TENANT-A) liefert 2 Zeile(n): ["TENANT-A","TENANT-A"]
|
||
dkvvehiclemaster-gebundenes-einfuegen-fremder-mandant-abgelehnt: bestanden — gebundenes INSERT unter TENANT-A mit tenantId=TENANT-B abgewiesen: ERROR: new row violates row-level security policy for table "DkvVehicleMaster"
|
||
dkvvehiclemaster-gebundenes-update-ueber-kennung-allein-trifft-null-zeilen: bestanden — gebundenes UPDATE unter TENANT-A ueber die Kennung 'veh-b1' (gehoert TENANT-B) allein betrifft 0 Zeile(n) — Folge fuer Aufgabe 3 (Befund G): die vorgeschaltete Besitzpruefung bleibt deshalb erhalten und wird nicht durch die Datenbank ersetzt
|
||
dkvvehiclemaster-schluessel-traegt-mandant-keine-fremdkollision: bestanden — gebundenes INSERT unter TENANT-A auf das bereits unter TENANT-B vorhandene Kennzeichen 'B-ONLY-1' gelingt — der Mandant ist Teil des zusammengesetzten Schluessels, keine Kollision auf einer unsichtbaren fremden Zeile, keine P2002-Uebersetzung noetig
|
||
dkv-zwei-parallele-gebundene-einzelabfragen-je-eigener-kontext: bestanden — TENANT-A: pid=286680, t="TENANT-A", rows=1; TENANT-B: pid=286679, t="TENANT-B", rows=1 — Nebenlaeufigkeitsform von getHistory(): zwei ueber Promise.all gleichzeitig gestartete gebundene Einzelabfragen ueber denselben Klienten, jede unter ihrem eigenen Kontext
|
||
Alle 41 Pruefungen bestanden.
|
||
```
|
||
|
||
Die Belegzeile, die diesen Abschnitt der Kritikschrift trägt, ist
|
||
`dkvmoduleconfig-ungebunden-null-zeilen`: der IDENTISCHE `SELECT "tenantId"
|
||
FROM "DkvModuleConfig"` ohne vorheriges `set_config` liefert **0 Zeilen**,
|
||
nicht etwa die 2 tatsächlich vorhandenen — an der echten, ausgelieferten
|
||
Policy gemessen, nicht an einer im Werkzeug nachgebauten Hilfstabelle.
|
||
|
||
Daneben trägt `dkvmoduleconfig-ungebundene-einzelabfrage-liefert-keine-zeile`
|
||
diesen Abschnitt zusätzlich, weil dieser Bereich an der entscheidenden
|
||
Stelle kein Mengenergebnis liest, sondern ein Einzelobjekt: dieselbe
|
||
Tabelle, dasselbe fehlende `set_config`, aber diesmal ein `SELECT ...
|
||
LIMIT 1` ohne jede Bedingung — exakt die Form, die `loadConfig()` ohne
|
||
Mandant (der Planer-Startpfad) heute über `findFirst()` benutzt. Auch
|
||
diese Abfrage liefert **0 Zeilen**, obwohl 2 existieren. Der Unterschied
|
||
zur ersten Belegzeile ist nicht die Zahl (beide sind 0), sondern die
|
||
Lesart: ein leeres `findMany`-Ergebnis ist im aufrufenden Code sichtbar
|
||
leer, ein leeres `findFirst`-Ergebnis wird zu `null`, und `null` hat in
|
||
`loadConfig()`/`onModuleInit()` bereits eine gültige, harmlose Bedeutung
|
||
("kein aktives Modul konfiguriert") — siehe (d3).
|
||
|
||
TEIL 2 hat zusätzlich die Nebenläufigkeitsform gemessen, auf die sich
|
||
`getHistory()` stützt (Befund C): zwei über `Promise.all` gleichzeitig
|
||
gestartete gebundene Einzelabfragen über DENSELBEN Klienten, hier für zwei
|
||
verschiedene Mandanten nachgebaut
|
||
(`dkv-zwei-parallele-gebundene-einzelabfragen-je-eigener-kontext`). Jede
|
||
Abfrage sah den Kontext, unter dem sie gestartet wurde (`TENANT-A`/
|
||
`TENANT-B`), und jede lieferte die richtige Zeilenzahl (je 1) — keine
|
||
Verletzung, kein Abbruch.
|
||
|
||
TEIL 3 hat nachgemessen, dass dieser Bereich keine mandantengebundene
|
||
Transaktion enthält (Befund C):
|
||
`grep -rn '\$transaction(' apps/api/src/dkv --include=*.ts | grep -v spec`
|
||
liefert **null Treffer** (Rückgabewert 1, keine Ausgabe). Der im Kopf von
|
||
`prisma-tenant.extension.ts` verlangte erneute Test vor jedem neuen
|
||
`forTenant()`-Fall mit eigener Transaktion ist damit für diesen Bereich
|
||
beantwortet: es fällt kein neuer Fall an, `withTenantTransaction()` wird
|
||
hier nicht gebraucht und in Aufgabe 2/3 nicht eingeführt. Die beiden
|
||
mehrschrittigen Stellen des Bereichs — der Ersetzen-Modus des
|
||
Fahrzeug-Imports (`deleteMany` gefolgt von `createMany`) und die
|
||
Zugangsdaten-Erhaltung in `saveConfig` (lesen, entschlüsseln, neu
|
||
verschlüsseln, schreiben) — bleiben deshalb so unatomar wie heute; sie in
|
||
eine Transaktion zu heben wäre eine Verhaltensänderung jenseits dieses
|
||
Auftrags, siehe (d5).
|
||
|
||
Zwei Messungen dieses Laufs tragen eine Entscheidung, die kein bisheriger
|
||
Bereich in dieser Form brauchte:
|
||
|
||
- `dkvvehiclemaster-gebundenes-einfuegen-fremder-mandant-abgelehnt`
|
||
(Befund I) — dieselbe Frage wie bei `TenderRssFeedSource` in `tenders`,
|
||
hier mit demselben Ergebnis: die ausgelieferten Policies dieses Bereichs
|
||
tragen keine eigene `WITH CHECK`-Klausel, also verwendet PostgreSQL
|
||
denselben `USING`-Ausdruck auch für neu geschriebene Zeilen — ein
|
||
gebundenes `INSERT` unter TENANT-A mit `tenantId = TENANT-B` wird
|
||
abgewiesen. Was PostgreSQL daraus für ein `INSERT` ableitet, ist eine
|
||
Eigenschaft der Datenbank, keine des Policy-Textes, deshalb gemessen und
|
||
nicht aus dem Text geschlossen.
|
||
- `dkvvehiclemaster-schluessel-traegt-mandant-keine-fremdkollision`
|
||
(Befund H) — der Gegenbefund zu `tenders`-Befund F: weil
|
||
`DkvVehicleMaster` die zusammengesetzte Eindeutigkeit `@@unique([tenantId,
|
||
kennzeichen])` trägt, gibt es hier KEINE Kollision auf einer unsichtbaren
|
||
fremden Zeile. Ein gebundenes `INSERT` unter TENANT-A auf ein
|
||
Kennzeichen, das unter TENANT-B bereits existiert, GELINGT — das
|
||
bestandene Ergebnis ist das Gelingen, nicht die Abweisung. Aufgabe 2/3
|
||
bauen deshalb keine P2002-Übersetzung für diesen Bereich; anders als bei
|
||
`TenderTriage` in `tenders` ist hier keine gebaut, weil keine gebraucht
|
||
wird — nachgemessen statt unterstellt.
|
||
|
||
### (d2) Signaltabelle je umgestelltem Pfad
|
||
|
||
| Pfad | Verhalten bei zu wenig Ergebnis | Konkretes Signal |
|
||
|---|---|---|
|
||
| `DkvService.getConfigForApi` | Der gebundene erste Lesezugriff (`loadConfig(tenantId)`) liefert `null` statt der eigenen Konfiguration | `GET /dkv/config` liefert `404 DKV module not yet configured`; die Oberfläche zeigt das Einrichtungsformular für ein Modul, das tatsächlich eingerichtet ist |
|
||
| `DkvService.getConfigForApi`, der zweite (rohe) Lesezugriff auf die Zugangsdaten | Läuft dieser gebundene `findUnique` leer (während der erste — sicher ausgewählte — noch träfe), bleibt `raw` `null`, der `try`-Block liefert `hasPassword=false` | Die Oberfläche meldet "kein Passwort hinterlegt" für ein Modul mit tatsächlich hinterlegtem Passwort — ohne Fehlermeldung, siehe (d3) Stelle 4 |
|
||
| `DkvService.saveConfig`, die Zugangsdaten-Erhaltung | Der gebundene erhaltende Lesezugriff liefert `null` statt der bestehenden Zeile, der `try/catch` schluckt das | Ein gespeichertes Passwort wird mit dem LEEREN Wert neu verschlüsselt — die zerstoerende Stelle, siehe (d3) Stelle 5 und T-MIR-07 |
|
||
| `DkvService.testConnection`, der Rückgriff auf gespeicherte Zugangsdaten | Der gebundene Lesezugriff liefert `null` statt der bestehenden Zeile | Der Verbindungstest schlägt mit einem Anmeldefehler des Postfachs fehl — die Meldung zeigt auf das Postfach, nicht auf die Datenbank |
|
||
| `DkvService._runPipeline`, der Konfigurations-Lesezugriff | Der gebundene `findUnique` liefert `null` statt der bestehenden Konfiguration | `_runPipeline` protokolliert `no config for tenant ...` als Warnung und `return`et — die Rechnungsverarbeitung stellt für diesen Mandanten die Arbeit ein, ohne Fehlermeldung |
|
||
| `DkvService.listVehicles`/`createVehicle` | Der gebundene Zugriff liefert 0 Zeilen statt der tatsächlich vorhandenen bzw. schreibt nicht | Die Fahrzeugliste ist leer für einen Mandanten mit tatsächlich vorhandenen Fahrzeugen |
|
||
| `DkvService.updateVehicle`/`deleteVehicle`, die Besitzprüfung | Der gebundene `findFirst` liefert `null` statt der eigenen Zeile | `PUT`/`DELETE /dkv/vehicles/:id` scheitert mit der vorhandenen `NotFoundException`, obwohl das Fahrzeug existiert — siehe (d2)-Zeile zum neuen Riegel unten für die spiegelbildliche Fehlerform |
|
||
| `DkvService.importVehiclesCsv` (Ersetzen-Modus) | Der gebundene `deleteMany` löscht 0 Zeilen statt der tatsächlich vorhandenen (harmlos: dann bleiben Alt-Fahrzeuge stehen, `createMany` legt zusätzlich an) | Nach einem Ersetzen-Import bestehen alte UND neue Fahrzeugzeilen nebeneinander — kein Datenverlust, aber ein Zustand, der als "Ersetzen" nicht mehr stimmt |
|
||
| `DkvService.getHistory` | Beide gebundenen Parallelabfragen liefern 0 Zeilen bzw. Zählung 0 statt der tatsächlich vorhandenen | Die Rechnungshistorie-Tabelle ist leer für einen Mandanten mit tatsächlich vorhandener Historie |
|
||
| `DkvService._buildExportRows`, der gebündelte Lesezugriff auf die Fahrzeugstammdaten | Der gebundene `findMany` liefert 0 Zeilen statt der tatsächlich vorhandenen | Eine vollständige Ausfuhrdatei OHNE einen einzigen Fahrer entsteht — kein Fehler, keine Warnung, eine Datei, die plausibel aussieht und falsch ist, siehe (d3) Stelle 7 |
|
||
| `DkvService.getExportFile`, neuer Riegel (Aufgabe 3, Befund E) | Der gebundene Lesezugriff auf `DkvInvoiceHistory.exportFilename` liefert keinen Treffer, obwohl die Datei existiert und das Namensmuster besteht | `GET /dkv/exports/:filename` liefert `404`, obwohl die Datei auf der Platte liegt — die Absicht der Umstellung: Fehlen und Fremdbesitz kollabieren bewusst zur selben Antwort |
|
||
| `loadConfig(tenantId)` ohne Mandant (bewusst ungebunden, Planer-Startpfad) | Betrifft nicht die Bindung selbst — die Methode bindet niemals. Nach dem Scharfschalten liefert dieselbe Abfrage `null` statt einer beliebigen Zeile | `onModuleInit()` protokolliert `DKV scheduler: no active config found — cron job not registered` und richtet für JEDEN Mandanten nichts ein — siehe (d4) |
|
||
|
||
### (d3) Welcher Code Leere als Abwesenheit deutet
|
||
|
||
Die Form, die diesen Bereich von `ldap`, `groups` und `tenders`
|
||
unterscheidet: nicht "eine Liste ist leer" und nicht "ein
|
||
Benachrichtigungsweg handelt gar nicht", sondern "ein einzelnes Objekt
|
||
wird `null`, und `null` hat an dieser Stelle bereits eine gültige,
|
||
harmlose Bedeutung". Ein eingerichtetes Modul sieht danach aus wie ein nie
|
||
eingerichtetes: ein leeres Formular, eine unauffällige Protokollzeile,
|
||
kein Alarm.
|
||
|
||
**Zerstörend (eine Stelle, der gefährlichste Punkt des Bereichs):**
|
||
|
||
5. `DkvService.saveConfig`, die Erhaltung der nicht ausgefüllten
|
||
Zugangsdaten — liest die bestehende Zeile, um Benutzername oder
|
||
Passwort zu übernehmen, wenn das Formularfeld leer gelassen wurde.
|
||
Läuft dieser Lesezugriff nach dem Scharfschalten leer (weil ungebunden
|
||
oder unter falschem Kontext gebunden), wird das Feld mit dem LEEREN
|
||
Wert neu verschlüsselt: aus einem gespeicherten Passwort wird ein
|
||
leeres. Die Stelle liegt hinter einem `try/catch`, das ausdrücklich
|
||
sagt, dass es Fehler ignoriert und mit dem Übergebenen überschreibt —
|
||
T-MIR-07 im Bedrohungsregister dieses Plans. Aufgabe 2 bindet Lese- UND
|
||
Schreibzugriff dieser Methode gemeinsam an denselben Mandanten, sodass
|
||
ein leerer Lesezugriff nicht mit einem erfolgreichen Schreibzugriff
|
||
unter einem anderen Kontext kombiniert werden kann.
|
||
|
||
**Lautlos (drei Stellen, die Rechnungsverarbeitung bekommt eigenen
|
||
Raum):**
|
||
|
||
1. `DkvSchedulerService.onModuleInit()`, gefolgt von
|
||
`config?.isActive && config.tenantId` — `null` heißt "kein aktives
|
||
Modul konfiguriert", der Planer richtet nichts ein und protokolliert
|
||
das als Normalfall (`DKV scheduler: no active config found — cron job
|
||
not registered`). Kein Fehler, keine Warnung, keine sichtbare
|
||
Änderung — siehe (d4) für die volle Begründung, warum dieser Pfad
|
||
bewusst ungebunden bleibt.
|
||
2. `DkvService._runPipeline`, `if (!config) { warn; return; }` — `null`
|
||
heißt "dieser Mandant hat DKV nicht eingerichtet". Die
|
||
Rechnungsverarbeitung stellt die Arbeit ein: Rechnungen laufen im
|
||
Postfach weiter auf, es entsteht keine Historienzeile, keine
|
||
Ausfuhrdatei, kein Versand — und keine Fehlermeldung. Anders als beim
|
||
Planer-Startpfad ist dieser Lesezugriff in Aufgabe 2 vollständig
|
||
gebunden; die Stelle bleibt hier festgehalten, weil sie die Folge einer
|
||
still verschwundenen Konfiguration ist, nicht weil sie ungebunden
|
||
bliebe.
|
||
7. `DkvService._buildExportRows`, der gebündelte Lesezugriff auf die
|
||
Fahrzeugstammdaten — ein fehlender Treffer je Kennzeichen ist nach D-13
|
||
bereits ein GÜLTIGER Zustand (unbekanntes Kennzeichen, leeres
|
||
Fahrerfeld). Läuft der Lesezugriff selbst ganz leer (0 Fahrzeuge statt
|
||
der tatsächlich vorhandenen), entsteht eine vollständige Ausfuhrdatei
|
||
OHNE einen einzigen Fahrer — kein Fehler, keine Warnung, eine Datei,
|
||
die plausibel aussieht und falsch ist. Diese Stelle ist der Grund,
|
||
warum es nicht genügt, nur die Anzeigepfade zu binden.
|
||
|
||
**Irreführend (drei Stellen, die auf die falsche Ursache zeigen oder eine
|
||
falsche Vergangenheit nahelegen):**
|
||
|
||
3. `DkvService.getConfigForApi`, `if (!safe) return null` — die
|
||
Oberfläche zeigt daraufhin ein leeres Einrichtungsformular. Ein
|
||
Administrator sieht "noch nicht eingerichtet" für ein Modul, das
|
||
eingerichtet IST — und würde beim Neu-Ausfüllen die vorhandenen
|
||
Zugangsdaten überschreiben. Diese Stelle bleibt bewusst unter
|
||
"irreführend" und nicht unter "zerstörend": die Zerstörung selbst
|
||
passiert erst in `saveConfig` (Stelle 5), falls der Administrator
|
||
tatsächlich neu ausfüllt und speichert — hier liegt nur die
|
||
irreführende Voraussetzung dafür.
|
||
4. `DkvService.getConfigForApi`, der `try/catch` um die Entschlüsselung —
|
||
fängt heute Entschlüsselungsfehler ab und liefert einen leeren
|
||
Benutzernamen. Nach dem Scharfschalten fällt der Lesezugriff selbst
|
||
leer aus, `raw` ist `null`, und der Zweig läuft ohne Fehler durch:
|
||
`hasPassword` bleibt `false`. Die Oberfläche meldet "kein Passwort
|
||
hinterlegt" für ein hinterlegtes Passwort.
|
||
6. `DkvService.testConnection`, der Rückgriff auf das gespeicherte
|
||
Passwort — läuft leer, der Test schlägt mit einem Anmeldefehler des
|
||
Postfachs fehl. Harmlos in der Richtung, aber irreführend: die Meldung
|
||
zeigt auf das Postfach, nicht auf die Datenbank.
|
||
|
||
**Gegenrichtung, ebenfalls nachgesehen statt geschlossen behauptet:** die
|
||
laut werfenden Stellen sind `updateVehicle`/`deleteVehicle`
|
||
(`NotFoundException` bei Leere), `importVehiclesCsv` (wirft bei leerem CSV)
|
||
und `getExportFile` (wirft bei fehlender Datei, jetzt zusätzlich bei
|
||
fehlendem gebundenen Historientreffer, Aufgabe 3). Diese Stellen sind
|
||
harmlos, weil ein zu kleines Ergebnis dort bereits heute einen Fehler
|
||
auslöst, der nicht mit dem Scharfschalten neu entsteht.
|
||
|
||
### (d4) Was dieser Durchlauf bewusst nicht löst
|
||
|
||
**Der Planer-Startpfad — die eigentliche Aufgabe dieses Plans, ausgeschrieben statt still getroffen.**
|
||
`DkvSchedulerService.onModuleInit()` ruft `DkvService.loadConfig()` ohne
|
||
Mandant auf und übernimmt `config.tenantId` als den einen Mandanten, den
|
||
der eine benannte Cron-Auftrag `dkv-inbox-poll` fortan bedient
|
||
(`this.dkvService.loadConfig()` → `findFirst()` ganz ohne Bedingung). Zwei
|
||
Zustände, beide gehören benannt, sonst liest sich die Markierung wie eine
|
||
Entwarnung:
|
||
|
||
- **Heute** ist die Abfrage bereits FALSCH, nicht nur ungenau: bei mehreren
|
||
Mandanten bedient sie einen BELIEBIGEN und die übrigen NIE. Die Prüfung
|
||
`config?.isActive && config.tenantId` verschärft das — ist ausgerechnet
|
||
die gezogene beliebige Zeile inaktiv, registriert der Planer gar nichts,
|
||
obwohl ein zweiter Mandant aktiv wäre.
|
||
- **Nach dem Scharfschalten** verstummt sie zusätzlich: dieselbe Abfrage
|
||
liefert `null`, der Planer protokolliert `DKV scheduler: no active
|
||
config found — cron job not registered` und richtet für JEDEN Mandanten
|
||
nichts ein — eine Zeile, die auf einer frischen Installation der
|
||
Normalfall ist und deshalb niemanden alarmiert.
|
||
|
||
Von den drei im Auftrag genannten Formen wurde geprüft:
|
||
|
||
- **(a) An einen konkret aufgelösten Mandanten binden** — nicht möglich.
|
||
`onModuleInit()` hat keine Anfrage, keinen Sitzungsnachweis und keinen
|
||
Konfigurationswert, aus dem ein Mandant käme. Einen einzuführen wäre eine
|
||
neue Einstellung, also eine Funktionsänderung.
|
||
- **(b) Umbau auf einmal-abfragen-viele-bedienen** — abgelehnt, mit
|
||
Begründung. Das ist genau die Mehrmandanten-Planung, die 07-04
|
||
zurückgestellt hat: alle aktiven Konfigurationen lesen, je Mandant einen
|
||
Auftrag führen, deren Lebenszyklus bei jeder Konfigurationsänderung
|
||
nachziehen (heute verwaltet `setInterval` GENAU EINEN Auftrag unter
|
||
einem festen Namen), und entscheiden, was bei unterschiedlichen
|
||
Intervallen je Mandant gilt. Das ist eine Funktion, kein Bindungsumbau.
|
||
- **(c) Als benannte Altlast weiterführen, mit Markierung** — GEWÄHLT. Der
|
||
unmittelbare Präzedenzfall ist `getAllActiveConfigs` im Bereich `ldap`
|
||
(260909-ipc, Befund B): ein bewusst übergreifender Planer-Lesezugriff,
|
||
der ungebunden bleibt, einen eigenen Kopfkommentar trägt, und dessen
|
||
Verstummen nach dem Scharfschalten an die Vorabprüfung von Etappe 4
|
||
übergeben wird.
|
||
|
||
**Die Unsymmetrie, die dieser Präzedenzfall NICHT deckt:**
|
||
`getAllActiveConfigs` ist HEUTE korrekt und verstummt erst später. Der
|
||
DKV-Planer ist HEUTE bereits falsch — er bedient bei mehreren Mandanten
|
||
einen beliebigen und die übrigen nie — und verstummt zusätzlich später.
|
||
Die Markierung in Aufgabe 2 sagt beides, sonst läse sie sich wie eine
|
||
Entwarnung. Die gewählte Form hat drei Teile, alle umgesetzt: die
|
||
übergreifende Abfrage ist eine EIGENE, benannte Methode (kein Zweig hinter
|
||
einem optionalen Parameter), sie und der Planer tragen einen Kopfkommentar,
|
||
der beide Zustände benennt, und die Altlast steht als offener Eintrag im
|
||
Broken-Windows-Register (siehe SUMMARY dieses Plans für die genaue
|
||
Eintragskennung). Das Signal für das Verstummen gehört in die
|
||
Vorabprüfung von Etappe 4 (`apps/api/scripts/rls-preflight.mjs`), NICHT in
|
||
diesen Durchlauf.
|
||
|
||
**Die gemeinsame Ablage der Ausfuhrdateien samt Verdrängung über
|
||
Mandantengrenzen (Befund F).** `DkvExportService.writeAndPrune` behält die
|
||
letzten zehn Dateien des GEMEINSAMEN Verzeichnisses `user-files/`.
|
||
Verarbeitet ein Mandant zehn Rechnungen, verdrängt er damit die Dateien
|
||
aller anderen; deren Historienzeilen nennen dann einen Dateinamen, der
|
||
nicht mehr existiert. Das ist keine Bindungsfrage — es ist die
|
||
Ablagestruktur, und sie zu ändern (Unterverzeichnisse je Mandant, Umzug der
|
||
Bestandsdateien) ist ein eigener Auftrag. Siehe auch (d5) und T-MIR-08.
|
||
|
||
**Die Übergaben in die noch nicht umgestellten Bereiche.**
|
||
`dkv-mail.service.ts` hängt an `SettingsService.getDecryptedSmtpConfig`
|
||
(`settings.service.ts` ist noch vollständig unbound, siehe
|
||
`docs/mandantentrennung-zugriffsklassifikation.md`) — dieselbe
|
||
Reihenfolgebedingung, die der `tenders`-Durchlauf für dieselbe Abhängigkeit
|
||
als Befund K festhielt. `dkv.seed.ts` hängt an `module-registry`, ebenfalls
|
||
noch unbound. Nach dem Scharfschalten fände die ungebundene SMTP-Abfrage
|
||
keine Zeile mehr — Ergebnis: kein Versand für niemanden, mit Wiederholung
|
||
bei jedem Lauf (die Datei bleibt lokal verfügbar, D-16). Reihenfolgebedingung
|
||
für Etappe 4, hier festgehalten, nicht gelöst.
|
||
|
||
**Nachtrag (260911-gwh):** der `settings`-Teil dieser Übergabe ist erfüllt —
|
||
`getDecryptedSmtpConfig(tenantId)` läuft seit Aufgabe 2 dieses Laufs über
|
||
`forTenant()`, siehe die Bestandsaufnahme-Zeile
|
||
`settings.service.ts`/`smtpConfig` in
|
||
`docs/mandantentrennung-zugriffsklassifikation.md` und (s4)(b). Erneut
|
||
gemessen: `dkv.seed.ts` ruft weiterhin
|
||
`ModuleRegistryService.seedModule()` (`module-registry.service.ts:206`,
|
||
`this.prisma.module.upsert`) — UNGEBUNDEN, aber bewusst und unverändert seit
|
||
260910-exd, weil `Module` der plattformweite Modulkatalog ohne `tenantId`-
|
||
Spalte ist (Befund E, `keine-mandantengebundene-tabelle`); "gebunden seit
|
||
260910-exd" trifft auf diesen Zugriff NICHT zu, gemessen statt aus dem Plan
|
||
abgeschrieben.
|
||
|
||
**Die offene Architekturfrage `req.tenantPrisma`.** Auch der Bereich `dkv`
|
||
entscheidet sie nicht — er bindet dienst-intern, wie `ldap`, `groups` und
|
||
`tenders` es vormachen.
|
||
|
||
### (d5) Was dieser Durchlauf bewusst nicht anfasst
|
||
|
||
- **Die beiden mehrschrittigen Stellen bleiben unatomar (Befund C, TEIL
|
||
3).** Der Ersetzen-Modus des Fahrzeug-Imports (`deleteMany` gefolgt von
|
||
`createMany`) und die Zugangsdaten-Erhaltung in `saveConfig` (lesen,
|
||
entschlüsseln, neu verschlüsseln, schreiben) werden NICHT in eine
|
||
Transaktion gehoben — geprüft und bewusst gelassen, nicht übersehen. Der
|
||
im Kopf von `prisma-tenant.extension.ts` verlangte erneute Test ist mit
|
||
TEIL 3 dieser Aufgabe beantwortet: kein neuer Transaktionsfall,
|
||
`withTenantTransaction()` bleibt für diesen Bereich ungenutzt.
|
||
- **Die Ablagestruktur der Ausfuhrdateien bleibt unverändert (Befund F).**
|
||
Geprüft und bewusst gelassen — eine Lösung (Unterverzeichnisse je
|
||
Mandant, Umzug der Bestandsdateien) ist ein eigener Auftrag, siehe (d4).
|
||
|
||
## Bereich user
|
||
|
||
Dieser Abschnitt erweitert die Kritikschrift um den Bereich `user`
|
||
(Quick-Task 260910-das) und beschreibt ihn zum Zeitpunkt seiner Umstellung.
|
||
Die Leitfrage aus Abschnitt (a) gilt unverändert weiter — dieser Abschnitt
|
||
beantwortet sie erneut, für den Bereich, in dem das plattformweite
|
||
Eindeutigkeitsproblem tatsächlich wohnt: `username` und `email` sind im
|
||
Schema plattformweit eindeutig, nicht je Mandant, und dieser Bereich enthält
|
||
als einziger BEIDE Formen gleichzeitig — Wege, die binden MÜSSEN
|
||
(Benutzerverwaltung je Mandant), und einen Weg, der binden NICHT DARF
|
||
(Nachschlagen auf dem plattformweit eindeutigen Schlüssel `username`). Ein
|
||
Quer-Schreiben ist hier keine Offenlegung, sondern eine Rechteausweitung
|
||
über die Mandantengrenze hinweg — die schwerste Klasse dieses ganzen
|
||
Vorhabens.
|
||
|
||
### (u1) Die Messung
|
||
|
||
Aufgabe 1 hat `apps/api/scripts/rls-scratch-check.mjs` um einen siebten
|
||
Abschnitt (`runUserAreaChecks`) erweitert. Die Tabelle `"User"` wird dabei
|
||
NICHT neu angelegt — sie existiert bereits, vom Abschnitt des Anmeldewegs,
|
||
samt eingeschaltetem und erzwungenem Zeilenschutz, beiden
|
||
Eindeutigkeitsbedingungen (`username`, `email`) und zwei Testzeilen in zwei
|
||
Mandanten (Befund O). Dieser Abschnitt baut darauf auf: er hält die im
|
||
Anmeldeweg-Abschnitt von Hand getippte Policy GEGEN die aus der
|
||
ausgelieferten Migration `20260618112133_rls_policies` geschnittene Fassung
|
||
(beide sind nach Normalisierung von Leerraum und abschließendem Semikolon
|
||
wortgleich — keine Ersetzung nötig), legt eine Tabelle `"Tenant"`
|
||
ausdrücklich OHNE Zeilenschutz an (die zu messende Eigenschaft selbst), und
|
||
ergänzt je eine weitere Benutzerzeile pro Mandant. Tatsächlich beobachtete
|
||
Ausgabe dieses Laufs (2026-09-10, gegen `tessera-ctl-db-1`, Adresse
|
||
`172.19.0.2`):
|
||
|
||
```
|
||
user-policy-aus-migration-wortgleich: bestanden — die im Anmeldeweg-Abschnitt (runAuthLookupChecks) von Hand getippte Policy auf "User" ist nach Normalisierung von Leerraum und abschliessendem Semikolon wortgleich mit der aus 20260618112133_rls_policies geschnittenen
|
||
user-gebunden-nur-eigener-mandant: bestanden — forTenant(TENANT-A) liefert 2 Zeile(n): ["TENANT-A","TENANT-A"]
|
||
user-ungebunden-null-zeilen: bestanden — ungebundener SELECT auf "User" liefert 0 Zeile(n)
|
||
user-ungebundene-suche-nach-benutzername-liefert-keine-zeile: bestanden — ungebundenes SELECT ... WHERE username = 'bob' liefert 0 Zeile(n), obwohl der Benutzer existiert — der aufrufende Code liest daraus "diesen Benutzer gibt es nicht" und legt an
|
||
user-gebundene-suche-nach-fremdem-benutzernamen-liefert-keine-zeile: bestanden — forTenant(TENANT-A) liefert fuer WHERE username = 'bob' (gehoert TENANT-B) 0 Zeile(n) — die Kollisionspruefung meldet faelschlich "frei"
|
||
user-eindeutigkeit-greift-trotz-unsichtbarkeit: bestanden — gebundenes INSERT unter TENANT-A mit dem angeblich freien Benutzernamen "bob" wird abgewiesen mit SQLSTATE 23505 (Raw query failed. Code: `23505`. Message: `Unique constraint failed: `) — eine Eindeutigkeitsverletzung (23505), NICHT eine Zeilenschutz-Ablehnung: genau die im Auftrag beschriebene Kette
|
||
user-gebundenes-einfuegen-fremder-mandant-abgelehnt: bestanden — gebundenes INSERT unter TENANT-A mit tenantId=TENANT-B abgewiesen: Raw query failed. Code: `42501`. Message: `ERROR: new row violates row-level security policy for table "User"`
|
||
user-ungebundenes-einfuegen-abgelehnt: bestanden — ungebundenes INSERT mit gueltiger Mandantenkennung abgewiesen: Raw query failed. Code: `42501`. Message: `ERROR: new row violates row-level security policy for table "User"`
|
||
user-gebundenes-update-ueber-kennung-allein-trifft-null-zeilen: bestanden — gebundenes UPDATE unter TENANT-A ueber die Kennung 'user-b' (gehoert TENANT-B) allein betrifft 0 Zeile(n) — die vorgeschalteten Besitz- und Rollenpruefungen bleiben deshalb erhalten und werden in Aufgabe 2/3 nicht durch die Datenbank ersetzt
|
||
user-gebundenes-loeschen-ueber-kennung-allein-trifft-null-zeilen: bestanden — gebundenes DELETE unter TENANT-A ueber die Kennung 'user-b2' (gehoert TENANT-B) allein betrifft 0 Zeile(n)
|
||
tenant-tabelle-ohne-zeilenschutz-bleibt-lesbar: bestanden — ungebundenes SELECT auf "Tenant" liefert 2 Zeile(n): ["TENANT-A","TENANT-B"]; pg_class.relrowsecurity fuer "Tenant" = false
|
||
user-fan-out-je-mandant-gebunden-liefert-alle-zeilen: bestanden — Vereinigung der je-Mandant gebundenen SELECTs liefert 4 Benutzernamen: ["alice","bob","carol","dave"]; Gesamtmenge (ueber die Wartungsrolle mit BYPASSRLS gemessen) sind 4: ["alice","bob","carol","dave"]
|
||
Alle 53 Pruefungen bestanden.
|
||
```
|
||
|
||
Drei Zeilen tragen diesen Abschnitt und werden hier ausdrücklich benannt und
|
||
auseinandergehalten, weil sie zusammen die im Auftrag beschriebene Kette
|
||
sind:
|
||
|
||
- **Die Belegzeile zur Unsichtbarkeit**, `user-ungebunden-null-zeilen`: der
|
||
IDENTISCHE `SELECT "tenantId" FROM "User"` ohne vorheriges `set_config`
|
||
liefert **0 Zeilen**, nicht etwa die 4 tatsächlich vorhandenen — an der
|
||
echten, ausgelieferten Policy gemessen, nicht an einer im Werkzeug
|
||
nachgebauten Hilfstabelle. Genau dieselbe Unsichtbarkeit trifft die
|
||
ungebundene Suche nach einem GÜLTIGEN Benutzernamen
|
||
(`user-ungebundene-suche-nach-benutzername-liefert-keine-zeile`) — die
|
||
Form, die die Erstanlage-Prüfung beim Start heute benutzt.
|
||
- **Die Zeile zum falschen „frei"**,
|
||
`user-gebundene-suche-nach-fremdem-benutzernamen-liefert-keine-zeile`:
|
||
dieselbe Suche, gebunden an TENANT-A, nach dem Benutzernamen `bob`
|
||
(gehört TENANT-B), liefert ebenfalls 0 Zeilen — der Moment, in dem eine
|
||
Kollisionsprüfung fälschlich „frei" meldet, obwohl der Name vergeben ist.
|
||
- **Die Zeile zum harten Eindeutigkeitsfehler**,
|
||
`user-eindeutigkeit-greift-trotz-unsichtbarkeit`: unmittelbar danach ein
|
||
gebundenes `INSERT` unter TENANT-A mit genau diesem, angeblich freien
|
||
Benutzernamen `bob`. Die Ablehnung trägt SQLSTATE **23505**
|
||
(Eindeutigkeitsverletzung) — gelesen aus `err.meta.code`, nicht aus
|
||
`err.code` (das bei einem fehlgeschlagenen `$executeRaw` immer den
|
||
generischen Prisma-Code `P2010` trägt, empirisch gegen
|
||
`tessera-ctl-db-1` geprüft, siehe Kopfkommentar von `sqlStateOf()` im
|
||
Werkzeug) — und NICHT SQLSTATE 42501 (Zeilenschutz-Ablehnung), die dieser
|
||
Abschnitt in zwei anderen Zeilen ebenfalls misst
|
||
(`user-gebundenes-einfuegen-fremder-mandant-abgelehnt`,
|
||
`user-ungebundenes-einfuegen-abgelehnt`). Diese Unterscheidung ist der
|
||
Kern der Messung: nur die erste ist die im Auftrag beschriebene Kette,
|
||
die zweite wäre eine ganz andere Geschichte.
|
||
|
||
Zusätzlich gemessen, statt behauptet: `tenant-tabelle-ohne-zeilenschutz-bleibt-lesbar`
|
||
hält sowohl das ungebundene `SELECT` (2 von 2 Zeilen sichtbar) als auch den
|
||
Systemkatalog (`pg_class.relrowsecurity` für `"Tenant"` = `false`)
|
||
gegeneinander — die Aussage hängt damit nicht allein daran, dass dieser
|
||
Abschnitt selbst keinen Zeilenschutz eingeschaltet hat.
|
||
`user-fan-out-je-mandant-gebunden-liefert-alle-zeilen` bildet die in
|
||
Aufgabe 2/3 gewählte Form der Plattform-Administratorsicht nach (Mandanten
|
||
ungebunden lesen, je Mandant EIN gebundener `SELECT`, Ergebnisse
|
||
vereinigen) und hält das Ergebnis gegen eine über die Wartungsrolle (mit
|
||
`BYPASSRLS`) gemessene Gesamtmenge, nicht gegen eine angenommene Zahl — beide
|
||
Mengen sind identisch: `["alice","bob","carol","dave"]`.
|
||
|
||
**TEIL 2, Beleg statt Behauptung für Befund B** — keine Transaktion in
|
||
diesem Bereich:
|
||
|
||
```
|
||
$ grep -rn '\$transaction(' apps/api/src/user --include=*.ts | grep -v spec
|
||
$ echo $?
|
||
1
|
||
```
|
||
|
||
Null Treffer, Rückgabewert 1. Der im Kopf von `prisma-tenant.extension.ts`
|
||
verlangte erneute Test ist damit für diesen Bereich beantwortet: kein neuer
|
||
Transaktionsfall, `withTenantTransaction()` wird hier nicht gebraucht und in
|
||
Aufgabe 2/3 nicht eingeführt.
|
||
|
||
**TEIL 3, Beleg statt Behauptung für Befund D** — die Aufrufermessung für
|
||
`findByUsername`:
|
||
|
||
```
|
||
$ grep -rn "findByUsername" apps/api/src packages
|
||
apps/api/src/user/user.service.ts:21: async findByUsername(username: string) {
|
||
```
|
||
|
||
Genau EIN Treffer, die Definition selbst — kein Aufrufer. Etappe 1
|
||
(260909-eor) hat den Anmeldeweg auf die drei schmalen
|
||
SECURITY-DEFINER-Funktionen umgezogen; `auth.service.ts` sucht seither über
|
||
`auth_lookup_user_by_username` und nicht mehr über diese Methode. Die
|
||
Messung bestätigt Befund D unverändert: die Methode bleibt ungebunden
|
||
(Entscheidung (c) aus den Planungszeit-Befunden), ihr Kopfkommentar wird in
|
||
Aufgabe 2 richtiggestellt.
|
||
|
||
### (u2) Signaltabelle je umgestelltem Pfad
|
||
|
||
| Pfad | Verhalten bei zu wenig Ergebnis | Konkretes Signal, Ort |
|
||
|---|---|---|
|
||
| `UserService.findById` | Der gebundene `findUnique` liefert `null` statt des eigenen Benutzers | `GET /users/:id` liefert `404 User not found`, obwohl der Benutzer existiert |
|
||
| `UserService.create` | Betrifft nicht das Lesen — ein gebundenes `INSERT` mit fremder Mandantenkennung wird von der Policy abgewiesen (`user-gebundenes-einfuegen-fremder-mandant-abgelehnt`) | `POST /users` scheitert mit einer Datenbank-Ablehnung statt einer verständlichen Meldung, sollte die Mandantenkennung je falsch ankommen — nach heutigem Code (Befund G, Selbstbedienungswege ausgenommen) nicht erreichbar, weil `tenantId` aus dem Sitzungsnachweis bzw. der ausdrücklichen SUPER_ADMIN-Übersteuerung stammt |
|
||
| `UserService.update` | Der gebundene `update` über die Kennung allein trifft eine fremde Zeile still (0 betroffene Zeilen, `user-gebundenes-update-ueber-kennung-allein-trifft-null-zeilen`), nicht laut | `PATCH /users/:id` würfe ohne die vorgeschaltete Prüfung in `user.controller.ts` keinen Fehler, sondern liefe ins Leere — die vorgeschaltete Mandantenprüfung bleibt deshalb Pflicht |
|
||
| `UserService.deactivate`/`delete` | Dieselbe stille Form wie `update` | `DELETE /users/:id` bzw. das Deaktivieren würde ohne die vorgeschaltete Prüfung 0 Zeilen treffen, ohne Fehler |
|
||
| Neue Methode: Plattform-Administratorsicht, Liste | Der Schleifentreiber (`tenant.findMany`, bewusst ungebunden) liefert 0 Mandanten statt der tatsächlich vorhandenen | Die Benutzerliste des `SUPER_ADMIN` ist für die gesamte Plattform leer, obwohl Mandanten mit Benutzern existieren — `user-fan-out-je-mandant-gebunden-liefert-alle-zeilen` misst die Vereinigung, nicht den Treiber; ein leerer Treiber ist eine Eigenschaft der Mandantentabelle, nicht dieses Bereichs |
|
||
| Neue Methode: Plattform-Administratorsicht, Kennungs-Auflösung | Läuft der gebundene Lesezugriff für den Mandanten des Zielbenutzers leer, findet die Methode den Benutzer nicht | `GET /users/:id`, `PATCH /users/:id`, `DELETE /users/:id` liefern für den `SUPER_ADMIN` `404`, obwohl der Benutzer existiert |
|
||
| `user.controller.ts`, `findAll` (ADMIN-Zweig) | Der gebundene `findMany` liefert 0 Benutzer statt der tatsächlich vorhandenen | Die Benutzerliste ist für einen Mandanten-Administrator leer — eine leere Liste sieht auf einer frischen Installation wie der Normalzustand aus |
|
||
| Selbstbedienungswege (Bild hochladen/löschen, Akzentfarbe, Bild ausliefern) | Der gebundene Zugriff über die eigene Kennung aus dem Sitzungsnachweis liefert 0 Zeilen | `GET /users/me/avatar` liefert die vorhandene `404 No avatar set`, obwohl ein Bild hinterlegt ist — harmlos, siehe (u3) |
|
||
| `UserService.findByUsername` (bewusst UNGEBUNDEN) | Betrifft nicht diesen Pfad selbst — er bindet nicht und liefert deshalb weiterhin korrekt. Das Risiko läge in einer KÜNFTIGEN Bindung | Würde man ihn binden: eine gebundene Suche nach einem fremden Benutzernamen meldete „frei" — hier ausdrücklich als Grenze festgehalten, nicht als heute beobachtbares Verhalten (Befund D) |
|
||
| `AdminSeedService`, Erstanlage-Prüfung (bewusst UNGEBUNDEN) | Betrifft nicht diesen Pfad selbst — er bindet nicht, läuft aber NACH dem Scharfschalten für JEDEN Administrator ins Leere, weil ohne Mandantenkontext keine Zeile sichtbar ist | Siehe (u3) — die schwerste Ausprägung dieses gesamten Bereichs |
|
||
| `AdminSeedService`, beide Zugriffe auf `tenant` (bewusst UNGEBUNDEN) | Betrifft nicht diese Pfade selbst — `Tenant` trägt keinen Zeilenschutz, ein ungebundenes Lesen/Schreiben bleibt korrekt | `tenant-tabelle-ohne-zeilenschutz-bleibt-lesbar` misst die tragende Eigenschaft |
|
||
|
||
### (u3) Welcher Code Leere als Abwesenheit deutet
|
||
|
||
Dies ist der Kern dieses Abschnitts. Die sechs Stellen aus Befund L,
|
||
namentlich benannt und nach ihrer Wirkung sortiert:
|
||
|
||
**Startverhindernd (eine Stelle, die schwerste Ausprägung im ganzen
|
||
Vorhaben):**
|
||
|
||
1. **`AdminSeedService.seedAdmin()`, die Erstanlage-Prüfung** — die
|
||
vollständige, im Auftrag beschriebene Kette in Reinform: eine
|
||
unsichtbare Zeile wird als Abwesenheit gelesen
|
||
(`user.findUnique({ where: { username } })` liefert nach dem
|
||
Scharfschalten `null`, nicht weil der Administrator fehlt, sondern weil
|
||
ohne gesetzten Mandantenkontext keine Zeile der Benutzertabelle sichtbar
|
||
ist — `user-ungebundene-suche-nach-benutzername-liefert-keine-zeile`),
|
||
die natürliche Folgehandlung ist Anlegen (Schritt 4 läuft, weil Schritt
|
||
3 entfällt), und das Anlegen scheitert hart an der plattformweiten
|
||
Eindeutigkeit von `username`
|
||
(`user-eindeutigkeit-greift-trotz-unsichtbarkeit`, SQLSTATE 23505, KEINE
|
||
Zeilenschutz-Ablehnung). Weil `seedAdmin()` bewusst NICHT gekapselt ist
|
||
— der Dateikopf sagt ausdrücklich, ein Fehlschlag solle den Start
|
||
weiterhin laut scheitern lassen —, wird aus dieser einen unsichtbaren
|
||
Zeile eine **Startsperre**: die Anwendung startet nach dem
|
||
Scharfschalten nicht mehr, für jede bestehende Installation mit
|
||
gesetzten Administrator-Umgebungswerten. Eine Absicht (laut scheitern)
|
||
wird damit ungewollt zu einer Sperre für den Normalfall.
|
||
|
||
**Kollisionserzeugend (drei Stellen — dieselbe Kette, an anderen Stellen im
|
||
Bereich):**
|
||
|
||
2. **`user.controller.ts`, `findAll` im ADMIN-Zweig** — eine leere Liste
|
||
heißt „dieser Mandant hat keine Benutzer". Ein Administrator, der seine
|
||
Kollegen nicht mehr sieht, legt sie erneut an; jede dieser Anlagen
|
||
kollidiert auf `username` bzw. `email`. Die Oberfläche zeigt dabei
|
||
nichts Auffälliges: eine leere Benutzerliste ist auf einer frischen
|
||
Installation der Normalzustand.
|
||
3. **`user.controller.ts`, `findAll` im SUPER_ADMIN-Zweig** — dieselbe
|
||
Leere, eine Ebene höher: die Plattformverwaltung sieht eine
|
||
Installation ohne jeden Benutzer und würde, bliebe die neue
|
||
übergreifende Methode ungebunden statt als gebundene Schleife gebaut,
|
||
ebenfalls zum Neuanlegen verleiten.
|
||
4. **Eine gebundene Suche nach Benutzername oder Adresse** (Befund D,
|
||
`user-gebundene-suche-nach-fremdem-benutzernamen-liefert-keine-zeile`)
|
||
— meldet „frei" für einen Namen, den es gibt. Die Kollisionsprüfung wird
|
||
zur Kollisionserzeugung; genau deshalb bleibt `findByUsername`
|
||
ungebunden und übersetzt `UserService.create`/`update` die
|
||
Eindeutigkeitsverletzung stattdessen an ihrem eigenen Erzeugungspunkt in
|
||
eine verständliche Meldung (Aufgabe 2).
|
||
|
||
**Bereits gebunden, als Beleg benannt (eine Stelle, nicht Gegenstand
|
||
dieses Durchlaufs):**
|
||
|
||
5. **`ldap.service.ts`, `upsertMappedUser`** — bereits gebunden, seit
|
||
260909-ipc, in einem ANDEREN Bereich, hier nur zu benennen und nicht
|
||
anzufassen: die Identitätssuche entscheidet über Anlegen-oder-
|
||
Aktualisieren und läuft in dieselbe Kette. Sie ist der Beleg, dass die
|
||
Kette nicht erst nach dem Scharfschalten existiert — die
|
||
Suchbedingung trägt bereits heute den Mandanten, ein fremder Halter ist
|
||
also bereits heute unsichtbar. Die Übersetzung der
|
||
Eindeutigkeitsverletzung gehört deshalb an den gemeinsamen Anlegepunkt
|
||
in `user.service.ts` (Aufgabe 2), der in diesem Plan ohnehin angefasst
|
||
wird — dort und nicht in `ldap`.
|
||
|
||
**Harmlos (eine Stelle):**
|
||
|
||
6. **Die Selbstbedienungswege für Bild und Akzentfarbe** (Befund G) — ein
|
||
leerer Lesezugriff heißt „kein Bild hinterlegt". Harmlos in der
|
||
Wirkung, aber vollständigkeitshalber in der Tabelle (u2) festgehalten.
|
||
|
||
**Was ein GEBUNDENER Nachschlageweg auf `username` mit dem Anmeldeweg
|
||
machen würde, und warum die Frage hier gegenstandslos ist:** der Anmeldeweg
|
||
läuft seit Etappe 1 (260909-eor) über die drei SECURITY-DEFINER-Funktionen
|
||
und nicht mehr über `UserService.findByUsername` — belegt durch die
|
||
Aufrufermessung aus TEIL 3 oben (genau ein Treffer, die Definition selbst),
|
||
nicht behauptet. Würde `findByUsername` dennoch gebunden, säße das Problem
|
||
nicht im Anmeldeweg (der diese Methode gar nicht mehr aufruft), sondern
|
||
genau in der unter Punkt 4 beschriebenen Kollisionserzeugung — derselbe
|
||
Grund, aus dem `resolveEmailForWrite` im Bereich `ldap` ungebunden bleibt.
|
||
|
||
**Gegenrichtung, ebenfalls nachgesehen und in diese Kritikschrift
|
||
gehörend:** ein gebundenes Ändern oder Löschen über die Kennung allein
|
||
trifft eine fremde Zeile NICHT still im Sinne einer Zeilenschutz-Ablehnung,
|
||
sondern schlicht mit null betroffenen Zeilen
|
||
(`user-gebundenes-update-ueber-kennung-allein-trifft-null-zeilen`,
|
||
`user-gebundenes-loeschen-ueber-kennung-allein-trifft-null-zeilen`) — die
|
||
vorgeschalteten Besitz- und Rollenprüfungen in `user.controller.ts` bleiben
|
||
deshalb Pflicht und werden in Aufgabe 3 nicht durch die Datenbank ersetzt.
|
||
Ein gebundenes Einfügen mit fremder Mandantenkennung wird laut abgewiesen
|
||
(`user-gebundenes-einfuegen-fremder-mandant-abgelehnt`, SQLSTATE 42501).
|
||
Das sind die lauten Stellen, und dieser Abschnitt besteht nicht nur aus
|
||
Alarm.
|
||
|
||
### (u4) Was dieser Durchlauf bewusst nicht löst
|
||
|
||
Die plattformweite Eindeutigkeit von `username` und `email` selbst — die
|
||
Ursache, aus der jede Ausnahme dieses Plans folgt. Die ehrliche Reparatur
|
||
wäre eine Schemaänderung (eine Eindeutigkeit mit Mandantendimension); das
|
||
ist eine Produktentscheidung — darf dieselbe Adresse zwei Mandanten
|
||
gehören — und sie ist für Etappe 3 bereits vorgemerkt. Hier wird sie
|
||
festgehalten, nicht entschieden, und ausdrücklich nicht durch eine
|
||
Migration vorweggenommen (Broken-Windows-Register, Aufgabe 2).
|
||
|
||
Die Übergabe in den noch nicht umgestellten Bereich `auth`: `auth.service.ts`
|
||
ist aus demselben Grund `gemischt` (Befund E) und NICHT Gegenstand dieses
|
||
Plans. Die Grenze ist gemessen, nicht aus Erinnerung gezogen: die Datei hat
|
||
drei bereits über `forTenant()` gebundene Schreibzugriffe (Anmeldezeitstempel,
|
||
Kennwortwechsel nach Zurücksetzen) und fünf noch ungebundene Zugriffe auf
|
||
`user`, die zu `getMe`, `changePassword` und `adminResetPassword` gehören.
|
||
Die drei Anmelde-/Zurücksetz-Nachschlagewege laufen über `$queryRaw` auf die
|
||
drei SECURITY-DEFINER-Funktionen. Dieser Plan fasst `auth.service.ts` an
|
||
KEINER Stelle an.
|
||
|
||
Die offene Architekturfrage `req.tenantPrisma` — auch der Bereich `user`
|
||
entscheidet sie nicht. Er bindet dienst-intern, wie `ldap`, `groups`,
|
||
`tenders` und `dkv` es vormachen.
|
||
|
||
### (u5) Was dieser Durchlauf bewusst NICHT anfasst
|
||
|
||
- `auth.service.ts` an keiner Stelle (siehe (u4)).
|
||
- Die drei SECURITY-DEFINER-Funktionen aus Etappe 1 und ihre Rechte nicht —
|
||
der Anmeldeweg bleibt unberührt, belegt durch die Aufrufermessung aus
|
||
TEIL 3 oben.
|
||
- Schema und Migrationen nicht.
|
||
- Die Verdrängung im gemeinsamen Ablagverzeichnis der Profilbilder
|
||
(`user-files/avatars/`) nicht — anders als bei den Ausfuhrdateien des
|
||
Bereichs `dkv` (Befund F dort) ist die Dateibenennung hier
|
||
`{userId}.{ext}` und damit bereits kollisionsfrei über Mandanten hinweg;
|
||
es ist ohnehin keine Bindungsfrage.
|
||
|
||
Jeweils mit der Feststellung, dass sie geprüft und bewusst gelassen sind —
|
||
nicht übersehen.
|
||
|
||
**Nachtrag (260910-das, Aufgabe 3).** Wie in den vorherigen Durchläufen wird
|
||
der Text oben NICHT umgeschrieben — er beschreibt korrekt den Stand zum
|
||
Zeitpunkt der Messung (Aufgabe 1); dieser Nachtrag hält fest, was Aufgabe 2/3
|
||
tatsächlich umgesetzt haben.
|
||
|
||
*Tatsächlich umgesetzte Pfade gegen die angekündigten gehalten:* alle in (u2)
|
||
genannten Pfade sind wie beschrieben umgestellt. `UserService.findById`,
|
||
`create`, `update`, `deactivate`, `delete` laufen über `forTenant()`;
|
||
`create`/`update` übersetzen die plattformweite Eindeutigkeitsverletzung in
|
||
eine deutsche Konfliktmeldung, die weder Halter noch Mandant nennt.
|
||
`AdminSeedService.seedAdmin()` bindet die Erstanlage des Administrators an
|
||
den unmittelbar zuvor angelegten Mandanten und entschärft die Startsperre
|
||
(P2002 wird wie „Administrator existiert bereits" behandelt, jeder andere
|
||
Fehler bricht weiterhin ab). `UserController` bindet alle sieben eigenen
|
||
Zugriffe (ADMIN-Zweig der Benutzerliste, alle fünf Selbstbedienungswege) und
|
||
löst die drei Wege über die Kennung rollenabhängig auf. `findByUsername`
|
||
bleibt wie angekündigt ungebunden, mit richtiggestelltem Kopfkommentar.
|
||
|
||
*Die geschlossene Lücke im Selbstlösch-Riegel (Befund H):* der Vergleich
|
||
`user.id === currentUser.sub` griff nie, weil der Sitzungsnachweis kein Feld
|
||
`sub` trägt (`JwtStrategy.validate()` liefert exakt `{ id, username, role,
|
||
tenantId }`). Der Nachweis kommt aus der Reihenfolge selbst, nicht aus einer
|
||
Behauptung: `user.controller.spec.ts`, Test 6, wurde zuerst gegen die alte
|
||
Fassung ausgeführt — die Zeile `if (user.id === currentUser.sub)` wurde
|
||
probeweise wiederhergestellt, der Testlauf zeigte den erwarteten roten Test
|
||
(„promise resolved … instead of rejecting"), danach wurde auf
|
||
`currentUser.id` repariert und derselbe Testlauf grün. Die Wirkung ist eine
|
||
Verhaltensänderung: ein Administrator kann sein eigenes Konto seither nicht
|
||
mehr löschen — das ist die ursprüngliche, im Code bereits formulierte
|
||
Absicht, nicht neu erfunden.
|
||
|
||
*Die gewählte Form der Plattform-Administratorsicht, samt Beleg aus der
|
||
Messung:* wie in (u3)/Befund F angekündigt, laufen
|
||
`UserService.findAllForPlatformAdmin()` und `findByIdForPlatformAdmin()` als
|
||
Schleife über alle Mandanten (`this.prisma.tenant.findMany`, ungebunden, weil
|
||
`Tenant` keinen Zeilenschutz trägt) mit je EINEM gebundenen Lesezugriff im
|
||
Rumpf — dieselbe Form wie `AdminSeedService.ensureDefaultGroupsForAllTenants()`.
|
||
Der Beleg, dass diese Form die heutige Sicht erhält statt sie zu mindern,
|
||
kommt aus Aufgabe 1: `user-fan-out-je-mandant-gebunden-liefert-alle-zeilen`
|
||
maß die Vereinigung der je-Mandant gebundenen `SELECT`s gegen eine über die
|
||
Wartungsrolle (mit `BYPASSRLS`) gemessene Gesamtmenge — beide Mengen waren
|
||
identisch (`["alice","bob","carol","dave"]`). In `user.service.spec.ts`
|
||
bestätigen Test 6 und Test 7 dasselbe am Code: je Mandant genau EIN
|
||
Protokolleintrag, die Sortierung nach Benutzername bleibt über die
|
||
zusammengeführten Teilmengen hinweg korrekt, und eine Kennungsauflösung für
|
||
einen Benutzer eines fremden Mandanten gelingt nachweislich über einen
|
||
gebundenen Lesezugriff.
|
||
|
||
*Falsifizierungsnachweise (Aufgabe 2 und 3), je einmal durchgeführt und
|
||
zurückgenommen:* in Aufgabe 2 wurde `UserService.findById` probeweise auf
|
||
den ungebundenen Klienten zurückgebaut — genau `user.service.spec.ts`, Test
|
||
4, wurde rot, mit der Meldung „erwarteter gebundener Aufruf
|
||
user.findUnique(tenant=t1) fehlt im Protokoll: []"; der Rückbau wurde
|
||
zurückgenommen, derselbe Testlauf danach wieder grün. In derselben Aufgabe
|
||
wurde zusätzlich `AdminSeedService.seedAdmin()`s gebundener
|
||
`tenantPrisma.user.create`-Aufruf probeweise auf den ungebundenen Basisclient
|
||
zurückgebaut — sechs Tests wurden rot (u. a. Test 9–12), alle mit der
|
||
Meldung „tenantPrisma.user.create is not a function", weil der ungebundene
|
||
Basisclient in der Testattrappe keine `create`-Methode auf `user` trägt; der
|
||
Rückbau wurde zurückgenommen, alle zehn Tests danach wieder grün. In Aufgabe
|
||
3 wurde `UserController.uploadAvatar`s gebundener Schreibzugriff probeweise
|
||
auf den ungebundenen Basisclient zurückgebaut — genau
|
||
`user.controller.spec.ts`, Test 7, wurde rot, mit der Meldung „Cannot read
|
||
properties of undefined (reading 'update')"; der Rückbau wurde
|
||
zurückgenommen, derselbe Testlauf danach wieder grün.
|
||
|
||
## Bereich module-registry
|
||
|
||
Dieser Abschnitt erweitert die Kritikschrift um den Bereich `module-registry`
|
||
(Quick-Task 260910-exd) und beschreibt ihn zum Zeitpunkt seiner Umstellung.
|
||
Die Leitfrage aus Abschnitt (a) gilt unverändert weiter — dieser Abschnitt
|
||
beantwortet sie für den Bereich, der bei JEDER Modulanfrage entscheidet, wer
|
||
was benutzen darf: die Aktivierungsstufe (`TenantModuleActivation`) und die
|
||
Freigabestufe (`ModuleGrant`). Bleibt hier eine Abfrage ungebunden, sieht das
|
||
nach dem Scharfschalten nicht wie ein Fehler aus, sondern wie ein
|
||
Rechteentzug — die sichtbarste Ausprägung der umgekehrten Fehlerrichtung im
|
||
gesamten Vorhaben und zugleich die am wenigsten meldungswahrscheinliche.
|
||
|
||
### (m1) Die Messung
|
||
|
||
Aufgabe 1 hat `apps/api/scripts/rls-scratch-check.mjs` um einen achten
|
||
Abschnitt (`runModuleRegistryAreaChecks`) erweitert. Er setzt auf den vier
|
||
Tabellen auf, die `runGroupsAreaChecks` bereits mit Policies WORTGLEICH aus
|
||
den ausgelieferten Migrationen anlegt (`Group`, `GroupMembership`,
|
||
`ModuleGrant`, `TenantModuleActivation`) und legt selbst nur hinzu: die
|
||
Tabelle `"Module"` OHNE Zeilenschutz (die zu messende Eigenschaft selbst),
|
||
den Eindeutigkeitsindex auf `("tenantId","moduleId")` für
|
||
`"TenantModuleActivation"` (wortgleich aus `20260619103242_add_module_registry`
|
||
geschnitten), zwei Direkt-Freigabezeilen und die Mitgliedschaft (group-b,
|
||
user-a), die `runGroupsAreaChecks` unter gebundenem Kontext bewusst nicht
|
||
anlegen konnte. Die Zeile `grant-foreign-group` aus dem Abschnitt `groups`
|
||
wird wiederverwendet. Tatsächlich beobachtete Ausgabe dieses Laufs
|
||
(2026-09-10, gegen `tessera-ctl-db-1`, Adresse `172.19.0.2`):
|
||
|
||
```
|
||
modulegrant-ungebunden-null-zeilen: bestanden — ungebundener SELECT auf "ModuleGrant" liefert 0 Zeile(n), tatsaechlich vorhanden sind 5
|
||
tenantmoduleactivation-ungebunden-null-zeilen: bestanden — ungebundener SELECT auf "TenantModuleActivation" liefert 0 Zeile(n), tatsaechlich vorhanden sind 3
|
||
admin-kurzschluss-gebunden-liefert-nur-eigene-aktivierungen: bestanden — forTenant(TENANT-A) liefert 1 aktive Aktivierung(en): ["TENANT-A"]
|
||
direktfreigabe-gebunden-nur-eigene-zeile: bestanden — forTenant(TENANT-A) liefert fuer die Direkt-Freigabe-Abfrage (userId=user-a) 1 Zeile(n): ["grant-direct-a"]
|
||
gruppenpfad-gebunden-folgt-der-gruppenregel: bestanden — forTenant(TENANT-A) liefert ueber den Drei-Tabellen-Weg (Freigabe ueber Gruppe ueber Mitgliedschaft) fuer user-a 1 Zeile(n): ["grant-a"]
|
||
gruppenpfad-gebunden-schliesst-die-fremde-gruppe-aus: bestanden — forTenant(TENANT-A) liefert 'grant-foreign-group' ueber denselben Drei-Tabellen-Weg NICHT (Ergebnis: ["grant-a"]), obwohl die Regel auf "ModuleGrant" diese Zeile nachweislich durchlaesst (T-JTS-03) und die Mitgliedschaft (group-b, user-a) vorhanden ist — diese Verteidigung greift erst nach dem Scharfschalten
|
||
gruppenpfad-ueber-die-wartungsrolle-liefert-die-fremde-gruppe-mit: bestanden — dieselbe Abfrage ueber die Verwaltungsrolle (BYPASSRLS) liefert ["grant-a","grant-foreign-group"] — der Ausschluss aus Pruefung 6 kommt damit nachweislich von der Bindung, nicht vom Aufbau
|
||
module-tabelle-traegt-keinen-zeilenschutz: bestanden — ungebundenes SELECT auf "Module" liefert 2 Zeile(n): ["mod-1","mod-2"]; pg_class.relrowsecurity fuer "Module" = false
|
||
katalog-bindung-aendert-heute-nichts-an-der-ergebnismenge: bestanden — forTenant(TENANT-A) liefert ["mod-1","mod-2"], ungebunden liefert ["mod-1","mod-2"] — identisch, weil "Module" keine Regel traegt (Befund E: die Nichtbindung des Katalogs ist heute keine Rettung vor Unsichtbarkeit, sondern eine Frage der Wahrhaftigkeit der Aufzeichnung; sie wird erst zur Rettung, WENN Etappe 3 dieser Tabelle eine Regel gibt)
|
||
gebundener-join-auf-den-katalog-liefert-den-modulnamen: bestanden — forTenant(TENANT-A) liefert fuer den Verbund aus Aktivierung und Katalog: [{"moduleId":"mod-1","name":"Modul Eins"}]
|
||
aktivierung-gebundenes-einfuegen-fremder-mandant-abgelehnt: bestanden — gebundenes INSERT unter TENANT-A mit tenantId=TENANT-B abgewiesen mit SQLSTATE 42501 (ERROR: new row violates row-level security policy for table "TenantModuleActivation")
|
||
aktivierung-eindeutigkeit-traegt-den-mandanten-keine-unsichtbare-kollision: bestanden — gebundenes INSERT von (TENANT-A, mod-2) ist GELUNGEN, obwohl (TENANT-B, mod-2) bereits existiert und unter TENANT-A unsichtbar ist — der Eindeutigkeitsindex fuehrt mit der Mandantenkennung, genau die Entlastung, die die Bereiche `tenders` und `user` NICHT hatten (dort: unsichtbare Zeile, falsches "frei", harter Eindeutigkeitsfehler)
|
||
freigabe-eindeutigkeitsindizes-fuehren-mit-der-mandantenkennung: bestanden — aus 20260804130130_add_groups_and_module_grants extrahiert: beide partiellen Eindeutigkeitsindizes auf "ModuleGrant" ("tenantId","moduleId","groupId") und ("tenantId","moduleId","userId") fuehren mit der Mandantenkennung, dieselbe Entlastung wie Pruefung 12, hier fuer die Freigabetabelle
|
||
Alle 66 Pruefungen bestanden.
|
||
```
|
||
|
||
**NACHTRAG (260910-jab):** die fett hervorgehobene Zeile oben
|
||
(`gruppenpfad-gebunden-schliesst-die-fremde-gruppe-aus`) ist ein
|
||
Messprotokoll vom 2026-09-10 und bleibt UNVERÄNDERT stehen. Ihre Behauptung
|
||
"die Regel auf ModuleGrant lässt diese Zeile durch (T-JTS-03)" ist seit
|
||
Migration `20260910120000_rls_widen_membership_grant_and_platform_read`
|
||
ÜBERHOLT: die Regel weist ein gebundenes Einfügen von `grant-foreign-group`
|
||
jetzt nachweislich ab (`modulegrant-fremde-gruppe-abgelehnt`); die Zeile
|
||
existiert in aktuellen Läufen nur noch, weil
|
||
`modulegrant-fremde-gruppe-ueber-die-wartungsrolle-weiterhin-moeglich` sie
|
||
über die Wartungsrolle (BYPASSRLS) anlegt. Der Meldetext dieser Prüfung ist
|
||
im Quelltext des Werkzeugs entsprechend richtiggestellt.
|
||
|
||
Die Belegzeile, die diesen Abschnitt trägt, ist
|
||
`modulegrant-ungebunden-null-zeilen`: der IDENTISCHE `SELECT "tenantId" FROM
|
||
"ModuleGrant"` ohne vorheriges `set_config` liefert **0 Zeilen**, nicht etwa
|
||
die 5 tatsächlich vorhandenen — an der echten, ausgelieferten Policy
|
||
gemessen. `tenantmoduleactivation-ungebunden-null-zeilen` misst dieselbe
|
||
Unsichtbarkeit für die Tabelle, die der Rollen-Kurzschluss für
|
||
ADMIN/SUPER_ADMIN als EINZIGE liest: auch hier fällt der
|
||
Verwaltungszugriff nach dem Scharfschalten aus.
|
||
|
||
**TEIL 2, Beleg statt Behauptung für Befund B** — keine Transaktion in
|
||
diesem Bereich:
|
||
|
||
```
|
||
$ grep -rn '\$transaction(' apps/api/src/module-registry --include=*.ts | grep -v spec
|
||
$ echo $?
|
||
1
|
||
```
|
||
|
||
Null Treffer, Rückgabewert 1. Der im Kopf von `prisma-tenant.extension.ts`
|
||
verlangte erneute Test ist damit für diesen Bereich beantwortet: kein neuer
|
||
Transaktionsfall, `withTenantTransaction()` wird hier nicht gebraucht und in
|
||
Aufgabe 2/3 nicht eingeführt.
|
||
|
||
**TEIL 3, Beleg statt Behauptung für Befund G** — die Aufrufermessung für
|
||
`isModuleActive` und `findActiveForTenant`:
|
||
|
||
```
|
||
$ grep -rn "isModuleActive" apps/api/src apps/web/src packages
|
||
apps/api/src/module-registry/module-registry.service.ts:133: async isModuleActive(tenantId: string, moduleSlug: string): Promise<boolean> {
|
||
|
||
$ grep -rn "findActiveForTenant" apps/api/src apps/web/src packages
|
||
apps/api/src/module-registry/module-registry.service.ts:35: async findActiveForTenant(tenantId: string) {
|
||
apps/api/src/module-registry/module-registry.controller.ts:51: * uses, not just tenant-wide activation. findActiveForTenant on
|
||
```
|
||
|
||
`isModuleActive` hat genau EINEN Treffer, die Definition selbst — ihr
|
||
Kopfkommentar behauptet "Used by ModuleGuard to gate access to
|
||
module-specific endpoints"; der Wächter ruft sie nachweislich nie auf
|
||
(`module.guard.ts` hält keinen eigenen Datenbankzugriff, siehe Befund D).
|
||
`findActiveForTenant` hat zwei Treffer, die Definition und eine
|
||
Prosa-Erwähnung im Kopfkommentar des Controllers ("stays unchanged for Plan
|
||
15-03's marketplace catalog") — auch sie hat keinen echten Aufrufer, der
|
||
Marktplatz-Katalog wird nachweislich von `getCatalogFlags` bedient. Beide
|
||
Kommentare werden in Aufgabe 3 richtiggestellt, mit Bezug auf diese Messung.
|
||
|
||
### (m2) Signaltabelle je umgestelltem Pfad
|
||
|
||
| Pfad | Verhalten bei zu wenig Ergebnis | Konkretes Signal, Ort |
|
||
|---|---|---|
|
||
| `ModuleAccessService.getAccessibleModuleIds`, Rollen-Kurzschluss (ADMIN/SUPER_ADMIN) | Der gebundene Aktivierungs-Lesezugriff liefert 0 Zeilen statt der mandantenweit aktiven Module | Sidebar und Marktplatz zeigen für JEDEN Administrator des Mandanten keine Module mehr; jede Modulroute antwortet mit 403 |
|
||
| `ModuleAccessService.getAccessibleModuleIds`, Direktweg (USER) | Der gebundene Freigabe-Lesezugriff über `userId` liefert 0 Zeilen statt der eigenen Direkt-Freigaben | Der Benutzer verliert genau die Module, die ihm direkt zugewiesen waren — ununterscheidbar von einem echten Entzug |
|
||
| `ModuleAccessService.getAccessibleModuleIds`, Gruppenweg (USER) | Der gebundene, verschachtelte Freigabe-Lesezugriff über die Gruppenmitgliedschaft liefert 0 Zeilen statt der Gruppen-Freigaben | Der Benutzer verliert alle über Gruppen geerbten Module, während seine Direkt-Freigaben unberührt bleiben — ein teilweiser, schwer zu erklärender Verlust |
|
||
| `ModuleAccessService.getAccessibleModuleIds`, Schnittmengenabfrage | Die gebundene Aktivierungsabfrage über `moduleId: { in: grantedIds }` liefert 0 Zeilen, obwohl Freigaben vorliegen | Ein Benutzer mit vorhandenen Freigaben sieht trotzdem kein Modul — von der leeren Vorgabemenge (kein Freigabe) nicht zu unterscheiden |
|
||
| `ModuleAccessService.findAccessibleModules` | Der Rückgabepunkt bei leerer `accessibleIds`-Menge liefert `[]`, ohne den Katalog überhaupt anzufragen | `GET /modules/active` liefert eine leere Liste; die Sidebar ist leer |
|
||
| `ModuleAccessService.getCatalogFlags` | Der gebundene Aktivierungs-Lesezugriff liefert 0 Zeilen statt der mandantenweit aktiven Module | Der Marktplatz zeigt für JEDES Modul `isActiveForTenant: false` UND `hasAccess: false` — eine ANDERE Unwahrheit als der Sperrhinweis: "nicht aktiviert" statt "nicht freigegeben" |
|
||
| `ModuleRegistryService.findActiveForTenant` (heute ohne Aufrufer) | Der gebundene Aktivierungs-Lesezugriff liefert 0 Zeilen | Betrifft heute keinen erreichbaren Pfad — die Falle liegt in einer KÜNFTIGEN Verdrahtung, siehe TEIL 3 |
|
||
| `ModuleRegistryService.activateForTenant` | Der gebundene Schreibzugriff schlägt fehl bzw. die Existenzprüfung des Katalogs (ungebunden) liefert `null` | `POST /modules/:id/activate` liefert `404 Module with id '...' not found`, obwohl das Modul existiert |
|
||
| `ModuleRegistryService.deactivateForTenant` | Der gebundene Lesezugriff auf die Aktivierung liefert `null` statt der vorhandenen Zeile | `POST /modules/:id/deactivate` liefert `404 Module '...' is not activated for this tenant` — LAUT, siehe (m3) |
|
||
| `ModuleRegistryService.isModuleActive` (heute ohne Aufrufer) | Der gebundene Aktivierungs-Lesezugriff liefert `null`, die Methode gibt `false` zurück | Betrifft heute keinen erreichbaren Pfad — die stille Falle für eine KÜNFTIGE Verdrahtung, siehe (m3) |
|
||
| `ModuleRegistryService.findAll`/`findBySlug`/`seedModule` (bewusst UNGEBUNDEN, Modulkatalog) | Betrifft nicht diese Pfade selbst — sie binden nicht und liefern deshalb weiterhin korrekt. Das Risiko liegt in einer KÜNFTIGEN Bindung (Etappe 3, sobald `Module` eine Regel bekommt) | Würde man sie binden: der gesamte Modulkatalog verschwände für JEDEN Mandanten — hier ausdrücklich als Grenze festgehalten, nicht als heute beobachtbares Verhalten |
|
||
| `ModuleAccessService`, Katalogzugriffe in `findAccessibleModules`/`getCatalogFlags` (bewusst UNGEBUNDEN) | Dieselbe Grenze wie oben, hier für die beiden Katalog-Lesezugriffe des Nachbardienstes | Dieselbe Folge: eine künftige Bindung würde den Katalog mandantenweit unsichtbar machen |
|
||
|
||
### (m3) Welcher Code Leere als Abwesenheit deutet
|
||
|
||
Nach Wirkung sortiert:
|
||
|
||
1. **`ModuleAccessService.getAccessibleModuleIds`, `grantedIds.length === 0`
|
||
→ `return new Set()`** — der Rückgabepunkt bei leerer Freigabemenge. Der
|
||
Vorgabezustand ist geschlossen (D-04/T-EXD-04); eine zu leer gebliebene
|
||
Freigabeabfrage sieht identisch aus wie ein Benutzer ohne jede Freigabe.
|
||
2. **`ModuleAccessService.getAccessibleModuleIds`, Rollen-Kurzschluss** —
|
||
`if (role === 'ADMIN' || role === 'SUPER_ADMIN')` liest ausschließlich
|
||
`tenantModuleActivation`; eine leere Aktivierungsliste liefert ein leeres
|
||
Set, ohne die Freigabestufe überhaupt zu befragen. Betrifft damit JEDEN
|
||
Administrator des Mandanten gleichzeitig.
|
||
3. **`ModuleAccessService.findAccessibleModules`, `accessibleIds.size === 0`
|
||
→ `return []`** — der Rückgabepunkt bei leerer Modulliste, bedient
|
||
`GET /modules/active` und damit die Sidebar.
|
||
4. **`ModuleAccessService.getCatalogFlags`** — eine leere Aktivierungsliste
|
||
lässt die zurückgegebene Map leer; der Controller
|
||
(`module-registry.controller.ts`, `findCatalog`) mappt einen fehlenden
|
||
Eintrag auf BEIDE Flags `false`. Das erzeugt eine ANDERE Unwahrheit als
|
||
der Sperrhinweis der Freigabestufe: "nicht aktiviert" statt "nicht
|
||
freigegeben" — der Marktplatz zeigt dem Benutzer den falschen Grund für
|
||
die Sperre.
|
||
5. **`ModuleGuard.canActivate`, die 403-Stelle** —
|
||
`if (!accessibleModuleIds.has(module.id)) throw new ForbiddenException(...)`.
|
||
Dieselbe Meldung für "wirklich keine Freigabe" und "die Aufösung hat
|
||
nichts gefunden" — siehe die Leitfrage dieses Abschnitts unten.
|
||
6. **`ModuleRegistryService.isModuleActive`, Vorgabewert `false`** — heute
|
||
ohne Aufrufer (TEIL 3), aber die stille Falle für morgen: `return
|
||
activation?.isActive === true` liefert bei `null` (leerer, gebundener
|
||
Lesezugriff) exakt dasselbe `false` wie eine echte, mandantenweite
|
||
Deaktivierung. Wird dieser Pfad morgen verdrahtet, ist er von Fall an
|
||
nicht mehr vom Rest dieses Abschnitts zu unterscheiden.
|
||
|
||
**Gegenrichtung, damit dieser Abschnitt nicht nur aus Alarm besteht:**
|
||
`ModuleRegistryService.deactivateForTenant` wirft bei leerer
|
||
Aktivierungsabfrage (`if (!activation) throw new NotFoundException(...)`)
|
||
LAUT — die Ausnahme meldet sich sofort und verständlich, statt ein stilles
|
||
`false` zu liefern. Dieselbe laute Richtung gilt für `activateForTenant` bei
|
||
unbekannter `moduleId`.
|
||
|
||
**Die Frage, die dieser Bereich vor allen anderen beantworten muss: welches
|
||
Signal unterscheidet "wirklich keine Freigabe" von "die Abfrage hat nichts
|
||
gefunden"?** Die Antwort lautet **keines** — schlicht, ohne Beschönigung.
|
||
Drei Stellen sehen heute identisch aus, ob der Benutzer tatsächlich keine
|
||
Freigabe hat oder ob eine gebundene Abfrage nach dem Scharfschalten leer
|
||
lief: dieselbe `ForbiddenException`-Meldung im Wächter
|
||
("Module '...' is not accessible for this user"), dieselbe leere Modulliste
|
||
mit Status 200 (`GET /modules/active`), kein einziger Protokolleintrag. Der
|
||
Zusatz, der diesen Bereich von allen vorherigen unterscheidet: der
|
||
Betroffene hat eine fertige, FALSCHE Erklärung zur Hand ("mein Administrator
|
||
hat mir das entzogen") und meldet deshalb keinen Fehler — anders als etwa
|
||
bei `LdapConfigScheduler`, wo niemand eine plausible Alltagserklärung für
|
||
ausbleibende Synchronisation hat.
|
||
|
||
Die eine Asymmetrie, die sich zu einem Signal machen LIESSE, wird hier
|
||
benannt und an Etappe 4 übergeben, nicht gelöst: nach dem Scharfschalten ist
|
||
ein zu kleines Ergebnis in diesem Bereich TOTAL und nicht selektiv — JEDER
|
||
Benutzer JEDES Mandanten verliert gleichzeitig jedes Modul, während die
|
||
Aktivierungs- und Freigabetabellen weiterhin Zeilen halten. Die
|
||
Vorabprüfung von Etappe 4 (`rls-preflight.mjs`) kann genau das feststellen:
|
||
aktive Aktivierungszeilen vorhanden, aber die Auflösung liefert für einen
|
||
bekannten Administrator eine leere Menge. Eine Laufzeitwarnung an den oben
|
||
genannten Stellen wird ERWOGEN und VERWORFEN, mit derselben Begründung wie
|
||
bei `getAllActiveConfigs` im Bereich `ldap` und den fünf Stellen im Bereich
|
||
`tenders`: eine leere Menge ist für einen Benutzer ohne Freigabe und auf
|
||
einer frischen Installation der Normalzustand, eine Warnung wäre Dauerlärm
|
||
und verlöre ihr Signal.
|
||
|
||
### (m4) Was dieser Durchlauf bewusst nicht löst
|
||
|
||
- ~~**T-JTS-02/T-JTS-03 bleiben aufgezeichnet und ungefixt.** Gemessen in
|
||
Aufgabe 1 (Prüfungen 6/7): die Regel auf `ModuleGrant` prüft nur die
|
||
Mandantenkennung der Zeile selbst, nicht die referenzierte Gruppe; die
|
||
Regel auf `GroupMembership` prüft nur die Gruppenseite. Die Bindung fügt
|
||
auf der LESESEITE eine zweite Verteidigung hinzu (der Drei-Tabellen-Weg
|
||
schließt die fremde Gruppe gebunden aus), aber erst nach Etappe 4 — die
|
||
Schreibseiten-Gegenprüfung in `module-grants.service.ts`
|
||
(`assertTargetBelongsToTenant`) bleibt deshalb der heutige Schutz und wird
|
||
durch diesen Durchlauf NICHT ersetzt.~~
|
||
**NACHTRAG (260910-jab): ÜBERHOLT — BEIDE Befunde sind geschlossen.**
|
||
Migration `20260910120000_rls_widen_membership_grant_and_platform_read`
|
||
lässt die Regel auf `GroupMembership` jetzt beide Seiten prüfen
|
||
(T-JTS-02) und die Regel auf `ModuleGrant` zusätzlich beide möglichen
|
||
Ziele (T-JTS-03, beide Zweige des Entweder-oder D-04). Die
|
||
Schreibseiten-Gegenprüfung in `module-grants.service.ts`
|
||
(`assertTargetBelongsToTenant`) bleibt TROTZDEM bestehen — sie ist bis
|
||
zum Scharfschalten (#18, Schalter weiterhin aus) der einzige tatsächlich
|
||
wirksame Schutz und wird NICHT ersetzt. Siehe den neuen Abschnitt
|
||
"Regelschluss T-JTS-02, T-JTS-03 und WINDOWS #19" unten.
|
||
- **Die Regel für den Modulkatalog gehört zu Etappe 3.** Gemessen in
|
||
Aufgabe 1 (Prüfung 8/9, Befund E): `"Module"` trägt heute keinen
|
||
Zeilenschutz, eine Bindung wäre heute wirkungslos, nicht katastrophal.
|
||
Katastrophal würde sie erst, WENN Etappe 3 dieser Tabelle eine Regel gibt
|
||
— dann verschwände der gesamte Katalog für jeden Mandanten. Diese
|
||
Bedingung steht hier als Bedingung, nicht als heute beobachtbare Tatsache.
|
||
- **Die offene Architekturfrage `req.tenantPrisma`** — auch dieser Bereich
|
||
entscheidet sie nicht. Er bindet dienst-intern, wie `ldap`, `groups`,
|
||
`tenders`, `dkv` und `user` es vormachen.
|
||
|
||
### (m5) Was dieser Durchlauf bewusst NICHT anfasst
|
||
|
||
- `module.guard.ts` — geprüft (Befund D: hält keinen eigenen
|
||
Datenbankzugriff, seine Richtigkeit ist vollständig eine Funktion dessen,
|
||
was `ModuleAccessService` zurückgibt) und bewusst gelassen, keine Bindung
|
||
nötig.
|
||
- `module-registry.controller.ts` — geprüft (hält ebenfalls keinen eigenen
|
||
Datenbankzugriff) und bewusst gelassen.
|
||
- Das Frontend — geprüft und bewusst gelassen, keine Datei dieses Plans.
|
||
- Schema und Migrationen — geprüft und bewusst gelassen, keine
|
||
Schemaänderung in dieser Etappe.
|
||
|
||
**Nachtrag (260910-exd, Aufgabe 3).** Wie in den vorherigen Durchläufen wird
|
||
der Text oben NICHT umgeschrieben — er beschreibt korrekt den Stand zum
|
||
Zeitpunkt der Messung (Aufgabe 1); dieser Nachtrag hält fest, was Aufgabe 2/3
|
||
tatsächlich umgesetzt haben.
|
||
|
||
*Tatsächlich umgesetzte Pfade gegen die in (m2) angekündigten gehalten:*
|
||
alle in (m2) genannten Pfade sind wie beschrieben umgestellt.
|
||
`ModuleAccessService.getAccessibleModuleIds` bindet Kurzschlusszweig,
|
||
Direktweg, Gruppenweg und Schnittmenge über EINEN Klienten je Aufruf;
|
||
`getCatalogFlags` bindet ihren eigenen Aktivierungs-Lesezugriff, die
|
||
geschachtelte `getAccessibleModuleIds`-Auflösung erzeugt ihren eigenen
|
||
Klienten; `findAccessibleModules` erreicht den Katalog weiterhin über den
|
||
ungebundenen Klienten. `ModuleRegistryService.findActiveForTenant`,
|
||
`activateForTenant` und `isModuleActive` binden ihren jeweiligen
|
||
Aktivierungszugriff; `deactivateForTenant` bindet beide Aktivierungszugriffe
|
||
(Lesen, Schreiben) über EINEN Klienten. Alle sechs Katalogzugriffe in
|
||
`module-registry.service.ts` (`findAll`, `findBySlug`, die beiden
|
||
Katalog-Existenzprüfungen, die Katalogsuche in `isModuleActive`,
|
||
`seedModule`) und der eine Katalogzugriff in `module-access.service.ts`
|
||
(`findAccessibleModules`) blieben wie angekündigt ungebunden, mit
|
||
Kommentaren, die Messung und Bedingung trennen. Beide falschen
|
||
Kopfkommentare aus Befund G sind behandelt: `isModuleActive`s Kommentar ist
|
||
richtiggestellt (mit Bezug auf die Aufrufermessung aus TEIL 3); der
|
||
irreführende Satz im Kopfkommentar von `module-registry.controller.ts`
|
||
("findActiveForTenant on ModuleRegistryService stays unchanged for Plan
|
||
15-03's marketplace catalog") wurde NICHT mitgeändert — der Controller ist
|
||
nicht Teil dieses Plans. Die Feststellung, dass diese Aussage falsch ist
|
||
(der Marktplatz-Katalog wird nachweislich von `getCatalogFlags` bedient,
|
||
nicht von `findActiveForTenant`), steht stattdessen hier in (m5).
|
||
|
||
*Deviation (Rule 1/3): eine cross-area Testabhängigkeit brach durch die
|
||
Umstellung.* `tender-scheduler.service.spec.ts` instanziiert
|
||
`ModuleRegistryService` unmocked gegen einen hand-gerollten Fake ohne
|
||
`$extends` (derselbe Zweck wie in `ldap.service.spec.ts`: der Aktivierungs-
|
||
Aufruf soll echt sein, nicht ein Stand-in). Nach der Umstellung von
|
||
`activateForTenant` auf `forTenant()` scheiterte dieser Test mit
|
||
`prisma.$extends is not a function`. Behoben mit derselben Konvention wie
|
||
`ldap.service.spec.ts` — `forTenant` in dieser einen Datei über `vi.mock`
|
||
auf eine Identitätsfunktion gelegt (`forTenant: vi.fn((p) => p)`), weil die
|
||
Datei RLS-Bindungsmechanik nicht testet, nur das Poll-once-fan-out-many-
|
||
Verhalten des Schedulers. Kein anderer Aufrufer von `new
|
||
ModuleRegistryService(...)` existiert im Quelltext (geprüft).
|
||
|
||
*Deviation (Rule 3): die Klassifikationsdokument-Stände für
|
||
`module-access.service.ts` wurden bereits in Aufgabe 2 nachgezogen*, nicht
|
||
erst in dieser Aufgabe — `rls-access-inventory.spec.ts` ist Teil der von
|
||
Aufgabe 2 verlangten vollständigen Testsuite und wäre sonst am Ende von
|
||
Aufgabe 2 bereits rot gewesen. Diese Abweichung von der Aufgabenaufteilung
|
||
(die Klassifikationsdatei war für Aufgabe 3 vorgesehen) ist auf das
|
||
Notwendige beschränkt: nur die `Stand`-Spalte der beiden betroffenen Zeilen,
|
||
keine Begründung, keine Zahlen. Die vollständige Nachziehung (fünf
|
||
Bestandsaufnahme-Zeilen inklusive `module-registry.service.ts`,
|
||
Übersichtszeile, Summenzeile, Klassen-Verteilung, Hintergrunddienst-
|
||
Abschnitt) erfolgte wie geplant in dieser Aufgabe.
|
||
|
||
*Falsifizierungsnachweise (Aufgabe 2 und 3), je einmal durchgeführt und
|
||
zurückgenommen:* in Aufgabe 2 wurde die Gruppenweg-Bindung der
|
||
Freigabe-Auflösung probeweise zurückgebaut (`tenantPrisma.moduleGrant` →
|
||
`this.prisma.moduleGrant` im Gruppenweg von `getAccessibleModuleIds`) —
|
||
genau `module-access.service.spec.ts`, Test "ModuleAccessService — Bindung
|
||
an forTenant() (260910-exd) > USER-Zweig bindet BEIDE
|
||
Freigabe-Lesezugriffe (Direktweg und Gruppenweg) UND den
|
||
Schnittmengen-Lesezugriff an DIESELBE Mandantenkennung", wurde rot, mit der
|
||
Meldung `expected 1 to be 2`; der Rückbau wurde zurückgenommen, derselbe
|
||
Testlauf danach wieder grün (23/23). In Aufgabe 3 wurde der
|
||
Schreibzugriff des Deaktivierens probeweise zurückgebaut
|
||
(`tenantPrisma.tenantModuleActivation.update` →
|
||
`this.prisma.tenantModuleActivation.update` in `deactivateForTenant`) —
|
||
genau `module-registry.service.spec.ts`, Test
|
||
"ModuleRegistryService.deactivateForTenant > bindet beide
|
||
Aktivierungszugriffe (Lesen, Schreiben) an denselben Mandanten, über einen
|
||
Klienten", wurde rot, mit der Meldung "erwarteter gebundener Aufruf
|
||
tenantModuleActivation.update(tenant=t1) fehlt im Protokoll:
|
||
[{"tenantId":"t1","model":"tenantModuleActivation","method":"findUnique"}]:
|
||
expected false to be true"; der Rückbau wurde zurückgenommen, derselbe
|
||
Testlauf danach wieder grün (16/16).
|
||
|
||
*Die offene WINDOWS-Aufzeichnung.* Eintrag #23 (`deviation`) hält fest, dass
|
||
es kein Signal gibt, das "wirklich keine Freigabe" von "die Abfrage hat
|
||
nichts gefunden" unterscheidet, mit der Vorabprüfung für Etappe 4 und der
|
||
begründeten Verwerfung einer Laufzeitwarnung — siehe (m3) oben und
|
||
`.planning/WINDOWS.md`.
|
||
|
||
**Nachtrag (260910-krx):** der oben in Befund E festgehaltene Befund zur
|
||
Reihenfolge — der Bereich `dashboard` erbt die Bindung der
|
||
Modul-Zugriffsauflösung, ohne dass eine Datei unter
|
||
`apps/api/src/module-registry` dafür angefasst werden muss — ist mit
|
||
Quick-Task 260910-krx EINGELÖST und NACHGEPRÜFT: `dashboard.service.ts`
|
||
ruft `ModuleAccessService.getAccessibleModuleIds` für den Widget-Modulfilter
|
||
in `getWidgets` unverändert auf, diese Auflösung bindet seit 260910-exd
|
||
bereits über `forTenant()`, und der Filter ist damit gebunden. Nachgeprüft
|
||
mit `grep -n "const tenantPrisma = forTenant" apps/api/src/module-registry/
|
||
module-access.service.ts` (ein Treffer) und mit einem Wachhund-Testfall in
|
||
`dashboard.service.spec.ts`, der den Modulkatalog aus dem
|
||
Bindungsprotokoll heraushält. Der Widget-Modulfilter wurde von 260910-krx
|
||
NICHT ein zweites Mal gebunden — keine Datei unter
|
||
`apps/api/src/module-registry` ist Teil dieses Plans.
|
||
|
||
## Regelschluss T-JTS-02, T-JTS-03 und WINDOWS #19
|
||
|
||
Dieser Abschnitt weicht bewusst von der geplanten Reihenfolge ab (260910-jab,
|
||
auf ausdrückliche Anweisung des Nutzers): die drei Regelfixe, ursprünglich
|
||
nach Etappe 2 vorgesehen, wurden vorgezogen. Folge: die fünf noch offenen
|
||
Bereiche der Etappe 2 messen ab jetzt gegen die NEUEN Regeln, mehrere
|
||
bereits abgeschlossene Bereiche (`groups`, `tenders`, `module-registry` oben)
|
||
haben gegen die ALTEN gemessen — diese Messungen stehen aufgezeichnet und
|
||
sind mit Nachträgen an den betroffenen Stellen versehen, nicht umgeschrieben.
|
||
|
||
### (r1) Tatsächlich beobachtete Ausgabe und Regelliste der lebenden Datenbank
|
||
|
||
Lauf vom 2026-09-10 gegen `tessera-ctl-db-1`, Adresse `172.19.0.2` (nur die
|
||
Zeilen der drei betroffenen Bereiche sowie der beiden aufsetzenden
|
||
`module-registry`-Prüfungen; die vollständige Ausgabe umfasst 74
|
||
Prüfungen):
|
||
|
||
```
|
||
groupmembership-folgt-join-auf-group: bestanden — forTenant(TENANT-A) liefert 1 Mitgliedschaft(en): ["group-a"]
|
||
groupmembership-schreiben-fremde-gruppe-abgelehnt: bestanden — INSERT mit fremder groupId abgewiesen: ERROR: new row violates row-level security policy for table "GroupMembership"
|
||
groupmembership-schreiben-fremder-benutzer-abgelehnt: bestanden — INSERT mit A-eigener Gruppe, aber fremder Benutzerkennung (user-b, TENANT-B) abgewiesen: ERROR: new row violates row-level security policy for table "GroupMembership"
|
||
groupmembership-fremder-benutzer-ueber-die-wartungsrolle-weiterhin-moeglich: bestanden — INSERT mit A-eigener Gruppe, aber fremder Benutzerkennung (user-b, TENANT-B) ist ueber die Wartungsrolle (BYPASSRLS) weiterhin GELUNGEN — der Ausschluss aus der vorigen Pruefung kommt damit nachweislich von der Regel, nicht vom Aufbau
|
||
modulegrant-gebunden-nur-eigene-zeile: bestanden — forTenant(TENANT-A) liefert 1 Zeile(n): ["TENANT-A"]
|
||
modulegrant-fremde-gruppe-abgelehnt: bestanden — INSERT mit korrekter eigener tenantId, aber fremder groupId (group-b, TENANT-B) abgewiesen: ERROR: new row violates row-level security policy for table "ModuleGrant"
|
||
modulegrant-fremder-benutzer-abgelehnt: bestanden — INSERT mit korrekter eigener tenantId, aber fremder userId (user-b, TENANT-B) abgewiesen: ERROR: new row violates row-level security policy for table "ModuleGrant"
|
||
modulegrant-fremde-gruppe-ueber-die-wartungsrolle-weiterhin-moeglich: bestanden — INSERT mit korrekter eigener tenantId, aber fremder groupId ist ueber die Wartungsrolle (BYPASSRLS) weiterhin GELUNGEN — legt zugleich die Zeile 'grant-foreign-group' an, auf der zwei Pruefungen des Bereichs module-registry aufsetzen (Befund C)
|
||
tenderrssfeed-plattformzeile-gebunden-sichtbar: bestanden — forTenant(TENANT-A) sieht die Platform-Zeile: true, forTenant(TENANT-B) sieht sie: true
|
||
tenderrssfeed-eigene-zeile-gebunden-weiterhin-sichtbar: bestanden — forTenant(TENANT-A) liefert ["rss-a","rss-platform"] — eigene Zeile (rss-a) sichtbar: true, fremde Zeile (rss-b, TENANT-B) sichtbar: false
|
||
tenderrssfeed-ungebunden-nur-die-plattformzeile: bestanden — ungebundener SELECT auf "TenderRssFeedSource" liefert 1 Zeile(n): ["rss-platform"]
|
||
tenderrssfeed-gebundenes-einfuegen-ohne-mandant-abgelehnt: bestanden — gebundenes INSERT mit tenantId=NULL abgewiesen (tenant_insert_policy)
|
||
tenderrssfeed-gebundenes-aendern-der-plattformzeile-abgelehnt: bestanden — gebundenes UPDATE auf die plattformweite Zeile traf keine Zeile (tenant_update_policy filtert sie heraus) — url unveraendert
|
||
tenderrssfeed-gebundenes-loeschen-der-plattformzeile-abgelehnt: bestanden — gebundenes DELETE auf die plattformweite Zeile traf 0 Zeilen (tenant_delete_policy filtert sie heraus)
|
||
searchprovider-mandantenlose-zeile-bleibt-unter-jedem-kontext-unsichtbar: bestanden — forTenant(TENANT-A) sieht die mandantenlose Zeile: false, forTenant(TENANT-B) sieht sie: false — bewusst UNVERAENDERT (widerlegte Praemisse)
|
||
gruppenpfad-gebunden-schliesst-die-fremde-gruppe-aus: bestanden — forTenant(TENANT-A) liefert 'grant-foreign-group' ueber denselben Drei-Tabellen-Weg NICHT — die Zeile wurde ueber die Wartungsrolle angelegt
|
||
gruppenpfad-ueber-die-wartungsrolle-liefert-die-fremde-gruppe-mit: bestanden — dieselbe Abfrage ueber die Verwaltungsrolle (BYPASSRLS) liefert ["grant-a","grant-foreign-group"]
|
||
Alle 74 Pruefungen bestanden.
|
||
```
|
||
|
||
Regelliste aus `pg_policies` der lebenden Datenbank (Systemkatalog, nicht die
|
||
Migrationsdatei):
|
||
|
||
```
|
||
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())))))
|
||
SearchProvider#tenant_isolation_policy#ALL#("tenantId" = current_tenant_id())
|
||
TenderRssFeedSource#tenant_delete_policy#DELETE#("tenantId" = current_tenant_id())
|
||
TenderRssFeedSource#tenant_insert_policy#INSERT##WITH CHECK ("tenantId" = current_tenant_id())
|
||
TenderRssFeedSource#tenant_platform_read_policy#SELECT#(("tenantId" = current_tenant_id()) OR ("tenantId" IS NULL))
|
||
TenderRssFeedSource#tenant_update_policy#UPDATE#USING ("tenantId" = current_tenant_id())#WITH CHECK ("tenantId" = current_tenant_id())
|
||
```
|
||
|
||
### (r2) Signaltabelle — beide Fehlerrichtungen je Regel
|
||
|
||
| Regel | Zu streng (Falsch-Negativ, DoS) | Zu locker (Falsch-Positiv, Rechteausweitung) |
|
||
|---|---|---|
|
||
| `GroupMembership` | Wäre die neue Regel zu streng, würde eine tatsächlich mandanteneigene Mitgliedschaft unsichtbar — Signal: `groupmembership-folgt-join-auf-group` würde fehlschlagen (liefert heute korrekt 1 Zeile) | `groupmembership-schreiben-fremder-benutzer-abgelehnt` — Signal wäre ein GELUNGENES Einfügen einer fremden Benutzerkennung; heute abgewiesen |
|
||
| `ModuleGrant` | Eine eigene, korrekt referenzierende Freigabe würde unsichtbar — Signal: `modulegrant-gebunden-nur-eigene-zeile` würde fehlschlagen (heute 1 Zeile) | `modulegrant-fremde-gruppe-abgelehnt`/`modulegrant-fremder-benutzer-abgelehnt` — Signal wäre ein GELUNGENES Einfügen mit fremder Gruppen- bzw. Benutzerkennung; heute beide abgewiesen |
|
||
| `TenderRssFeedSource` (Lese-/Schreibsplit) | Zu streng bedeutet: die plattformweite Zeile bleibt trotz Bindung unsichtbar — Signal: `tenderrssfeed-plattformzeile-gebunden-sichtbar` würde fehlschlagen (heute sichtbar unter beiden Mandanten) ODER die eigene Zeile verschwindet — Signal: `tenderrssfeed-eigene-zeile-gebunden-weiterhin-sichtbar` würde fehlschlagen | Zu locker bedeutet: ein Mandant kann die plattformweite Zeile ändern/entfernen — Signal: `tenderrssfeed-gebundenes-aendern-der-plattformzeile-abgelehnt`/`tenderrssfeed-gebundenes-loeschen-der-plattformzeile-abgelehnt` würden fehlschlagen (heute beide: 0 betroffene Zeilen) |
|
||
|
||
### (r3) Stellen, an denen Leere weiterhin als Abwesenheit gedeutet wird
|
||
|
||
Namentliche Liste, wie in den Abschnitten (d)/(g3)/(t3)/(u3)/(m3) oben je
|
||
Bereich geführt — hier bereichsübergreifend für die drei betroffenen
|
||
Tabellen, inklusive der NEUEN Stelle aus Befund F:
|
||
|
||
- `TenderRssFeedSourceService.listForUser` (**NEU seit 260910-jab, Aufgabe
|
||
2**) — vor dieser Reparatur lieferte der ungebundene Pfad nach dem
|
||
Scharfschalten NICHTS (eine schreiende Leere, die auffällt). Nach der
|
||
Reparatur würde er UNGEBUNDEN nur die plattformweiten Zeilen liefern — eine
|
||
kurze, glaubhafte Teilantwort, die NICHT auffällt (der Nutzer sieht
|
||
plattformweite Feeds und hat keinen Anlass zu melden, dass seine eigenen
|
||
fehlen). Deshalb gebunden — die einzige Stelle, die diese Aufgabe still
|
||
falsch gemacht hätte, wenn sie nicht mitgebunden worden wäre.
|
||
- Alle bereits in (t3) genannten fünf lautlosen Stellen in den beiden
|
||
Tender-Hintergrunddiensten bleiben unverändert lautlos — von dieser
|
||
Regeländerung nicht betroffen.
|
||
- `GroupMembership`/`ModuleGrant` selbst tragen keine "Leere als Abwesenheit
|
||
gedeutet"-Stelle im hier verstandenen Sinn (eine ABGEWIESENE Schreibung ist
|
||
ein harter Fehler, keine stille Leere) — die relevante Fehlerrichtung ist
|
||
hier die Rechteausweitung (r2), nicht die stille Leere.
|
||
|
||
### (r4) Was dieser Durchlauf bewusst nicht löst
|
||
|
||
- **Der Verwaltungsweg für plattformweite Zeilen fehlt.** Unter der
|
||
Anwendungsrolle lässt sich eine plattformweite `TenderRssFeedSource`-Zeile
|
||
weder anlegen noch entfernen — in der alten wie in der neuen Regel, weil
|
||
jede Schreibregel einen Mandanten verlangt. `createPlatform`/`remove`
|
||
bleiben deshalb bewusst ungebunden. Als eigener offener Ledger-Eintrag
|
||
festgehalten (WINDOWS #24), damit dieser Rest nicht mit #19 verschwindet —
|
||
ein Verwaltungsweg (z. B. eine eigene Systemrolle oder ein expliziter
|
||
Admin-Bypass-Pfad) ist Gegenstand von Etappe 4.
|
||
- **Die fehlende Benutzerdimension der Ausschreibungsregeln.** Selbst
|
||
gemessen statt übernommen (`grep -rn "current_setting\|set_config"
|
||
apps/api/src apps/api/prisma/migrations`, 260910-jab): es existiert
|
||
GENAU EINE Sitzungsvariable, `app.current_tenant`
|
||
(`prisma-tenant.extension.ts`, `20260618112133_rls_policies`). Es gibt
|
||
KEINE zweite Sitzungsvariable für den Benutzer — Tabellen wie
|
||
`TenderSavedSearch` (siehe `tendersavedsearch-fremder-nutzer-desselben-
|
||
mandanten-sichtbar` oben) haben deshalb strukturell keine Möglichkeit,
|
||
eine Benutzerdimension auf Datenbankebene durchzusetzen, ohne eine solche
|
||
Variable erst einzuführen. Nicht gebaut in diesem Durchlauf — die
|
||
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
|
||
#22)** — unverändert, nicht Gegenstand dieses Plans.
|
||
|
||
### (r5) Was dieser Durchlauf bewusst nicht anfasst
|
||
|
||
- `SearchProvider` — die Regel bleibt wörtlich unverändert (`"tenantId" =
|
||
current_tenant_id()`), mit gemessener Begründung (widerlegte Prämisse,
|
||
siehe `docs/mandantentrennung-zugriffsklassifikation.md`).
|
||
- Die Regeln auf `Group` und `TenantModuleActivation` — beide unverändert,
|
||
nicht Teil der drei benannten Löcher.
|
||
- Der Schalter (`DATABASE_URL`, Rolle `tessera`) — bleibt aus. Die drei
|
||
Regeln sind heute wirkungslos; das Wegwerf-Werkzeug und die Regelliste der
|
||
lebenden Datenbank sind die einzigen Zeugen dafür, dass sie greifen.
|
||
|
||
## Bereich dashboard
|
||
|
||
Dieser Abschnitt erweitert die Kritikschrift um den Bereich `dashboard`
|
||
(Quick-Task 260910-krx), den achten Bereich der Etappe und den einzigen
|
||
Dienst, der ausschließlich hält, was ein Nutzer sich selbst eingerichtet
|
||
hat: die Anordnung seiner Widgets und seine eigenen Suchmaschinen. Die
|
||
umgekehrte Fehlerrichtung sieht hier nicht wie ein Fehler aus, sondern wie
|
||
ein Zurücksetzen — und sie ist in diesem Bereich beweisvernichtend, siehe
|
||
(w3).
|
||
|
||
### (w1) Die Messung
|
||
|
||
Aufgabe 1 hat `apps/api/scripts/rls-scratch-check.mjs` um einen neunten
|
||
Abschnitt (`runDashboardAreaChecks`) erweitert, unmittelbar nach
|
||
`runModuleRegistryAreaChecks` und vor `runTransactionShapeMeasurement`
|
||
aufgerufen. Er legt die beiden Wegwerf-Tabellen `DashboardLayout` und
|
||
`WidgetInstance` selbst neu an und benutzt die von
|
||
`runSearchProviderAreaChecks` bereits angelegte Tabelle `SearchProvider`
|
||
WEITER (eigene Kennungen, keine zweite Anlage) — alle drei Policies mit
|
||
`extractPolicySql()` WORTGLEICH aus der ausgelieferten Migration
|
||
`20260909140000_rls_remaining_tenant_tables` geschnitten, **gemessen am
|
||
Regelstand NACH der Migration `20260910120000_rls_widen_membership_grant_and_platform_read`**
|
||
(die drei Regeln dieses Bereichs sind von jener Migration unverändert
|
||
gelassen worden, siehe deren Abschnitt (4) — die Messung gilt trotzdem dem
|
||
aktuellen, lebenden Regelstand, nicht einem veralteten). Tatsächlich
|
||
beobachtete Ausgabe dieses Laufs (2026-09-11, gegen `tessera-ctl-db-1`,
|
||
Adresse `172.19.0.2`, nur die dreizehn neuen Zeilen dieses Abschnitts sowie
|
||
die abschließende Summenzeile):
|
||
|
||
```
|
||
dashboardlayout-gebunden-nur-eigener-mandant: bestanden — forTenant(TENANT-A) liefert 2 Zeile(n): ["layout-a1","layout-a2"]
|
||
dashboardlayout-fremder-nutzer-desselben-mandanten-gebunden-sichtbar: bestanden — forTenant(TENANT-A) liefert die Anordnung von 'user-a2' (anderer Benutzer, gleicher Mandant) mit: true — die Regel auf "DashboardLayout" kennt keine Benutzerdimension, die anwendungsseitige Pruefung ueber die Benutzerkennung bleibt deshalb der einzige Schutz gegen Quer-Lesen zwischen Nutzern DESSELBEN Mandanten
|
||
dashboardlayout-ungebunden-null-zeilen: bestanden — ungebundener SELECT auf "DashboardLayout" liefert 0 Zeile(n), tatsaechlich vorhanden sind 4
|
||
widgetinstance-ungebunden-null-zeilen: bestanden — ungebundener SELECT auf "WidgetInstance" liefert 0 Zeile(n), tatsaechlich vorhanden sind 3
|
||
widgetinstance-gebunden-nur-eigener-mandant: bestanden — forTenant(TENANT-A) liefert 2 Zeile(n): ["widget-a1","widget-a2"]
|
||
widgetinstance-fremder-nutzer-desselben-mandanten-gebunden-sichtbar: bestanden — forTenant(TENANT-A) liefert das Widget von 'user-a2' (anderer Benutzer, gleicher Mandant) mit: true — dieselbe fehlende Benutzerdimension wie bei "DashboardLayout" und "SearchProvider" (Befund G), der dritte der drei Faelle dieses Bereichs
|
||
dashboardlayout-gebundener-konfliktschreibvorgang-auf-unsichtbare-zeile-scheitert-laut: bestanden — gebundenes INSERT ... ON CONFLICT ("userId") DO UPDATE unter TENANT-A auf die unter TENANT-B physisch vorhandene, unsichtbare Zeile (user-conflict) scheitert LAUT mit SQLSTATE 42501: ERROR: new row violates row-level security policy (USING expression) for table "DashboardLayout"
|
||
widgetinstance-gebundenes-einfuegen-fremder-mandant-abgelehnt: bestanden — gebundenes INSERT unter TENANT-A mit tenantId=TENANT-B abgewiesen mit SQLSTATE 42501 (ERROR: new row violates row-level security policy for table "WidgetInstance")
|
||
widgetinstance-gebundenes-loeschen-fremder-zeile-trifft-keine-zeile: bestanden — gebundenes DELETE unter TENANT-A ueber die Kennung 'widget-b1' (gehoert TENANT-B) trifft 0 Zeile(n) — die vorgeschaltete Besitzpruefung im Anwendungscode bleibt deshalb der einzige Schutz vor dem Scharfschalten
|
||
searchprovider-ungebunden-null-zeilen: bestanden — ungebundener SELECT auf "SearchProvider" liefert 0 Zeile(n), tatsaechlich vorhanden sind 4
|
||
searchprovider-gebunden-nur-eigener-mandant: bestanden — forTenant(TENANT-A) liefert 2 Zeile(n) aus den neu hinzugefuegten: ["search-a1","search-a2"]
|
||
searchprovider-fremder-nutzer-desselben-mandanten-gebunden-sichtbar: bestanden — forTenant(TENANT-A) liefert die Suchmaschine von 'user-a2' (anderer Benutzer, gleicher Mandant) mit: true — dieselbe fehlende Benutzerdimension wie bei "DashboardLayout" und "WidgetInstance" (Befund G)
|
||
searchprovider-gebundenes-einfuegen-ohne-mandant-abgelehnt: bestanden — gebundenes INSERT unter TENANT-A mit tenantId=NULL abgewiesen mit SQLSTATE 42501 (ERROR: new row violates row-level security policy for table "SearchProvider")
|
||
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
|
||
namentlich vorgezeichnet — die dreizehnte
|
||
(`widgetinstance-fremder-nutzer-desselben-mandanten-gebunden-sichtbar`)
|
||
wurde ergänzt, weil Befund G des Plans ausdrücklich alle DREI Tabellen
|
||
dieses Bereichs als ohne Benutzerdimension benennt, der Plan aber nur für
|
||
`DashboardLayout` und `SearchProvider` einen entsprechenden Testfall
|
||
vorzeichnete. Die Gesamtzahl der Werkzeugprüfungen steigt damit von 74 auf
|
||
87 (74 + 13).
|
||
|
||
**Die tragende Belegzeile ist `dashboardlayout-ungebunden-null-zeilen`:**
|
||
der IDENTISCHE `SELECT "tenantId" FROM "DashboardLayout"` ohne vorheriges
|
||
`set_config` liefert **0 Zeilen**, nicht die 4 tatsächlich vorhandenen —
|
||
an der echten, ausgelieferten Policy gemessen. `widgetinstance-ungebunden-
|
||
null-zeilen` misst dieselbe Unsichtbarkeit für die Widget-Tabelle.
|
||
|
||
**Die Konfliktmessung (Befund K, Prüfung 5) hat ein Ergebnis, nicht eine
|
||
Vermutung.** Ein gebundenes `INSERT ... ON CONFLICT ("userId") DO UPDATE`
|
||
unter TENANT-A, das auf die unter TENANT-B physisch vorhandene, unter
|
||
TENANT-A unsichtbare Zeile trifft, scheitert LAUT mit SQLSTATE `42501`
|
||
("new row violates row-level security policy (USING expression) for table
|
||
\"DashboardLayout\""), nicht mit einem Eindeutigkeitsfehler (`23505`) und
|
||
nicht mit einem stillen Erfolg. Zusätzlich am ECHTEN, generierten Prisma
|
||
Client gemessen (nicht nur an rohem SQL), weil `saveLayout` in Wahrheit
|
||
`prisma.dashboardLayout.upsert()` aufruft, nicht `$executeRaw`: gegen eine
|
||
eigens dafür angelegte Wegwerf-Datenbank mit vollständigem Spaltensatz
|
||
(`id`, `userId`, `tenantId`, `layouts`, `createdAt`, `updatedAt`) und einer
|
||
eigenen Wegwerf-Rolle ohne `BYPASSRLS` liefert derselbe Konfliktfall über
|
||
`tenantPrisma.dashboardLayout.upsert({ where: { userId }, update, create })`
|
||
einen **`PrismaClientUnknownRequestError`** — nicht den bekannten
|
||
`PrismaClientKnownRequestError` mit `.code === 'P2002'`, den der Bereich
|
||
`tenders` für seinen Eindeutigkeitsfall abfängt. `.code` und `.meta` sind
|
||
bei diesem Fehlertyp `undefined`; die einzige verlässliche Information
|
||
steht im rohen `.message`-Text, der den PostgreSQL-Fehler eingebettet
|
||
enthält (`code: "42501"`, `message: "new row violates row-level security
|
||
policy (USING expression) for table \"DashboardLayout\""`). **Das ist die
|
||
zentrale Abweichung von der Annahme, das `tenders`-P2002-Muster ließe sich
|
||
wörtlich übernehmen** — es lässt sich nicht, weil dieser Fehler eine andere
|
||
Prisma-Fehlerklasse ist. Aufgabe 2 fängt deshalb
|
||
`Prisma.PrismaClientUnknownRequestError` ab (Prüfung auf die Fehlerklasse,
|
||
nicht auf `.code`) und übersetzt ihn in eine verständliche deutsche
|
||
Meldung — siehe (w4) für die Grenze dieser Behandlung.
|
||
|
||
**Die eigenständige Nachprüfung der widerlegten Prämisse (Befund F,
|
||
WINDOWS #19).** Nachgeprüft mit derselben Anweisung wie zur Planungszeit,
|
||
diesmal gegen den aktuellen Quelltext (2026-09-11):
|
||
`grep -rn "searchProvider\|SearchProvider" apps packages prisma
|
||
--include=*.ts --include=*.mjs --include=*.js --include=*.sql
|
||
--include=*.json` (ohne `node_modules`, `dist/`, `.next/`). Ergebnis
|
||
unverändert: der einzige Schreibweg ist
|
||
`dashboard.service.ts:241` (`addSearchProvider` → `create`) mit
|
||
`tenantId: string` als PFLICHTPARAMETER der aufrufenden Methode; keine
|
||
Seed-Datei (`apps/api/prisma/` enthält ausschließlich `migrations` und
|
||
`schema.prisma`), kein Skript, kein weiterer Schreibweg. Reichweite der
|
||
Suche, wie zur Planungszeit benannt: sie findet keinen Schreibweg über
|
||
einen dynamisch gebildeten Modellnamen und deckt keine manuelle
|
||
Datenbankänderung ab — die Aussage lautet deshalb "kein Anwendungspfad
|
||
erzeugt eine mandantenlose Zeile", nicht "es kann keine geben". Zusätzlich
|
||
datenbankseitig verteidigt: `searchprovider-gebundenes-einfuegen-ohne-
|
||
mandant-abgelehnt` weist ein gebundenes Einfügen mit `tenantId = NULL`
|
||
laut mit SQLSTATE `42501` ab, weil die Regel ohne eigene `WITH CHECK`-
|
||
Klausel ihre `USING`-Klausel dafür wiederverwendet.
|
||
|
||
### (w2) Signaltabelle je umgestelltem Pfad
|
||
|
||
| Pfad | Verhalten bei zu wenig Ergebnis | Konkretes Signal, Ort |
|
||
|---|---|---|
|
||
| `DashboardService.getLayout` | Der gebundene Lesezugriff liefert `null` statt der vorhandenen Zeile — identisch zum heutigen "noch keine Anordnung gespeichert" | Der Rückgabepunkt liefert die Vorgabeanordnung `{ lg: [], md: [], sm: [], xs: [], xxs: [] }`, Status 200, kein Fehler. Sichtbar im Browser als leeres Dashboard — siehe (w3) für die Folgekette |
|
||
| `DashboardService.saveLayout` | Der gebundene Schreibzugriff trifft im `update`-Zweig die vorhandene, aber unter dem laufenden Mandanten unsichtbare Zeile über den plattformweit eindeutigen Schlüssel `userId` — Konfliktmessung, siehe (w1) | `PUT /dashboard/layout` liefert nach Aufgabe 2 eine verständliche deutsche Konfliktmeldung statt eines rohen 500ers — siehe (w4) für die Grenze |
|
||
| `DashboardService.getWidgets`, Widget-Lesezugriff | Der gebundene Lesezugriff liefert eine leere Liste statt der platzierten Widgets | Der Nutzer sieht ein Dashboard ohne jedes Widget, Status 200, kein Fehler |
|
||
| `DashboardService.addWidget` | Der gebundene Schreibzugriff schlägt fehl bzw. legt die Zeile unter einer Mandantenkennung an, die der Sitzungsnachweis liefert — kein Leere-Fall in diese Richtung | `POST /dashboard/widgets` liefert einen Fehler statt eines neuen Widgets, falls der Mandant fehlt (`ForbiddenException` bereits im Controller) |
|
||
| `DashboardService.updateWidgetConfig`/`removeWidget`, Besitzprüfung | Die gebundene `findUnique`-Abfrage liefert `null` statt der eigenen Zeile — ununterscheidbar vom echten "gehört jemand anderem" | `NotFoundException` — dieselbe Meldung wie beim echten Besitzverstoß, siehe (w4) |
|
||
| `DashboardService.getSearchProviders` | Der gebundene Lesezugriff auf `custom` liefert eine leere Liste statt der eigenen Suchmaschinen — die drei Vorgaben aus der Konstante bleiben unberührt und werden IMMER vorangestellt | Die eigenen Suchmaschinen verschwinden aus der Auswahlliste des Such-Widgets, die Leiste funktioniert weiter — siehe (w3), Befund J |
|
||
| `DashboardService.addSearchProvider` | Kein Leere-Fall — der Schreibzugriff verlangt die Mandantenkennung als Pflichtparameter | — |
|
||
| `DashboardService.removeSearchProvider`, Besitzprüfung | Wie bei Widgets: `null` statt der eigenen Zeile | `NotFoundException` — dieselbe Meldung wie beim echten Besitzverstoß |
|
||
|
||
### (w3) Welcher Code Leere als Abwesenheit deutet
|
||
|
||
**Backend, eine Stelle:** `DashboardService.getLayout` —
|
||
`if (!record) return { lg: [], md: [], sm: [], xs: [], xxs: [] };`. Kein
|
||
Datensatz bedeutet hier nicht "Fehler", sondern "Vorgabeanordnung" — dieselbe
|
||
Deutung wie bei `getWidgets` (leere Liste) und `getSearchProviders` (nur die
|
||
drei Konstanten).
|
||
|
||
**Frontend, drei Stellen, zur Ausführungszeit an den beiden Dateien erneut
|
||
nachgeprüft (Befund I/J), nicht aus dem Plan abgeschrieben — dieser Plan
|
||
ändert an KEINER der beiden Dateien etwas:**
|
||
|
||
1. `apps/web/src/lib/stores/dashboard-store.ts`, `loadDashboard`: setzt
|
||
`layouts` und `widgets` genau auf das, was `api.fetchLayout()` und
|
||
`api.fetchWidgets()` liefern. Der `catch`-Zweig
|
||
(`error: 'Failed to load dashboard'`) feuert nur bei einem Netzwerk-
|
||
oder Statusfehler — eine erfolgreiche, leere Antwort setzt keinen
|
||
Fehlerzustand.
|
||
2. `apps/web/src/lib/stores/dashboard-store.ts`, `setEditMode`:
|
||
`if (prev && !mode && get().isDirty) { get().saveLayout(); }` — der
|
||
Neuaufbau wird beim bloßen VERLASSEN des Bearbeitungsmodus automatisch
|
||
zurückgeschrieben, ohne dass jemand auf "Speichern" klickt.
|
||
3. `apps/web/src/components/dashboard/widgets/search-widget.tsx`: die
|
||
Rückfallprüfung `if (!cancelled && data.length > 0)` greift NIE, weil
|
||
`getSearchProviders` die drei Vorgaben immer voranstellt — `data` ist
|
||
nie leer, selbst wenn `custom` (die eigenen Suchmaschinen) nach dem
|
||
Scharfschalten leer geblieben wäre. Der `.catch()`-Rückfallzweig auf
|
||
`DEFAULT_PROVIDERS` feuert deshalb ebenfalls nie in diesem Fall.
|
||
|
||
**Die beweisvernichtende Schleife, als Schleife beschrieben (Befund I):**
|
||
leeres Dashboard (Stelle 1) → der Nutzer hält das für einen Fehler des
|
||
Widget-Systems oder für verlorene Einstellungen, baut seine Anordnung neu
|
||
auf, `addWidget` legt echte neue `WidgetInstance`-Zeilen an (keine
|
||
Eindeutigkeitsbedingung über `(userId, widgetType)`, Dubletten häufen sich
|
||
also bei wiederholtem Neuaufbau an) → beim Verlassen des Bearbeitungsmodus
|
||
schreibt Stelle 2 den Neuaufbau AUTOMATISCH zurück, ohne dass der Nutzer
|
||
"Speichern" geklickt hat → die `layouts`-Spalte der ursprünglichen Zeile
|
||
ist überschrieben, die einzige Aufzeichnung der ursprünglichen Anordnung
|
||
ist verloren, bevor irgendjemand die Ursache untersuchen konnte. Der
|
||
Nutzer hat dabei eine fertige, FALSCHE Erklärung zur Hand ("das
|
||
Widget-System spinnt", "meine Einstellungen sind weg") und meldet deshalb
|
||
keinen Fehler — derselbe Mechanismus, der auch in `module-registry` (m3)
|
||
und `ldap` beschrieben ist, hier aber mit einer zusätzlichen, aktiven
|
||
Zerstörungshandlung (das automatische Zurückschreiben), die es in keinem
|
||
der beiden anderen Bereiche gibt.
|
||
|
||
**Das Verhalten der Suchleiste (Befund J), präzise statt "still"
|
||
formuliert:** verschwinden die eigenen Suchmaschinen des Nutzers aus
|
||
`custom`, bleibt die Auswahlliste (`<select>`) dennoch gefüllt — mit den
|
||
drei Vorgaben. Sichtbar wird das nur, wenn der Nutzer VORHER eine eigene
|
||
Suchmaschine ausgewählt hatte: React setzt `value={selectedProviderId}`
|
||
auf dem `<select>`, aber `selectedProviderId` referenziert nach dem
|
||
Verschwinden keinen vorhandenen `<option>`-Wert mehr — der Browser zeigt in
|
||
diesem Fall (kein `<option>` mit passendem `value`) den ERSTEN Eintrag der
|
||
Liste an, also "Google", OHNE dass der interne React-State
|
||
`selectedProviderId` sich ändert oder irgendeine Meldung erscheint. Klickt
|
||
der Nutzer danach Suchen, greift `handleSearch`:
|
||
`providers.find((p) => p.id === selectedProviderId) ?? providers[0]` — der
|
||
`find` schlägt fehl (die eigene Suchmaschine ist nicht mehr in `providers`),
|
||
der Rückfall auf `providers[0]` (Google) greift, und eine Anfrage, die für
|
||
ein internes Werkzeug gedacht war, geht an eine externe Suchmaschine. Für
|
||
einen Nutzer, der die Anzeige "Google" im Dropdown nicht bewusst als
|
||
Abweichung von seiner eigenen Auswahl liest, bleibt das unbemerkt.
|
||
|
||
### (w4) Was dieser Durchlauf bewusst nicht löst
|
||
|
||
- **Die plattformweite Eindeutigkeit von `DashboardLayout.userId`
|
||
(Befund K).** `userId` trägt `@unique` ohne Mandantenanteil — strukturell
|
||
dieselbe Kette wie WINDOWS #22 im Bereich `user`: unsichtbare Zeile,
|
||
falsches "frei", harter Eindeutigkeitsfehler. Anders als bei #22 ist der
|
||
Fehler hier aber GEMESSEN statt angenommen (siehe (w1), Prüfung 5) und
|
||
wird in Aufgabe 2 BEDINGT auf das gemessene Ergebnis behandelt: eine
|
||
verständliche deutsche Meldung statt eines rohen 500ers, indem
|
||
`Prisma.PrismaClientUnknownRequestError` abgefangen wird — NICHT
|
||
`.code === 'P2002'` wie im Bereich `tenders`, weil der gemessene Fehler
|
||
eine andere Prisma-Fehlerklasse ist. Die ehrliche Reparatur wäre eine
|
||
Schemaänderung (Eindeutigkeit mit Mandantendimension) und ist als
|
||
Produktentscheidung für Etappe 3 vorgemerkt, wie #22 es für denselben
|
||
Fall bereits tut.
|
||
- **Die fehlende Unterscheidbarkeit von "noch keine Anordnung gespeichert"
|
||
und "Anordnung nicht sichtbar".** Beide liefern identisch die
|
||
Vorgabeanordnung, Status 200, keinen Protokolleintrag. Die konkrete
|
||
Vorabprüfung für Etappe 4 (`rls-preflight.mjs`): eine physisch
|
||
vorhandene `DashboardLayout`-Zeile für einen bekannten Benutzer, aber der
|
||
gebundene Lesezugriff für dessen Mandanten liefert `null` — das
|
||
unterscheidet den echten Erstbenutzer-Fall (keine Zeile vorhanden) vom
|
||
Trennungsfehler (Zeile vorhanden, aber unsichtbar).
|
||
- **Eine Laufzeitwarnung an `getLayout`/`getWidgets`/`getSearchProviders`
|
||
wurde erwogen und VERWORFEN**, mit derselben Begründung wie bei
|
||
`getAllActiveConfigs` im Bereich `ldap`: eine leere Anordnung, eine leere
|
||
Widget-Liste oder keine eigene Suchmaschine sind auf einer frischen
|
||
Installation oder für einen neuen Benutzer der NORMALZUSTAND — eine
|
||
Warnung an dieser Stelle wäre Dauerlärm und verlöre ihr Signal, bevor sie
|
||
gebraucht wird. Das gilt hier NOCH ausgeprägter als in `ldap`, weil
|
||
praktisch jeder neue Benutzer diesen Zustand beim ersten Login durchläuft.
|
||
- Der offene Ledger-Eintrag zur beweisvernichtenden Schleife (angelegt in
|
||
Aufgabe 3, siehe `.planning/WINDOWS.md`) ist an dieselbe Bedingung
|
||
gebunden wie #18 — er wird erst
|
||
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
|
||
|
||
- **Das Frontend** — geprüft (Befund I/J, (w3) oben) und bewusst gelassen,
|
||
keine Datei dieses Plans. `dashboard-store.ts` und `search-widget.tsx`
|
||
werden NICHT geändert.
|
||
- **Der Bereich `favorites`.** `FavoriteLink` hängt per Fremdschlüssel an
|
||
`WidgetInstance` (`apps/api/prisma/schema.prisma`,
|
||
`favoriteLinks FavoriteLink[]` auf `WidgetInstance`), ist aber ein
|
||
eigener Bereich mit eigener Umstellung (`apps/api/src/favorites/
|
||
favorites.service.ts`, ungebunden, sieben Rohtreffer laut
|
||
Klassifikationsdokument) — nicht Teil dieses Plans.
|
||
- **Der Bereich `module-registry`** samt der geerbten Entlastung aus
|
||
Befund E: `dashboard.service.ts` ruft
|
||
`ModuleAccessService.getAccessibleModuleIds` für den Widget-Modulfilter
|
||
auf, und diese Auflösung bindet bereits seit 260910-exd
|
||
(`const tenantPrisma = forTenant(this.prisma, tenantId)` in
|
||
`module-access.service.ts`). Der Filter ist damit gebunden, OHNE dass
|
||
dieser Plan eine Datei unter `apps/api/src/module-registry` anfasst —
|
||
nachgeprüft mit `grep -n "const tenantPrisma = forTenant" apps/api/src/
|
||
module-registry/module-access.service.ts`, ein Treffer. Der eine
|
||
Katalogzugriff in `dashboard.service.ts` (`getWidgets`, `this.prisma.
|
||
module`) bleibt bewusst ungebunden — siehe die Begründung in der
|
||
Klassifikationstabelle, die Messung und Bedingung trennt.
|
||
- **Die Ungenauigkeit in `apps/api/src/groups/groups.service.ts` (Befund
|
||
L), zur Ausführungszeit gegengelesen und WÖRTLICH bestätigt, NICHT
|
||
behoben.** Der Kopfkommentar um Zeile 24 begründet die dortige
|
||
Join-Filterung mit "nach dem Ownership-Check-Muster aus
|
||
DashboardService.removeWidget". Das trifft in zwei Punkten nicht zu:
|
||
`removeWidget` filtert NICHT zusätzlich in der Datenbankabfrage, sondern
|
||
vergleicht NACH dem Laden im JavaScript (`widget.userId !== userId`),
|
||
und dieser Vergleich läuft über die BENUTZER-, nicht die
|
||
Mandantenkennung. Die Datei `apps/api/src/groups/groups.service.ts`
|
||
wird von diesem Plan NICHT angefasst — diese Feststellung steht hier als
|
||
Richtigstellung, nicht als Änderung an der fremden Datei.
|
||
- Schema und Migrationen — geprüft und bewusst gelassen, keine
|
||
Schemaänderung in dieser Etappe.
|
||
|
||
## Bereich calendar
|
||
|
||
Dieser Abschnitt erweitert die Kritikschrift um den Bereich `calendar`
|
||
(Quick-Task 260911-cwh), den neunten Bereich der Etappe und den einzigen
|
||
Dienst, der nicht bloß Daten hält, sondern ZUGANGSDATEN zu fremden Servern —
|
||
die verschlüsselten Exchange-, CalDAV- und ICS-Anmeldungen eines Nutzers.
|
||
Ein Quer-Lesen ist hier nicht Offenlegung eines Termins, sondern Offenlegung
|
||
der Anmeldung einer anderen Firma bei ihrem Mailserver. Die umgekehrte
|
||
Fehlerrichtung sieht hier nicht wie ein Fehler aus, sondern wie ein leerer
|
||
Kalender — und das Frontend verstärkt das, siehe (k3).
|
||
|
||
### (k1) Die Messung
|
||
|
||
Aufgabe 1 hat `apps/api/scripts/rls-scratch-check.mjs` um einen elften
|
||
Abschnitt (`runCalendarAreaChecks`) erweitert, unmittelbar nach
|
||
`runDashboardAreaChecks` und vor `runTransactionShapeMeasurement` aufgerufen.
|
||
Er legt die Wegwerf-Tabelle `CalendarSource` selbst neu an, mit SÄMTLICHEN
|
||
17 Spalten des Modells (nicht nur den für Roh-SQL nötigen — die Lehre aus
|
||
Prüfung 5b im Bereich `dashboard`: der generierte Client wählt standardmäßig
|
||
JEDE Spalte des Modells aus und scheitert mit P2022 an jeder fehlenden), mit
|
||
der Regel `extractPolicySql()` WORTGLEICH aus der ausgelieferten Migration
|
||
`20260909140000_rls_remaining_tenant_tables` geschnitten, **gemessen am
|
||
Regelstand NACH der Migration
|
||
`20260910120000_rls_widen_membership_grant_and_platform_read`**. Diese
|
||
zweite Migration wird zur LAUFZEIT geprüft (Prüfung
|
||
`calendarsource-regelstand-eindeutig`), nicht nur zur Planungszeit behauptet:
|
||
`extractPolicySql(readRlsWidenMigrationSql(), 'CalendarSource')` liefert
|
||
`null` — jene Migration trägt KEINE eigene Regel für `CalendarSource`, der
|
||
Stand aus `20260909140000` ist weiterhin der ausgelieferte, aktuelle
|
||
Regelstand. Fände sich dort doch eine Regel, bräche der Abschnitt mit einer
|
||
FEHLGESCHLAGENEN Prüfung ab, statt die abgelöste Regel weiterzumessen — die
|
||
Messfalle aus 260910-jab.
|
||
|
||
Vier der dreizehn neuen Prüfungen laufen über den GENERIERTEN Prisma-Client
|
||
(nicht nur an rohem SQL), weil `calendar.service.ts` in Wahrheit
|
||
`this.prisma.calendarSource.findMany/update/create()` aufruft, nicht
|
||
`$executeRaw` — dieselbe dashboard-Lehre: Prüfung 8
|
||
(`calendarsource-wegwerftabelle-deckt-alle-spalten-des-generierten-clients`)
|
||
vergleicht die zur Laufzeit aus `schema.prisma` gelesenen 17 Feldnamen des
|
||
Modells `CalendarSource` mit den tatsächlichen Spalten der Wegwerf-Tabelle
|
||
über `information_schema.columns` — sie steht VOR den Client-Prüfungen 9-12
|
||
und bricht den Abschnitt ab, wenn sie durchfällt, weil alle folgenden
|
||
Client-Messungen sonst wertlos wären.
|
||
|
||
Tatsächlich beobachtete Ausgabe dieses Laufs (2026-09-11, gegen
|
||
`tessera-ctl-db-1`, Adresse `172.19.0.2`, nur die dreizehn neuen Zeilen
|
||
dieses Abschnitts sowie die abschließende Summenzeile):
|
||
|
||
```
|
||
calendarsource-regelstand-eindeutig: bestanden — die *_rls_widen_membership_grant_and_platform_read-Migration (260910-jab) enthaelt KEINE eigene Regel fuer "CalendarSource" (Befund G) — der Stand aus 20260909140000_rls_remaining_tenant_tables ist weiterhin der ausgelieferte, aktuelle Regelstand
|
||
calendarsource-wegwerftabelle-deckt-alle-spalten-des-generierten-clients: bestanden — Schema-Felder aus schema.prisma (model CalendarSource, 17): ["color","createdAt","domain","encryptedPassword","exchangeMode","id","isVisible","lastSyncAt","lastSyncError","name","syncIntervalMin","tenantId","type","updatedAt","url","userId","username"]; Spalten der Wegwerf-Tabelle (17): ["color","createdAt","domain","encryptedPassword","exchangeMode","id","isVisible","lastSyncAt","lastSyncError","name","syncIntervalMin","tenantId","type","updatedAt","url","userId","username"]
|
||
calendarsource-gebunden-nur-eigener-mandant: bestanden — forTenant(TENANT-A) liefert 2 Zeile(n): ["source-a1","source-a2"]
|
||
calendarsource-fremder-nutzer-desselben-mandanten-gebunden-sichtbar: bestanden — forTenant(TENANT-A) liefert die Quelle von 'user-a2' (anderer Benutzer, gleicher Mandant) mit encryptedPassword="enc(a2-passwort-platzhalter)" — die Regel auf "CalendarSource" kennt keine Benutzerdimension, die verschluesselten Zugangsdaten eines Kollegen DESSELBEN Mandanten sind auf Datenbankebene lesbar; die anwendungsseitige Filterung ueber die Benutzerkennung bleibt deshalb der einzige Schutz, bis die Etappe-3-Entscheidung (2) die Benutzerdimension nachzieht
|
||
calendarsource-ungebunden-null-zeilen: bestanden — ungebundener SELECT auf "CalendarSource" liefert 0 Zeile(n), tatsaechlich vorhanden sind 3
|
||
calendarsource-ungebundene-einzelabfrage-ueber-kennung-liefert-keine-zeile: bestanden — ungebundenes SELECT ueber die Kennung 'source-b1' (vorhanden) liefert 0 Zeile(n) — die Datenbankseite der drei Besitzpruefungen: ein ungebundenes Nachschlagen ueber eine vorhandene Kennung liefert null Zeilen, das ist der Weg in NotFoundException
|
||
calendarsource-gebundenes-einfuegen-fremder-mandant-abgelehnt: bestanden — gebundenes INSERT unter TENANT-A mit tenantId=TENANT-B abgewiesen mit SQLSTATE 42501 (Invalid `prisma.$executeRaw()` invocation: Raw query failed. Code: `42501`. Message: `ERROR: new row violates row-level security policy for table "CalendarSource"`)
|
||
calendarsource-gebundenes-loeschen-fremder-zeile-trifft-keine-zeile: bestanden — gebundenes DELETE unter TENANT-A ueber die Kennung 'source-b1' (gehoert TENANT-B) trifft 0 Zeile(n); ueber die Wartungsrolle ist die Zeile danach noch vorhanden: true
|
||
calendarsource-gebundenes-update-ueber-kennung-allein-trifft-null-zeilen: bestanden — gebundenes UPDATE ... WHERE id = 'source-b1' (gehoert TENANT-B) unter TENANT-A trifft 0 Zeile(n), lastSyncError bleibt unveraendert
|
||
calendarsource-generierter-client-gebundene-quellenliste-nur-eigener-mandant: bestanden — bound.calendarSource.findMany({ where: { userId: 'user-a1', isVisible: true } }) unter TENANT-A liefert 1 Zeile(n): ["source-a1"] — die Abfrage, die fetchAndCacheEvents stellt
|
||
calendarsource-generierter-client-ungebundene-quellenliste-null-zeilen: bestanden — dieselbe Abfrage auf dem UNGEBUNDENEN generierten Client liefert 0 Zeile(n) ohne Fehler — exakt der Wert, den getSources als "keine Quelle" und fetchAndCacheEvents als "keine Termine" weiterreicht
|
||
calendarsource-generierter-client-gebundenes-update-auf-unsichtbare-zeile-scheitert-laut: bestanden — bound.calendarSource.update unter TENANT-A auf die unter TENANT-B unsichtbare Zeile (source-b1) wirft PrismaClientKnownRequestError (code P2025): Invalid `prisma.calendarSource.update()` invocation: An operation failed because it depends on one or more records that were required but not found. No record was found for an update.
|
||
calendarsource-generierter-client-gebundenes-anlegen-eigener-mandant-gelingt: bestanden — bound.calendarSource.create unter TENANT-A gelingt (id=62b383e2-4768-48f2-997a-cc5251174f8b, createdAt="2026-09-11T07:44:53.869Z"), gebunden lesbar: true — bestaetigt nebenbei, dass die Wegwerf-Tabelle die clientseitig erzeugten Werte (id, createdAt, updatedAt) annimmt
|
||
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
|
||
des Plans namentlich vorgezeichnet — die dreizehnte
|
||
(`calendarsource-regelstand-eindeutig`) wurde ergänzt, weil sie die
|
||
Messfalle aus 260910-jab zur LAUFZEIT prüft statt sie nur als Planungsprosa
|
||
festzuhalten, derselbe Grund, aus dem der Bereich `dashboard` eine
|
||
dreizehnte Prüfung ergänzt hatte.
|
||
|
||
**Die tragende Belegzeile ist `calendarsource-ungebunden-null-zeilen`:** der
|
||
IDENTISCHE `SELECT "tenantId" FROM "CalendarSource"` ohne vorheriges
|
||
`set_config` liefert **0 Zeilen**, nicht die 3 tatsächlich vorhandenen — an
|
||
der echten, ausgelieferten Policy gemessen.
|
||
|
||
**Das Wettlauf-Ergebnis aus Befund H/K ist gemessen, nicht vorweggenommen —
|
||
und es ist eine ANDERE Fehlerklasse als im Bereich `dashboard`.** Ein
|
||
gebundenes `bound.calendarSource.update({ where: { id: 'source-b1' }, ... })`
|
||
unter TENANT-A auf die unter TENANT-B unsichtbare Zeile wirft eine
|
||
**`PrismaClientKnownRequestError` mit `.code === 'P2025'`** ("Record to
|
||
update not found") — NICHT die `PrismaClientUnknownRequestError`, die
|
||
`dashboardLayout.upsert()` im Bereich `dashboard` bei genau diesem
|
||
Wettlauf-Fall geworfen hat. Der Unterschied erklärt sich aus der Form des
|
||
Zugriffs: `dashboardLayout.upsert()` löst ein `INSERT ... ON CONFLICT`
|
||
aus, das die RLS-`USING`-Klausel beim `INSERT`-Zweig verletzt (SQLSTATE
|
||
`42501`, von Prisma als unbekannter Fehler durchgereicht); ein einfaches
|
||
`calendarSource.update({ where: { id } })` ohne Konfliktbehandlung sieht
|
||
unter der gebundenen Regel schlicht KEINE passende Zeile — dasselbe
|
||
Verhalten wie ein `UPDATE` über eine nicht existierende Kennung, das Prisma
|
||
grundsätzlich als P2025 meldet. **Das bedeutet für Aufgabe 2: keine der drei
|
||
Besitzprüfungsschreibpfade (`updateSource`/`deleteSource`/`testConnection`)
|
||
braucht eine neue Fehlerübersetzung** — P2025 wäre ohnehin nicht der Pfad,
|
||
über den ein Nutzer diese Zeile erreicht (die vorgeschaltete
|
||
`findUnique`-Besitzprüfung fängt den Fall vorher über `NotFoundException`
|
||
ab), sondern ausschließlich der Wettlauf-Fall der Aggregationsschleife
|
||
(Befund K, siehe (k2)).
|
||
|
||
### (k2) Signaltabelle je umgestelltem Pfad
|
||
|
||
| Pfad | Verhalten bei zu wenig Ergebnis | Konkretes Signal, Ort | Frontend lässt Signal durch? |
|
||
|---|---|---|---|
|
||
| `CalendarService.getSources` | Der gebundene Lesezugriff liefert eine leere Liste statt der vorhandenen Quellen | `GET /calendar/sources` liefert `[]`, Status 200, kein Fehler | Nein — `calendar-widget.tsx` zeigt `emptyNoSources` ("keine Quelle eingerichtet"), `calendar-settings-panel.tsx` zeigt `sourceEmpty`; beide ununterscheidbar vom echten Erstbenutzer-Zustand |
|
||
| `CalendarService.addSource` | Kein Leere-Fall in diese Richtung — die Mandantenkennung ist Pflichtparameter | Schlägt ohne Mandant im Controller bereits mit `ForbiddenException` fehl | — |
|
||
| `CalendarService.updateSource`, Besitzprüfung | Die gebundene `findUnique`-Abfrage liefert `null` statt der eigenen Zeile — ununterscheidbar vom echten "gehört jemand anderem" | `NotFoundException('Calendar source not found')` — dieselbe Meldung wie beim echten Besitzverstoß, siehe (k4)(d) für 403/404 | `calendar-settings-panel.tsx` fängt Schreibfehler bei `handleVisibilityToggle` (`catch { revert }`) bzw. beim Formular (`saveError`/`editSaveError`) — sichtbar als Fehlermeldung, NICHT als leerer Zustand |
|
||
| `CalendarService.deleteSource`, Besitzprüfung | Wie bei `updateSource`: `null` statt der eigenen Zeile | `NotFoundException` — dieselbe Meldung wie beim echten Besitzverstoß | Wie oben — Löschfehler werden sichtbar gemeldet, nicht verschluckt |
|
||
| `CalendarService.testConnection`, Besitzprüfung | Wie oben; zusätzlich der Wettlauf-Fall der beiden Rückschreibungen bei Erfolg/Fehler (Befund K) | `NotFoundException` bei fehlender/fremder Zeile; ein Wettlauf zwischen Nachschlagen und Rückschreiben würfe gemessen `PrismaClientKnownRequestError` (P2025, siehe (k1)) — heute strukturell ausgeschlossen, weil derselbe `tenantPrisma`-Klient beide Schritte trägt | `testResults`-State zeigt `'error'` — sichtbar, nicht verschluckt |
|
||
| `CalendarService.fetchAndCacheEvents` | Der gebundene Lesezugriff auf die Quellenliste liefert eine leere Liste statt der sichtbaren Quellen — `if (sources.length === 0) return [];` kehrt VOR dem Cache-Eintrag zurück, ein leeres Ergebnis wird also NIE zwischengespeichert, jeder Aufruf misst neu leer. Der Wettlauf-Fall der beiden Synchronstatus-Rückschreibungen (Befund K) ist gemessen: `PrismaClientKnownRequestError` (P2025) — strukturell ausgeschlossen durch EINEN `tenantPrisma`-Klient je Aufruf | `GET /calendar/events` liefert `[]`, Status 200, kein Fehler, keine Protokollzeile | Nein — `calendar-widget.tsx` zeigt bei leerem `events` (aber `hasSources === true`) `emptyNoEvents` ("keine Termine"); ein LAUTER Fehler von `fetchEvents()` ODER `fetchSources()` landet im selben `catch` und setzt ebenfalls `events = []` — die Unterscheidung zwischen "leer" und "Fehler" existiert im State nicht |
|
||
| `CalendarService.refreshCacheInBackground` | Ruft `fetchAndCacheEvents` mit der Mandantenkennung der urspünglichen Anfrage auf (kein eigener Kontext, siehe Befund B) — dasselbe Leere-Verhalten wie oben, zusätzlich verschluckt durch `.catch((error) => this.logger.warn(...))`: selbst ein LAUTER Fehler der Hintergrundauffrischung erzeugt nur eine Protokollzeile, kein Signal an den Aufrufer, der die Antwort bereits erhalten hat | Kein HTTP-Signal — die auslösende Anfrage ist bereits beantwortet, bevor die Hintergrundauffrischung beginnt | Kann das Frontend strukturell nicht erreichen — die Anfrage, die es ausgelöst hat, ist längst beantwortet |
|
||
|
||
### (k3) Welcher Code Leere als Abwesenheit deutet
|
||
|
||
**Backend, zwei Stellen (Befund I):** `CalendarService.getSources` liefert
|
||
bei null Treffern eine leere Liste, Status 200 — dieselbe Deutung wie überall
|
||
in dieser Etappe. `CalendarService.fetchAndCacheEvents`,
|
||
`if (sources.length === 0) return [];`: kehrt VOR dem Cache-Eintrag zurück,
|
||
ein leeres Quellenergebnis wird also nicht einmal für die TTL festgehalten,
|
||
sondern bei JEDEM Aufruf neu leer gemessen — anders als ein echter
|
||
Cache-Treffer, der fünf Minuten stehen bleibt. Die drei
|
||
Besitzprüfungspfade (`updateSource`, `deleteSource`, `testConnection`) sind
|
||
dagegen LAUT: ein zu kleines Nachschlagen wirft `NotFoundException`.
|
||
|
||
**Frontend, drei Dateien, zur Ausführungszeit erneut nachgeprüft (Befund
|
||
J), nicht aus dem Plan abgeschrieben — dieser Plan ändert an KEINER der drei
|
||
Dateien etwas:**
|
||
|
||
1. `apps/web/src/components/dashboard/widgets/calendar-widget.tsx`, Zeile
|
||
37: `if (sources.length === 0)` setzt `hasSources = false`, gerendert als
|
||
`emptyNoSources` ("keine Quelle eingerichtet"). Zeile 50:
|
||
`catch { // Silent fail — show empty state }` fängt jeden Fehler von
|
||
`fetchSources()` ODER `fetchEvents()` — bei einem Fehler bleibt
|
||
`hasSources` jedoch beim vorherigen Wert (nicht `false`) und `events`
|
||
wird auf `[]` gesetzt, wodurch die Render-Logik in den Zweig
|
||
`events.length === 0` fällt und `emptyNoEvents` ("keine Termine")
|
||
zeigt — NICHT `emptyNoSources`. Ein LAUTER Fehler (403, 500,
|
||
Netzwerkfehler) auf `GET /calendar/sources` oder `GET /calendar/events`
|
||
ist damit für den Nutzer vom echten "Quellen vorhanden, aber gerade
|
||
keine Termine" nicht zu unterscheiden — beide zeigen `emptyNoEvents`.
|
||
2. `apps/web/src/components/settings/calendar-settings-panel.tsx`, Zeile
|
||
49: `.catch(() => { // Silent fail — show empty state })` — hier bleibt
|
||
`sources` beim initialen `[]`, unabhängig davon, ob `fetchSources()` eine
|
||
echte leere Antwort ODER einen LAUTEN Fehler liefert; Zeile 127:
|
||
`sources.length === 0` zeigt `sourceEmpty`. Auf dieser Seite sind "keine
|
||
Quelle" und "Fehler beim Laden" damit VOLLSTÄNDIG ununterscheidbar — eine
|
||
schärfere Form derselben Verschluckung als im Widget.
|
||
3. `apps/web/src/components/settings/calendar-source-form.tsx`, Zeile 149:
|
||
`if (password) payload.password = password;` — nicht Leere, sondern
|
||
Weglassen; gehört hierhin, weil es die Erhaltungsfrage beantwortet (siehe
|
||
(k4)(b)): ein leer gelassenes Passwortfeld wird aus dem Sendeobjekt
|
||
WEGGELASSEN, nicht als leere Zeichenkette übertragen.
|
||
|
||
**Die Folgekette, abgegrenzt gegen `dashboard` (Befund J):** leerer Kalender
|
||
oder leere Quellenliste → der Nutzer liest das als "die Synchronisation ist
|
||
kaputt" oder "ich habe keine Quelle eingerichtet" → er legt seine Quelle
|
||
NEU an (`addSource`, gebunden, gelingt) → er tippt sein Exchange- oder
|
||
CalDAV-Passwort ein ZWEITES MAL in ein System, das gerade aussieht, als wäre
|
||
es defekt → die ursprüngliche Zeile bleibt unsichtbar liegen. Anders als bei
|
||
`dashboard` wird dabei NICHTS überschrieben (`CalendarSource` hat keinen
|
||
automatischen Rückschreibpfad wie `setEditMode` im Dashboard) — die
|
||
Zerstörung ist hier keine verlorene Aufzeichnung, sondern eine preisgegebene
|
||
Anmeldung: der Nutzer gibt Zugangsdaten zu einem fremden Mailserver in ein
|
||
System ein, das er für kaputt hält, und sobald die Ursache behoben ist
|
||
(Etappe 4), liegen ZWEI Quellen mit ZWEI Sätzen von Zugangsdaten für
|
||
denselben Server vor — Dubletten bei Quellen UND bei Terminen.
|
||
|
||
**Die Fehlerverschluckung als eigener Punkt:** selbst ein LAUTER
|
||
Backend-Fehler auf `GET /calendar/sources` oder `GET /calendar/events` (403,
|
||
500, Netzwerkfehler) ist für den Nutzer vom leeren Kalender nicht zu
|
||
unterscheiden — das Signal existiert ausschließlich im Netzwerkprotokoll des
|
||
Browsers und im API-Log, an keiner Stelle in der Benutzeroberfläche.
|
||
|
||
### (k4) Was dieser Durchlauf bewusst nicht löst
|
||
|
||
- **(a) Das Urteil zum Cache-Schlüssel**, vierteilig belegt, jedes Glied
|
||
einzeln nachgesehen: `eventCache` (Zeile 109 alt) wird mit
|
||
`${userId}:${from}:${to}` beschlüsselt. `userId` kommt aus
|
||
`calendar.controller.ts` `extractContext` (`req.user?.id`); das ist laut
|
||
`apps/api/src/auth/strategies/jwt.strategy.ts` `validate` (Zeile 27-34)
|
||
wörtlich `id: payload.sub`; `payload.sub` ist laut
|
||
`apps/api/src/auth/auth.service.ts` (Zeilen 143 und 332, beide
|
||
`sub: user.id`) die Datenbankkennung `User.id`; die trägt laut
|
||
`apps/api/prisma/schema.prisma` `model User` `@id @default(uuid())`.
|
||
**Urteil:** der Schlüssel trägt eine plattformweit eindeutige UUID, kein
|
||
Anmeldename — die Etappe-3-Entscheidung (1) des Users (Anmeldenamen
|
||
eindeutig PRO MANDANT statt plattformweit) betrifft `User.username` und
|
||
`User.email`, nicht `User.id`, und berührt den Schlüssel deshalb NICHT.
|
||
Der Schlüssel bleibt unverändert; das Urteil steht zusätzlich als
|
||
Kommentar unmittelbar über der `eventCache`-Zuweisung in
|
||
`calendar.service.ts` (Aufgabe 2).
|
||
- **(b) Der Befund zur Zugangsdaten-Erhaltung (Befund E)**, an vier Stellen
|
||
zur Ausführungszeit nachgelesen: `calendar.service.ts` `updateSource`
|
||
besitzt KEINEN Lesezugriff, der ein gespeichertes Passwort lädt, um es neu
|
||
zu verschlüsseln — die `dkv`-Form (lesen, entschlüsseln, neu
|
||
verschlüsseln) existiert hier nicht. Stattdessen: `if (dto.password !== undefined)`
|
||
entscheidet, ob überhaupt geschrieben wird — Feld FEHLT im Rumpf →
|
||
`encryptedPassword` bleibt im `update`-Aufruf gänzlich unerwähnt und damit
|
||
in der Datenbank unverändert; Feld LEER (`''`) → wird explizit auf `null`
|
||
gesetzt (Löschen); Feld GESETZT → wird verschlüsselt. Auf der Web-Seite
|
||
(`calendar-source-form.tsx`, Zeile 149) wird ein leer gelassenes
|
||
Passwortfeld WEGGELASSEN, nicht als leere Zeichenkette gesendet — die
|
||
Erhaltung läuft also über das Weglassen des Felds im Rumpf, nicht über
|
||
einen Lesezugriff, der nach dem Scharfschalten leerlaufen könnte. Die
|
||
beiden lesenden Stellen (`testConnection`, `fetchAndCacheEvents`)
|
||
entschlüsseln nur, um den Provider aufzurufen, und schreiben nichts
|
||
Entschlüsseltes zurück. Als drei Testfälle in Aufgabe 2 festgenagelt.
|
||
- **(c) Die fehlende Benutzerdimension der Regel (Befund G)**, gemessen in
|
||
(k1) (`calendarsource-fremder-nutzer-desselben-mandanten-gebunden-sichtbar`):
|
||
die verschlüsselten Exchange-/CalDAV-Zugangsdaten eines Kollegen
|
||
DESSELBEN Mandanten sind auf Datenbankebene lesbar, bis die
|
||
Etappe-3-Entscheidung (2) des Users die Benutzerdimension in die Regel
|
||
aufnimmt (`CalendarSource` steht dort ausdrücklich in der Liste). Bis
|
||
dahin bleiben der `userId`-Filter in `getSources`/`fetchAndCacheEvents`
|
||
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):**
|
||
`updateSource`, `deleteSource` und `testConnection` werfen bei fremdem
|
||
Besitz `ForbiddenException('Not your calendar source')` (403), bei
|
||
unbekannter Kennung `NotFoundException('Calendar source not found')`
|
||
(404) — ein Kollege DESSELBEN Mandanten erfährt über 403 die Existenz
|
||
einer fremden Quellenkennung, ein Nutzer eines FREMDEN Mandanten bekommt
|
||
nach der Bindung durchgängig 404 (die Zeile ist für ihn unsichtbar).
|
||
Kennungen sind UUIDs, nicht erratbar. Die Antwortsemantik wird von diesem
|
||
Plan NICHT geändert (wäre eine API-Änderung außerhalb des Auftrags).
|
||
- **(e) Die fehlende Unterscheidbarkeit von "keine Quelle" und "Quelle
|
||
nicht sichtbar".** Beide liefern identisch eine leere Liste, Status 200,
|
||
keinen Protokolleintrag — siehe (k3). Die konkrete Vorabprüfung für
|
||
Etappe 4 (`rls-preflight.mjs`): physisch vorhandene
|
||
`CalendarSource`-Zeilen je Mandant über die Wartungsrolle zählen und mit
|
||
der gebundenen Zählung je Mandant vergleichen — jede Abweichung ist ein
|
||
Trennungsfehler, kein Erstbenutzer. Eine Laufzeitwarnung an
|
||
`getSources`/`fetchAndCacheEvents` wurde erwogen und VERWORFEN, mit
|
||
derselben Begründung wie bei `getAllActiveConfigs` im Bereich `ldap` und
|
||
bei `getLayout`/`getWidgets`/`getSearchProviders` im Bereich `dashboard`:
|
||
keine Quelle ist auf einer frischen Installation oder für einen neuen
|
||
Nutzer der NORMALZUSTAND — eine Warnung an dieser Stelle wäre Dauerlärm
|
||
und verlöre ihr Signal, bevor sie gebraucht wird.
|
||
|
||
### (k5) Was dieser Durchlauf bewusst nicht anfasst
|
||
|
||
- **Das Frontend** — geprüft (Befund I/J, (k3) oben) und bewusst gelassen,
|
||
keine Datei dieses Plans. `calendar-widget.tsx`,
|
||
`calendar-settings-panel.tsx` und `calendar-source-form.tsx` werden NICHT
|
||
geändert.
|
||
- **Die drei Provider** (`ics.provider.ts`, `caldav.provider.ts`,
|
||
`exchange.provider.ts`) — reden mit echten Servern, werden in Aufgabe 2
|
||
NICHT ausgeübt, nur als Attrappen (`fetchEvents`/`testConnection` als
|
||
`vi.fn`) verwendet.
|
||
- **`testConnectionFromConfig`** — kein Datenbankzugriff, kein Mandant
|
||
(prüft eine Konfiguration, bevor sie gespeichert wird), unverändert.
|
||
- **Der Bereich `favorites`** — eigener Bereich mit eigener Umstellung,
|
||
nicht Teil dieses Plans.
|
||
- **Die Antwortsemantik 403/404** — siehe (k4)(d), bewusst nicht geändert.
|
||
- **Der Fremdkommentar in `apps/api/src/ldap/ldap-config.service.ts`**
|
||
(Zeile 24, nennt `CalendarSource` als Vorbild der Verschlüsselung) —
|
||
zutreffend (`CalendarCryptoService`, siehe Dezision `07-01` in
|
||
STATE.md), nicht zu ändern.
|
||
- 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.
|
||
|
||
**Nachtrag (260911-mkj):** WINDOWS #27 — die in (b) beschriebene
|
||
Erkennungsluecke der Bestandsaufnahme — ist geschlossen. Eine vierte
|
||
Erkennungsform in `rls-access-inventory.spec.ts` (`analyzeSource`,
|
||
`SCHEMA_RELATIONS`) loest Relationsfelder ueber `schema.prisma` auf ihr
|
||
Zielmodell auf und traegt das Zielmodell als eigene Fundstelle ein — der
|
||
Nachweis steht gegen KONTROLLIERTE Proben im Spec, nicht gegen die drei
|
||
realen Stellen aus (b): die einzige gefaehrliche Auspraegung
|
||
(`tenant.controller.ts`, ungebundener aeusserer Aufruf auf ungeschuetzter
|
||
Tabelle mit Einbindung in eine geschuetzte Tabelle) traegt die Form seit
|
||
(n3)/Aufgabe 3 (dem Fan-out-Ersatz) nicht mehr — deshalb Probestrings, die
|
||
dieselbe Form nachbilden (ungebunden und gebunden). Gemessen: SIEBEN neue
|
||
(Datei, Modell)-Paare, DREI fortgeschriebene Staende, davon
|
||
`ldap-config.service.ts`/`ldapFieldMapping` als einziger Klassenwechsel
|
||
(`muss-mandantengebunden` -> `beides`, siehe (b) oben: `getAllActiveConfigs`
|
||
reicht auch in `LdapFieldMapping` hinein, nicht nur in `Tenant`). Verbleibende
|
||
Grenze, LAUT gehalten statt still: ein Empfaenger ausserhalb aller vier
|
||
Erkennungsformen bleibt fuer JEDE Form unsichtbar — gemessen
|
||
`tenders/tenders.seed.ts` (Funktionsparameter `prisma: PrismaService`) und
|
||
`tenders/backfill-tender-source.ts` (eigenstaendiges Skript, eigener `new
|
||
PrismaClient()`), beide als eigener Ledger-Eintrag gefuehrt (nicht
|
||
stillschweigend mit #27 mitgeschlossen).
|
||
|
||
### (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.
|
||
|
||
## Bereich auth
|
||
|
||
Dieser Abschnitt erweitert die Kritikschrift um den Bereich `auth`
|
||
(Quick-Task 260911-fh9) und beschreibt ihn zum Zeitpunkt seiner Umstellung.
|
||
Anders als jeder Bereich davor traegt dieser Bereich die Grenze, an der die
|
||
gesamte Mandantentrennung haengt: der Anmeldeweg (Benutzer suchen, BEVOR der
|
||
Mandant bekannt ist) wurde bereits in Etappe 1 (260909-eor) ueber drei enge
|
||
SECURITY-DEFINER-Funktionen geloest und bleibt in diesem Durchlauf
|
||
unangetastet; gegenstand dieses Durchlaufs sind ausschliesslich die drei
|
||
Methoden NACH der Anmeldung (`getMe`, `changePassword`,
|
||
`adminResetPassword`), die in Etappe 1 bewusst liegen gelassen wurden.
|
||
|
||
### (h1) Die Messung
|
||
|
||
Aufgabe 1 hat `apps/api/scripts/rls-scratch-check.mjs` um einen dreizehnten
|
||
Abschnitt (`runAuthAreaChecks`) erweitert — ausdruecklich GETRENNT vom
|
||
bestehenden `runAuthLookupChecks`: jener misst den Anmeldeweg VOR bekanntem
|
||
Mandanten (die drei Funktionen ueber `$queryRaw`), dieser die drei Methoden
|
||
NACH der Anmeldung (gebundener Modellzugriff ueber den GENERIERTEN Client).
|
||
Der neue Abschnitt setzt auf der von `runAuthLookupChecks` bereits angelegten
|
||
Wegwerf-Tabelle `"User"` auf (eingeschalteter und erzwungener Zeilenschutz,
|
||
wortgleiche Policy, zwei Zeilen in zwei Mandanten; seit `runTenantAreaChecks`
|
||
zusaetzlich mit Fremdschluessel auf `"Tenant"`) und ruestet sie um die fuenf
|
||
Spalten nach, die der generierte Client zusaetzlich braucht (`createdAt`,
|
||
`updatedAt`, `lastLoginAt`, `avatarPath`, `accentColor` — Befund G): dafuer
|
||
liest ein neuer Helfer `readSchemaModelScalarFieldNames('User')` die
|
||
skalaren Felder aus `schema.prisma` und filtert Relationsfelder ueber ihren
|
||
Typ heraus (`tenant`, `passwordResetTokens`, `groupMemberships`,
|
||
`moduleGrants` fallen weg, `role` bleibt — `Role` ist ein `enum`, kein
|
||
`model`). Diese Spaltenpruefung steht VOR den Client-Pruefungen und bricht
|
||
den Abschnitt ab, wenn sie durchfaellt (Lehre aus Pruefung 8 im Bereich
|
||
`calendar`).
|
||
|
||
Tatsaechlich beobachtete Ausgabe dieses Laufs (2026-09-11, gegen
|
||
`tessera-ctl-db-1`, Adresse `172.19.0.2`):
|
||
|
||
```
|
||
auth-wegwerftabelle-user-deckt-alle-spalten-des-generierten-clients: bestanden — Schema-Felder aus schema.prisma (model User, skalare Felder ohne Relationen, 15): ["accentColor","avatarPath","createdAt","displayName","email","id","isActive","lastLoginAt","ldapDn","mustChangePassword","passwordHash","role","tenantId","updatedAt","username"]; Spalten der Wegwerf-Tabelle (15): ["accentColor","avatarPath","createdAt","displayName","email","id","isActive","lastLoginAt","ldapDn","mustChangePassword","passwordHash","role","tenantId","updatedAt","username"]
|
||
auth-anmeldefunktionen-security-definer-unveraendert: bestanden — 3 Funktion(en) unter 'auth_lookup_%' gefunden: ["auth_lookup_reset_token","auth_lookup_user_by_email","auth_lookup_user_by_username"]; je Funktion prosecdef/provolatile/proconfig/LIMIT-1: [{"proname":"auth_lookup_reset_token","prosecdef":true,"provolatile":"s","proconfig":["search_path=public, pg_temp"],"hatLimit1":true},{"proname":"auth_lookup_user_by_email","prosecdef":true,"provolatile":"s","proconfig":["search_path=public, pg_temp"],"hatLimit1":true},{"proname":"auth_lookup_user_by_username","prosecdef":true,"provolatile":"s","proconfig":["search_path=public, pg_temp"],"hatLimit1":true}]
|
||
auth-anmeldesuche-findet-benutzer-weiterhin-mit-festem-spaltensatz: bestanden — auth_lookup_user_by_username('alice') liefert 1 Zeile(n) mit 9 Schluessel(n): ["displayName","id","isActive","ldapDn","mustChangePassword","passwordHash","role","tenantId","username"] — der feste Spaltensatz der Funktion laesst die Spaltenerweiterung nicht durch
|
||
auth-getme-generierter-client-ungebunden-liefert-null: bestanden — ungebundenes prisma.user.findUnique({ where: { id: 'user-a' }, select: {...} }) (die Form von getMe) liefert null — das ist der Wert, den GET /auth/me nach dem Scharfschalten als leeren Rumpf ausliefert
|
||
auth-getme-generierter-client-gebunden-eigener-mandant-findet-benutzer: bestanden — gebunden unter TENANT-A liefert findUnique({ where: { id: 'user-a' }, select: {...} }): {"id":"user-a","username":"alice","displayName":null,"role":"USER","tenantId":"TENANT-A","mustChangePassword":false,"passwordHash":"hash-a","ldapDn":null,"avatarPath":null,"accentColor":null}
|
||
auth-getme-generierter-client-gebunden-fremder-mandant-liefert-null: bestanden — gebunden unter TENANT-B liefert findUnique({ where: { id: 'user-a' } }) (gehoert TENANT-A): null — ein Administrator von TENANT-B sieht 'user-a' nicht, die Datenbankseite von T-FH9-01
|
||
auth-changepassword-generierter-client-ungebundenes-update-scheitert-laut: bestanden — ungebundenes prisma.user.update({ where: { id: 'user-a' }, data: {...} }) (die Form von changePassword) wirft PrismaClientKnownRequestError (code P2025): Invalid `prisma.user.update()` invocation: An operation failed because it depends on one or more records that were required but not found. No record was found for an update. — die Wartungsrolle liest danach weiterhin passwordHash="hash-a"
|
||
auth-changepassword-generierter-client-gebundenes-update-eigener-mandant-gelingt: bestanden — gebunden unter TENANT-A liefert die Wartungsrolle danach passwordHash="hash-a-neu", updatedAt="2026-09-11T09:42:42.165Z" — bestaetigt nebenbei, dass die Wegwerf-Tabelle die clientseitig gesetzten Werte annimmt
|
||
auth-adminreset-generierter-client-gebundenes-update-fremder-mandant-scheitert-laut: bestanden — gebunden unter TENANT-B liefert update({ where: { id: 'user-a' } }) (gehoert TENANT-A) PrismaClientKnownRequestError (code P2025): Invalid `prisma.user.update()` invocation: An operation failed because it depends on one or more records that were required but not found. No record was found for an update. — ein ADMIN von TENANT-B kann das Kennwort von 'user-a' nicht setzen, gemessen statt behauptet; die Wartungsrolle liest danach weiterhin passwordHash="hash-a-neu"
|
||
auth-fan-out-je-mandant-gebunden-loest-mandant-der-kennung-auf: bestanden — Fan-out ueber 2 Mandant(en): 'user-a' gefunden unter "TENANT-A", tenantId der Zeile="TENANT-A" — die Kennung allein ergibt den Mandanten des Ziels, weil User.id plattformweit eindeutig ist
|
||
Alle 120 Pruefungen bestanden.
|
||
```
|
||
|
||
Die tragende Belegzeile ist `auth-getme-generierter-client-ungebunden-liefert-null`: der IDENTISCHE `findUnique`, den `getMe` heute stellt, liefert UNGEBUNDEN `null`, waehrend die Wartungsrolle die Zeile sieht — das ist der Wert, den `GET /auth/me` nach dem Scharfschalten als leeren Rumpf ausliefert. Die `pg_proc`-Messung
|
||
(`auth-anmeldefunktionen-security-definer-unveraendert`) und der feste
|
||
Spaltensatz (`auth-anmeldesuche-findet-benutzer-weiterhin-mit-festem-spaltensatz`)
|
||
belegen zusammen, dass die drei Anmeldefunktionen unangetastet sind und die
|
||
Spaltenerweiterung von `"User"` nichts Zusaetzliches preisgibt: alle drei
|
||
Funktionen sind weiterhin `SECURITY DEFINER`, `STABLE`, mit festem Suchpfad
|
||
`search_path=public, pg_temp` und `LIMIT 1`, und `auth_lookup_user_by_username`
|
||
liefert nach der Spaltenerweiterung weiterhin genau neun Spalten — weder
|
||
`avatarPath` noch `accentColor` noch `email` sind darunter.
|
||
|
||
Sechs der zehn Pruefungen laufen ueber den GENERIERTEN CLIENT
|
||
(`prisma.user.findUnique`/`update`, `bound.user.findUnique`/`update`), nicht
|
||
ueber Roh-SQL — bewusst, weil `findUnique` ohne `select` (die Form von
|
||
`changePassword`/`adminResetPassword`) und das `select` von `getMe` Client-
|
||
Formen sind: Roh-SQL sieht sie strukturell nicht (Fehler 7 des Vorhabens).
|
||
|
||
### (h2) Signaltabelle je Pfad
|
||
|
||
| Pfad | `@Public()` | Verhalten HEUTE nach dem Scharfschalten (ohne diesen Plan) | Konkretes Signal | Frontend laesst es durch? |
|
||
|---|---|---|---|---|
|
||
| `validateUser` (`POST /auth/login`) | ja | Sucht ueber `auth_lookup_user_by_username` (Funktion, unveraendert) — findet den Benutzer weiterhin, dann gebundener `lastLoginAt`-Schreibzugriff | Anmeldung funktioniert unveraendert | — |
|
||
| `requestPasswordReset` (`POST /auth/request-reset`) | ja | Sucht ueber `auth_lookup_user_by_email` (Funktion, unveraendert), dann gebundene Token-Anlage | Unveraendert, immer `200` (T-02-12) | — |
|
||
| `resetPassword` (`POST /auth/reset-password`) | ja | Sucht ueber `auth_lookup_reset_token` (Funktion, unveraendert), dann gebundenes Kennwort-Update und Token-Markierung | Unveraendert | — |
|
||
| `login`/`logout` | ja/nein | Kein Datenbankzugriff | Keins | — |
|
||
| `getMe` (`GET /auth/me`) | nein | Vor diesem Plan: ungebundener `findUnique` liefert `null` (`auth-getme-generierter-client-ungebunden-liefert-null`) statt der eigenen Zeile | `200` mit leerem Rumpf statt der Profildaten | Ja — verschluckt, siehe (h3) |
|
||
| `changePassword` (`POST /auth/change-password`) | nein | Vor diesem Plan: ungebundener `findUnique` liefert `null` | `UnauthorizedException('User not found or has no local password')`, `401` | Ja — als `networkError`, siehe (h3) |
|
||
| `adminResetPassword`, ADMIN-Zweig (`POST /auth/admin-reset-password/:userId`) | nein | Vor diesem Plan: ungebundener `findUnique` sieht Ziele JEDES Mandanten — Rechteausweitung ueber die Mandantengrenze (T-FH9-01), kein Frontend-Aufrufer | `BadRequestException('User not found')` nur, wenn die Kennung gar nicht existiert; ein fremdmandantiges Ziel wuerde heute GEFUNDEN | Kein UI-Aufrufer (gemessen, Befund D) |
|
||
| `adminResetPassword`, SUPER_ADMIN-Zweig | nein | Dieselbe Ausweitung; zusaetzlich keine Rollenpruefung des Ziels (T-FH9-04) | Dieselbe Meldung | Kein UI-Aufrufer |
|
||
|
||
Nach der Bindung (Aufgabe 2) loest der SUPER_ADMIN-Zweig den Mandanten des
|
||
Ziels ueber den gebundenen Fan-out `UserService.findByIdForPlatformAdmin`
|
||
auf — denselben Praezedenzfall, den `user.controller.ts` (`resolveTargetUser`,
|
||
260910-das) fuer seine eigene Rollenverzweigung benutzt; der ADMIN-Zweig
|
||
bindet dagegen an `currentUser.tenantId` aus dem Sitzungsnachweis.
|
||
|
||
### (h3) Welcher Code Leere als Abwesenheit deutet
|
||
|
||
Die `getMe`-Kette, alle vier Glieder namentlich: `getMe` liefert `null` (die
|
||
Belegzeile `auth-getme-generierter-client-ungebunden-liefert-null`) →
|
||
`AuthController.me` gibt `null` zurueck, ohne zu werfen → NestJS'
|
||
`ExpressAdapter.reply` sendet bei `isNil(body)` einen LEEREN Rumpf mit
|
||
Status `200` (`apps/api/node_modules/@nestjs/platform-express/adapters/express-adapter.js`,
|
||
Methode `reply`) → `fetchCurrentUser` (`apps/web/src/lib/auth-actions.ts`)
|
||
laeuft mit `response.json()` auf den leeren Rumpf, faengt im `catch` und
|
||
liefert `null` → `apps/web/src/components/layout/header.tsx` (`if (u)
|
||
setUser(...)`) und `apps/web/src/components/settings/account-settings-form.tsx`
|
||
(`if (u) ...`) tun bei `null` NICHTS. Folge: die Portalhuelle rendert OHNE
|
||
angemeldeten Benutzer — kein Name, kein Avatar, `isAdmin` falsch, Admin-
|
||
Navigation weg. Der Auftrag vermutete hier "laut"; die Lesung ergibt
|
||
**verschluckt**: `fetchCurrentUser` liefert fuer "nicht angemeldet" und
|
||
"Zeile unsichtbar" denselben Wert `null` — das ist NICHT die Familie eines
|
||
lauten Fehlers, sondern dieselbe Familie wie WINDOWS #23/#25/#26. Die Seite
|
||
`apps/web/src/app/(portal)/change-password/page.tsx` haengt an demselben
|
||
Aufruf; `ForcePasswordChangeInterceptor` laesst `/auth/me` und
|
||
`/auth/change-password` ausdruecklich durch, weil der erzwungene
|
||
Kennwortwechsel `getMe` braucht.
|
||
|
||
`changePassword`: der gebundene `findUnique` liefert nach der Bindung `null`
|
||
fuer ein fremdmandantiges Ziel (kommt hier praktisch nicht vor — der
|
||
angemeldete Benutzer sucht sich selbst) → `UnauthorizedException('User not
|
||
found or has no local password')`, `401` → `auth-actions.ts` uebersetzt NUR
|
||
die woertliche Meldung `Current password is incorrect` in
|
||
`wrongCurrentPassword`, jede andere 401-Meldung in `networkError` — der
|
||
Nutzer saehe eine Netzwerkfehler-Meldung fuer einen Trennungsfehler. Der
|
||
Auftrag vermutete "falsches altes Kennwort"; gemessen ist es `networkError`.
|
||
|
||
`adminResetPassword`: `BadRequestException('User not found')`, `400`, kein
|
||
UI-Aufrufer (Befund D) — fuer einen API-Aufrufer bedeutet das "diesen
|
||
Benutzer gibt es nicht", und fuer ein fremdmandantiges Ziel ist das NACH der
|
||
Bindung dieses Plans die RICHTIGE Antwort: sie macht keine Aussage ueber die
|
||
Existenz oder den Mandanten eines fremden Halters (dieselbe Form wie
|
||
T-DAS-08).
|
||
|
||
### (h4) Was dieser Durchlauf bewusst nicht löst
|
||
|
||
**(a) Etappe 3.** Die Etappe-3-Entscheidung (1) macht `username` (und
|
||
`email`) je Mandant eindeutig. Davon betroffen, alles NICHT dieser Auftrag:
|
||
`auth_lookup_user_by_username(p_username)` (stuetzt sich heute auf `username
|
||
@unique` plattformweit; braucht kuenftig `(p_tenant_id, p_username)` — die
|
||
Funktion wird dabei ENGER, zwei Gleichheitsbedingungen statt einer, nicht
|
||
weiter), gegebenenfalls `auth_lookup_user_by_email`, `local.strategy.ts`
|
||
(kennt nur `username`/`password`, braucht eine Mandantenangabe VOR der
|
||
Suche), die `@unique`-Indizes auf `User`, `UserService.findByUsername`,
|
||
`resolveEmailForWrite` (ldap), die P2002-Uebersetzung in `UserService.create`/
|
||
`update` (WINDOWS #22). Dieser Plan ist dafuer NEUTRAL: die Bindung der drei
|
||
Methoden haengt am Claim `tenantId` (das es unabhaengig davon gibt, WIE der
|
||
Anmeldeweg den Mandanten ermittelt) und an `User.id` (plattformweite UUID,
|
||
Kette aus 260911-cwh: Schema → `auth.service.ts` `sub: user.id` →
|
||
`JwtStrategy.validate` → `@CurrentUser().id`) — nicht an `username`/`email`.
|
||
Unterlassen, damit Etappe 3 nicht schwerer wird: Selbstbedienung ueber
|
||
`req.tenantId` binden; den Mandanten irgendwo nach der Anmeldung aus
|
||
`username`/`email` ableiten; die Funktionen um Spalten erweitern.
|
||
|
||
**(b) Die Rechteausweitung ADMIN → SUPER_ADMIN im Schwesterweg.**
|
||
`apps/api/src/user/user.controller.ts`, `update` (Zeilen 172-208) prueft mit
|
||
T-02-08 nur, ob die Rolle `SUPER_ADMIN` NEU ZUGEWIESEN wird
|
||
(`dto.role === Role.SUPER_ADMIN`), nicht, ob das ZIEL diese Rolle bereits
|
||
HAT — `password`/`isActive` im `UpdateUserDto` gehen fuer ein bestehendes
|
||
`SUPER_ADMIN`-Ziel ungeprueft durch. `remove` (Zeilen 220-244) prueft
|
||
ueberhaupt keine Rolle des Ziels, nur Selbstloeschung und Mandantengrenze.
|
||
Erneut gelesen (260911-fh9, Aufgabe 1): beide Luecken bestehen unveraendert
|
||
— `grep -n "Role.SUPER_ADMIN" apps/api/src/user/user.controller.ts` findet
|
||
nur die eine Stelle in `update` (`dto.role === Role.SUPER_ADMIN`), keine im
|
||
`user`-Objekt selbst. Kein Mandantenproblem, sondern Rechteausweitung
|
||
INNERHALB des Mandanten — derselbe Fall wie T-FH9-04 in diesem Plan, nur am
|
||
Schwesterweg. Reparatur in einem Satz: die Rolle des ZIELS pruefen (ist
|
||
`user.role === Role.SUPER_ADMIN` und der Aufrufer nicht `SUPER_ADMIN`,
|
||
`ForbiddenException`) — die Vorlage steht seit dieser Aufgabe in
|
||
`AuthService.adminResetPassword`. Ausserhalb der Erlaubnisliste dieses
|
||
Plans, deshalb Ledger-Eintrag statt Reparatur (Aufgabe 3, T-FH9-05).
|
||
|
||
**(c) Das Frontend, das `null` verschluckt.** Nicht angefasst (siehe (h3));
|
||
Ledger-Eintrag in Aufgabe 3, Familie #23/#25/#26.
|
||
|
||
**(d) Die fehlende Existenzpruefung des `x-tenant-id`-Werts.** Bekannt aus
|
||
`(n4)(f)` des Abschnitts `## Bereich tenant`; hier nur genannt, weil sie die
|
||
Entscheidung gegen `req.tenantId` fuer Selbstbedienung stuetzt — ein
|
||
SUPER_ADMIN kann per `x-tenant-id` eine erfundene Kennung schicken, sie
|
||
bindet an einen leeren Kontext, kein Leck. Nicht dieser Auftrag.
|
||
|
||
**(e) Die Etappe-4-Vorabpruefung.** Fuer einen bekannten Benutzer die Zeile
|
||
ueber die Wartungsrolle lesen und den gebundenen `findUnique` unter seinem
|
||
Claim-Mandanten daneben halten — dieselbe Form wie bei den Bereichen `user`
|
||
und `tenant`.
|
||
|
||
**(f) `changePassword` hat zwei verschiedene 401-Meldungen.**
|
||
`User not found or has no local password` (kein lokales Kennwort, z. B.
|
||
LDAP-Konto) vs. `Current password is incorrect` (falsches Kennwort) —
|
||
bestehendes Verhalten, betrifft nur die eigene Zeile des angemeldeten
|
||
Benutzers, unveraendert in diesem Plan.
|
||
|
||
### (h5) Was dieser Durchlauf bewusst nicht anfasst
|
||
|
||
- Die drei SECURITY-DEFINER-Funktionen aus Etappe 1
|
||
(`auth_lookup_user_by_username`, `auth_lookup_user_by_email`,
|
||
`auth_lookup_reset_token`) und ihre Migration `20260909160000_auth_lookup_functions`
|
||
— belegt durch `git diff --name-only 6236b30 -- apps/api/prisma` (leer)
|
||
und die `pg_proc`-Messung aus (h1).
|
||
- Die drei `$queryRaw`-Aufrufe in `validateUser`, `requestPasswordReset`,
|
||
`resetPassword` — bleiben auf dem ungebundenen Klienten `this.prisma`.
|
||
- `local.strategy.ts` und `jwt.strategy.ts` — nur gelesen.
|
||
- `apps/api/src/auth/dto/admin-reset-password.dto.ts` — nur gelesen, kein
|
||
Mandantenfeld.
|
||
- `docs/mandantentrennung-datenbankrolle.md`, Abschnitt 3 — beschreibt die
|
||
Anordnung des Anmeldewegs korrekt, keine Aenderung noetig.
|
||
- `docs/anleitung-entwicklung.md` — nennt keine der drei Methoden.
|
||
- `apps/api/src/user/user.controller.ts` — nur gelesen (siehe (h4)(b)).
|
||
- Das Frontend (`header.tsx`, `account-settings-form.tsx`, `auth-actions.ts`,
|
||
`change-password/page.tsx`) — nur beschrieben, nicht geaendert.
|
||
- Schema und Migrationen.
|
||
- Die Kopfzeile in `auth.service.spec.ts` (Zeile 4-6), die auf ein Muster in
|
||
`ldap.service.spec.ts` verweist — wird in Aufgabe 2 ersetzt, hier nur als
|
||
Befund F genannt.
|
||
|
||
## Bereich favorites
|
||
|
||
Dieser Abschnitt erweitert die Kritikschrift um den Bereich `favorites`
|
||
(Quick-Task 260911-gwh, zusammen mit `settings` der LETZTE Lauf von Etappe 2)
|
||
und beschreibt ihn zum Zeitpunkt seiner Umstellung. Die Leitfrage aus
|
||
Abschnitt (a) gilt unverändert weiter. Anders als jeder Bereich davor hängt
|
||
`FavoriteLink` über einen Fremdschlüssel an einer ZWEITEN mandantengebundenen
|
||
Tabelle (`WidgetInstance`), deren Zeilenschutz-Regel der Fremdschlüssel auf
|
||
Datenbankebene umgeht — eine Ausprägung von WINDOWS #27, hier zum ersten Mal
|
||
ausdrücklich mitgebaut statt nur benannt.
|
||
|
||
### (f1) Die Messung
|
||
|
||
Aufgabe 1 hat `apps/api/scripts/rls-scratch-check.mjs` um einen weiteren
|
||
Abschnitt (`runFavoritesAreaChecks`) erweitert, mit der Policy für
|
||
`FavoriteLink` (aus der ausgelieferten Migration
|
||
`20260909140000_rls_remaining_tenant_tables`) WORTGLEICH extrahiert, nicht im
|
||
Werkzeug nachgetippt. Tatsächlich beobachtete Ausgabe dieses Laufs
|
||
(2026-09-11, gegen `tessera-ctl-db-1`, Adresse `172.19.0.2`):
|
||
|
||
```
|
||
favoritelink-regelstand-eindeutig: bestanden — die *_rls_widen_membership_grant_and_platform_read-Migration (260910-jab) enthaelt KEINE eigene Regel fuer "FavoriteLink" — der Stand aus 20260909140000_rls_remaining_tenant_tables ist weiterhin der ausgelieferte, aktuelle Regelstand
|
||
favoritelink-wegwerftabelle-deckt-alle-spalten-des-generierten-clients: bestanden — Schema-Felder aus schema.prisma (model FavoriteLink, skalare Felder ohne Relation, 10): ["createdAt","iconUrl","id","position","tenantId","title","updatedAt","url","userId","widgetId"]; Spalten der Wegwerf-Tabelle (10): [dieselben zehn]; gemessene "WidgetInstance"-Zeilen (Befund C, von runDashboardAreaChecks angelegt): [{"id":"widget-a1","userId":"user-a1","tenantId":"TENANT-A"},{"id":"widget-a2","userId":"user-a2","tenantId":"TENANT-A"},{"id":"widget-b1","userId":"user-b1","tenantId":"TENANT-B"}]
|
||
favoritelink-liste-generierter-client-ungebunden-liefert-leere-liste: bestanden — ungebundenes prisma.favoriteLink.findMany({ where: { userId: 'user-a1', widgetId: 'widget-a1' }, orderBy: [{ position: 'asc' }, { title: 'asc' }] }) (die Form von list) liefert 0 Zeile(n), obwohl 2 tatsaechlich vorhanden sind — das ist der Wert, aus dem favorites-widget.tsx "Noch keine Favoriten." macht
|
||
favoritelink-liste-generierter-client-gebunden-eigener-mandant-liefert-eigene-zeilen: bestanden — bound.favoriteLink.findMany unter TENANT-A liefert fuer (userId='user-a1', widgetId='widget-a1') 2 Zeile(n): ["fav-a1-1","fav-a1-2"] — die Zeile von user-a2 fehlt (anwendungsseitige Benutzerfilterung); ein gebundenes findMany({ where: { widgetId: 'widget-a2' } }) unter DEMSELBEN Mandanten liefert dagegen 1 Zeile(n) des Kollegen user-a2 (["fav-a2-1"]) — die Regel auf "FavoriteLink" kennt keine Benutzerdimension
|
||
favoritelink-besitzpruefung-generierter-client-gebunden-fremder-mandant-liefert-null: bestanden — bound.favoriteLink.findUnique({ where: { id: 'fav-a1-1' } }) unter TENANT-B (die Zeile gehoert TENANT-A) liefert null
|
||
favoritelink-gebundenes-loeschen-ueber-kennung-allein-fremder-mandant-scheitert-laut: bestanden — bound.favoriteLink.delete unter TENANT-B auf die unter TENANT-A liegende Zeile fav-a1-1 wirft PrismaClientKnownRequestError (code P2025): No record was found for a delete. — die Wartungsrolle liest die Zeile danach noch: true
|
||
favoritelink-fremdschluessel-prueft-am-zeilenschutz-vorbei: bestanden — bound.favoriteLink.create unter TENANT-A mit widgetId='widget-b1' (gehoert TENANT-B, unter TENANT-A per gebundenem widgetInstance.findUnique unsichtbar: null) GELINGT (id=fav-a1-fremdes-widget) — der Fremdschluessel prueft am Zeilenschutz VORBEI (dokumentiertes PostgreSQL-Verhalten); bound.favoriteLink.create unter TENANT-A mit widgetId="widget-gibt-es-nicht" scheitert mit PrismaClientKnownRequestError (code P2003): Foreign key constraint violated — der Unterschied zwischen beiden Antworten ist das Existenzorakel (T-GWH-05)
|
||
favoritelink-gebundenes-anlegen-eigener-mandant-gelingt: bestanden — bound.favoriteLink.create unter TENANT-A mit widgetId='widget-a1' gelingt (id=fav-a1-neu); die Wartungsrolle liest danach tenantId="TENANT-A", createdAt und updatedAt gesetzt
|
||
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
|
||
IDENTISCHE `findMany`, den `list` heute stellt, liefert UNGEBUNDEN `[]`,
|
||
während die Wartungsrolle zwei Zeilen sieht — das ist der Wert, aus dem
|
||
`favorites-widget.tsx` `Noch keine Favoriten.` macht (siehe (f3)).
|
||
|
||
Der Fremdschlüssel ist als mitgebaute Relation gemessen, nicht nur behauptet
|
||
(Befund C, WINDOWS #27): die Wegwerf-Tabelle `"FavoriteLink"` trägt
|
||
`FOREIGN KEY ("widgetId") REFERENCES "WidgetInstance"("id") ON DELETE CASCADE`
|
||
auf die von `runDashboardAreaChecks` bereits angelegte Tabelle; vorher wurde
|
||
über die Wartungsrolle gemessen, welche `WidgetInstance`-Zeilen tatsächlich
|
||
stehen (drei, siehe Belegausgabe von Prüfung 2), statt sie anzunehmen.
|
||
|
||
Das Ergebnis von Prüfung 7 (`favoritelink-fremdschluessel-prueft-am-zeilenschutz-vorbei`)
|
||
ist zweigeteilt und trägt eine Entscheidung für Aufgabe 2: ein gebundenes
|
||
`create` unter TENANT-A mit `widgetId` eines TENANT-B-Widgets GELINGT — der
|
||
Fremdschlüssel prüft an der Zeilenschutz-Regel von `WidgetInstance` VORBEI
|
||
(dokumentiertes PostgreSQL-Verhalten: referentielle Integrität umgeht Row
|
||
Security). Derselbe Aufruf mit einer wirklich fehlenden `widgetId` scheitert
|
||
dagegen laut mit einer FK-Verletzung (Code P2003). Der Unterschied zwischen
|
||
beiden Antworten — "existiert nicht" (500/Fehler) vs. "gehört einem fremden
|
||
Mandanten" (gelingt) — ist ein Existenzorakel über Mandantengrenzen
|
||
(T-GWH-05, medium). Aufgabe 2 baut deshalb einen anwendungsseitigen
|
||
Besitzriegel in `create`, der beide Fälle auf dieselbe Antwort
|
||
(`Widget not found`) abbildet.
|
||
|
||
### (f2) Signaltabelle je umzustellendem Pfad
|
||
|
||
| Pfad | Verhalten HEUTE nach dem Scharfschalten (ohne diesen Plan) | Konkretes Signal | Frontend lässt es durch? |
|
||
|---|---|---|---|
|
||
| `list` (`GET /favorites?widgetId=`) | Ungebundener `findMany` liefert `[]` statt der eigenen Zeilen | `200 []` | Ja — `fetchFavorites` (`favorites-api.ts:22-29`) gibt `[]` durch, siehe (f3) |
|
||
| `create` (`POST /favorites`) | Ungebundenes `create` schreibt eine Zeile, die unter der Mandantenkennung des Aufrufers physisch korrekt liegt, aber der Icon-Suchpfad ist unverändert; der Riegel aus Aufgabe 2 prüft VOR dem Schreiben, ob `widgetId` existiert und dem Aufrufer gehört | `NotFoundException('Widget not found')`, 404, wenn das Widget nicht dem Aufrufer gehört | Ja — `createFavorite` (`favorites-api.ts:33-46`) wirft `Failed to create favorite` bei `!res.ok` |
|
||
| `update` (`PATCH /favorites/:id`) | Ungebundener `findUnique` liefert `null` statt der eigenen Zeile, Vorprüfung greift bereits heute (userId-Vergleich) | `NotFoundException('FavoriteLink not found')`, 404 | Ja — `updateFavorite` wirft `Failed to update favorite` |
|
||
| `remove` (`DELETE /favorites/:id`) | Dieselbe Form wie `update` | `NotFoundException('FavoriteLink not found')`, 404 | Ja — `deleteFavorite` wirft `Failed to delete favorite` |
|
||
| `getIconBytes` (`GET /favorites/:id/icon`) | Dieselbe Vorprüfung wie `update`/`remove` | `NotFoundException('FavoriteLink not found')`, 404 | Ja — `<img>`-Ladefehler, vom Widget nicht gesondert behandelt |
|
||
|
||
### (f3) Welcher Code Leere als Abwesenheit deutet
|
||
|
||
Die `list`-Kette, alle Glieder namentlich: `list` liefert `[]` (die Belegzeile
|
||
`favoritelink-liste-generierter-client-ungebunden-liefert-leere-liste`) →
|
||
`GET /favorites?widgetId=` antwortet `200 []` → `fetchFavorites`
|
||
(`favorites-api.ts:22-29`) prüft nur `!res.ok` (bei `200` erfüllt das nicht)
|
||
und gibt `[]` durch → `favorites-widget.tsx:212-213` zeigt
|
||
`t('favorites.empty')` = **`Noch keine Favoriten.`** (`de.json`, Zeile 219).
|
||
"Zeile unsichtbar" und "nie einen gespeichert" sind für das Frontend
|
||
derselbe Wert `[]` — das ist NICHT die Familie eines lauten Fehlers, sondern
|
||
dieselbe Familie wie WINDOWS #23/#25/#26/#28 (module-registry, dashboard,
|
||
calendar, auth).
|
||
|
||
`update`/`remove`/`getIconBytes` deuten Leere dagegen LAUT: die
|
||
Vorprüfung (`findUnique` → `null` oder fremder `userId`) wirft
|
||
`NotFoundException('FavoriteLink not found')`, 404 — `favorites-api.ts`
|
||
übersetzt das in `Failed to update/delete favorite`, das Widget setzt
|
||
`t('favorites.error')` im `catch`. Diese Richtung ist harmlos, weil ein zu
|
||
kleines Ergebnis dort bereits heute einen Fehler auslöst, der nicht mit dem
|
||
Scharfschalten neu entsteht.
|
||
|
||
### (f4) Was dieser Durchlauf bewusst nicht löst
|
||
|
||
- **(a) Die fehlende Benutzerdimension der Regel.** Prüfung 4
|
||
(`favoritelink-liste-generierter-client-gebunden-eigener-mandant-liefert-eigene-zeilen`)
|
||
zeigt zweigeteilt: die eigenen Zeilen kommen korrekt, UND ein gebundenes
|
||
`findMany` auf die `widgetId` eines Kollegen DESSELBEN Mandanten liefert
|
||
dessen Zeile ebenfalls — die Regel auf `FavoriteLink` kennt keine
|
||
Benutzerdimension (dieselbe Lehre wie bei `CalendarSource`,
|
||
`DashboardLayout`, `WidgetInstance`). Die anwendungsseitige
|
||
`userId`-Filterung bleibt bestehen und ist bis zur Etappe-3-Entscheidung
|
||
(2) der einzige Schutz gegen Quer-Lesen zwischen Nutzern desselben
|
||
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;
|
||
Ledger-Eintrag in Aufgabe 3.
|
||
- **(c) Die Mandantenquelle — warum der dashboard-Präzedenzfall und nicht
|
||
der auth-Präzedenzfall.** `favorites.controller.ts` `extractContext` liest
|
||
`req.tenantId ?? req.user?.tenantId` — WORTGLEICH mit
|
||
`dashboard.controller.ts`, unter dessen Bindung `WidgetInstance` liegt.
|
||
`FavoriteLink` hängt über `widgetId` an `WidgetInstance`; würde
|
||
`favorites` stattdessen an das Claim binden (wie `auth` für
|
||
Selbstbedienung), während `dashboard` an der Guard-Kennung bleibt, lägen
|
||
Widget und Link unter einem `x-tenant-id`-Wechsel eines SUPER_ADMIN in
|
||
verschiedenen Mandanten. `favorites-api.ts` sendet die Kopfzeile heute
|
||
nicht (`grep -rn "x-tenant-id" apps/web/src`: nur die vier
|
||
Marktplatz-Stellen) — die Entscheidung hängt an der Bauform, nicht am
|
||
heutigen Aufrufer.
|
||
- **(d) Die Etappe-4-Vorabprüfung.** Für einen bekannten Nutzer/Widget die
|
||
Favoritenzahl über die Wartungsrolle und über den gebundenen `findMany`
|
||
daneben halten — dieselbe Form wie bei `dashboard`/`calendar`.
|
||
|
||
### (f5) Was dieser Durchlauf bewusst nicht anfasst
|
||
|
||
- Der Icon-Proxy (`icon-discovery.service.ts`, Befund G) — erreicht die
|
||
Datenbank NICHT und nimmt KEINE Client-URL: `getIconBytes` holt nur die
|
||
GESPEICHERTE `iconUrl` einer Zeile, die der Aufrufer besitzt (T-QFIP-01);
|
||
`discoverFavoriteIconUrl` nimmt die Nutzer-URL nur für den
|
||
SSRF-gesicherten Abruf (T-08-05). Kein Mandantenbezug — unverändert, in
|
||
der Testdatei eine Attrappe.
|
||
- Die DTOs (`create-favorite.dto.ts`, `update-favorite.dto.ts`) — nur
|
||
gelesen, kein Mandantenfeld.
|
||
- `dashboard.controller.ts` — nur gelesen (Präzedenzfall für (f4)(c)).
|
||
- Das Frontend (`favorites-widget.tsx`, `favorites-api.ts`) — nur
|
||
beschrieben, nicht geändert.
|
||
- Schema und Migrationen.
|
||
|
||
## Bereich settings
|
||
|
||
Dieser Abschnitt erweitert die Kritikschrift um den Bereich `settings`
|
||
(Quick-Task 260911-gwh) und beschreibt ihn zum Zeitpunkt seiner Umstellung.
|
||
Anders als jeder Bereich davor trägt dieser Bereich die Reihenfolgebedingung
|
||
Befund K aus den Läufen `tenders` und `dkv`: `getDecryptedSmtpConfig(tenantId)`
|
||
ist der einzige Versandpfad für Ausschreibungs- und DKV-Mails. Zusätzlich
|
||
trägt der Startpfad des Mailmoduls den SECHSTEN Fall der
|
||
Hintergrunddienst-Falle — anders als bei `dkv` (WINDOWS #21) verdeckt hier
|
||
eine Rückfallkette das Verstummen mit einem falschen Transport statt
|
||
schlichter Leere.
|
||
|
||
### (s1) Die Messung
|
||
|
||
Aufgabe 1 hat `apps/api/scripts/rls-scratch-check.mjs` um einen weiteren
|
||
Abschnitt (`runSettingsAreaChecks`) erweitert, mit der Policy für
|
||
`SmtpConfig` (aus der ausgelieferten Migration
|
||
`20260909140000_rls_remaining_tenant_tables`) WORTGLEICH extrahiert. Die
|
||
Wegwerf-Tabelle trägt zusätzlich den Eindeutigkeitsindex
|
||
`SmtpConfig_tenantId_key` WORTGLEICH aus `20260629130000_add_missing_tables`
|
||
— ohne ihn misst Prüfung 8 nichts. Tatsächlich beobachtete Ausgabe dieses
|
||
Laufs (2026-09-11, gegen `tessera-ctl-db-1`, Adresse `172.19.0.2`):
|
||
|
||
```
|
||
smtpconfig-regelstand-eindeutig: bestanden — die *_rls_widen_membership_grant_and_platform_read-Migration (260910-jab) enthaelt KEINE eigene Regel fuer "SmtpConfig" — der Stand aus 20260909140000_rls_remaining_tenant_tables ist weiterhin der ausgelieferte, aktuelle Regelstand
|
||
smtpconfig-wegwerftabelle-deckt-alle-spalten-des-generierten-clients: bestanden — Schema-Felder aus schema.prisma (model SmtpConfig, skalare Felder ohne Relation, 10): ["createdAt","encryptedPassword","encryption","fromAddress","host","id","port","tenantId","updatedAt","username"]; Spalten der Wegwerf-Tabelle (10): [dieselben zehn]; Eindeutigkeitsindex "SmtpConfig_tenantId_key" ueber pg_indexes: ["CREATE UNIQUE INDEX \"SmtpConfig_tenantId_key\" ON public.\"SmtpConfig\" USING btree (\"tenantId\")"]
|
||
smtpconfig-startpfad-generierter-client-ungebunden-liefert-null: bestanden — ungebundenes prisma.smtpConfig.findFirst() (die Form von loadAnySmtpConfigForStartupTransport) liefert null, obwohl 2 Zeilen existieren — das ist der Wert, mit dem mail.module.ts nach dem Scharfschalten auf Umgebungsvariablen und zuletzt localhost:1025 zurueckfaellt, ein falscher Transport statt einer Meldung
|
||
smtpconfig-startpfad-ueber-wartungsrolle-zieht-beliebige-zeile: bestanden — dieselbe Abfrage ueber die Wartungsrolle liefert genau EINE Zeile, tenantId="TENANT-A" — nichts in der Abfrage bestimmt, WELCHER Mandant gezogen wird
|
||
smtpconfig-versandpfad-generierter-client-ungebunden-liefert-null: bestanden — ungebundenes prisma.smtpConfig.findUnique({ where: { tenantId: 'TENANT-A' } }) (die Form von getDecryptedSmtpConfig) liefert null, waehrend die Wartungsrolle die Zeile liest
|
||
smtpconfig-versandpfad-generierter-client-gebunden-eigener-mandant-liefert-zugangsdaten: bestanden — gebunden unter TENANT-A liefert findUnique: host="smtp-a.example.invalid", fromAddress="a@example.invalid", encryptedPassword="enc(a-passwort-platzhalter)"
|
||
smtpconfig-versandpfad-generierter-client-gebunden-fremder-mandant-liefert-null: bestanden — gebunden unter TENANT-B liefert findUnique({ where: { tenantId: 'TENANT-A' } }): null
|
||
smtpconfig-ungebundenes-upsert-auf-unsichtbare-zeile-scheitert-laut: bestanden — ungebundenes prisma.smtpConfig.upsert(...) (die Form von saveSmtpConfig) wirft PrismaClientUnknownRequestError: ConnectorError ... SQLSTATE 42501, "new row violates row-level security policy for table \"SmtpConfig\"" — die Wartungsrolle liest danach weiterhin host="smtp-a.example.invalid"
|
||
smtpconfig-gebundenes-upsert-eigener-mandant-aktualisiert: bestanden — gebundenes upsert unter TENANT-A trifft die eigene Zeile (id=smtp-a), die Wartungsrolle liest danach den neuen Host, updatedAt gesetzt; smtp-b bleibt unveraendert
|
||
Alle 137 Pruefungen bestanden.
|
||
```
|
||
|
||
Die tragenden Belegzeilen sind drei: `smtpconfig-startpfad-generierter-client-ungebunden-liefert-null`
|
||
für den Startpfad, `smtpconfig-versandpfad-generierter-client-ungebunden-liefert-null`
|
||
für Befund K, und `smtpconfig-ungebundenes-upsert-auf-unsichtbare-zeile-scheitert-laut`
|
||
für den Speicherkonflikt. Prüfung 8 wirft — gemessen, nicht angenommen —
|
||
**`PrismaClientUnknownRequestError`**, dieselbe Fehlerklasse, die 260910-krx
|
||
für `DashboardLayout` gemessen hat (nicht `PrismaClientKnownRequestError`
|
||
mit `P2002`, die Form der Bereiche `tenders`/`user`): die Regel weist den
|
||
Schreibzugriff ab, bevor eine Eindeutigkeit überhaupt geprüft wird. Die
|
||
zugrundeliegende PostgreSQL-Meldung (SQLSTATE `42501`, "new row violates
|
||
row-level security policy") ist in der Belegausgabe wörtlich enthalten.
|
||
|
||
### (s2) Signaltabelle je Pfad
|
||
|
||
| Pfad | Verhalten HEUTE nach dem Scharfschalten (ohne diesen Plan) | Signal | Frontend |
|
||
|---|---|---|---|
|
||
| `getSmtpConfig` (`GET /settings/smtp`) | Ungebundener `findUnique` liefert `null` statt der eigenen Zeile | Controller gibt `null` → NestJS sendet `200` mit LEEREM Rumpf | Ja — verschluckt, siehe (s3) |
|
||
| `saveSmtpConfig` (`PUT /settings/smtp`) | Ungebundenes `upsert` scheitert am Eindeutigkeitsindex, weil die physisch vorhandene Zeile unsichtbar ist | `PrismaClientUnknownRequestError`, 500 | Nein — kein spezifischer Übersetzungspfad im Controller, roher 500 |
|
||
| `getDecryptedSmtpConfig` — Aufrufer `tender-mail.service.ts` (`resolveTransport`) | Ungebundener `findUnique` liefert `null` | `logger.warn('No SMTP configuration ... skipping tender mail send (will retry next run)')`, KEIN Wurf, Digest übersprungen | — (Hintergrunddienst, kein Frontend-Pfad) |
|
||
| `getDecryptedSmtpConfig` — Aufrufer `dkv-mail.service.ts` (`sendExportEmail`) | Dieselbe Form | `throw new Error('No SMTP configuration found for tenant ...')` | — (Hintergrunddienst) |
|
||
| `testSmtpConfig` (`POST /settings/smtp/test`) | Rückgriff auf `getDecryptedSmtpConfig` liefert `null`, `password`/`username` bleiben `undefined` (falls DTO leer) | Verbindungstest scheitert am Postfach, `{ success: false }` — irreführend, zeigt auf das Postfach statt auf die Datenbank | Ja — Testergebnis wird angezeigt |
|
||
| Startpfad `loadAnySmtpConfigForStartupTransport()` (`mail.module.ts`, beim Start) | Ungebundenes `findFirst()` liefert `null` statt einer beliebigen Zeile; die Rückfallkette von `mail.module.ts` greift (Priorität 2-4, zuletzt `localhost:1025`) — EIGENE Spalte, siehe (s4)(a) | Kein Fehler, ein FALSCHER, aber vorhandener Transport; `MailService` fängt jeden Transportfehler (T-02-12), Controller antwortet `200` | Ja — doppelt verdeckt |
|
||
|
||
### (s3) Welcher Code Leere als Abwesenheit deutet
|
||
|
||
Die `getSmtpConfig`-Kette, alle Glieder namentlich: `getSmtpConfig` liefert
|
||
`null` (Belegzeile `smtpconfig-versandpfad-generierter-client-ungebunden-liefert-null`
|
||
zeigt dieselbe Form für den Versandpfad) → `settings.controller.ts` gibt
|
||
`null` zurück, ohne zu werfen → NestJS' `ExpressAdapter.reply` sendet bei
|
||
`isNil(body)` einen LEEREN Rumpf mit Status `200` (dieselbe Adapter-Kette wie
|
||
in (h3), 260911-fh9) → `fetchSmtp` (`settings-api.ts:51-57`) prüft nur
|
||
`res.status === 404` (nicht erfüllt) und `!res.ok` (bei `200` nicht erfüllt),
|
||
dann `res.json()` auf den leeren Rumpf → wirft → `smtp-settings-form.tsx:76-78`
|
||
`.catch(() => { /* Silent fail */ })` → leeres Formular: "SMTP nicht
|
||
eingerichtet", während die Zugangsdaten physisch noch da sind. "Nicht
|
||
eingerichtet" und "Zeile unsichtbar" sind für das Frontend derselbe Zustand
|
||
— dieselbe Familie wie WINDOWS #23/#25/#26/#28.
|
||
|
||
Trägt der Administrator die Zugangsdaten unter diesem Eindruck neu ein, läuft
|
||
`saveSmtpConfig` als `upsert({ where: { tenantId } })`: unter der
|
||
UNGEBUNDENEN Form ist die Zeile unsichtbar, der Upsert versucht ein `INSERT`
|
||
und scheitert am Eindeutigkeitsindex `SmtpConfig_tenantId_key` —
|
||
`PrismaClientUnknownRequestError` (Prüfung 8, gemessen statt vorweggenommen;
|
||
dieselbe Fehlerklasse wie die `dashboard`-Lehre aus 260910-krx, NICHT `P2002`).
|
||
|
||
Die beiden Versandpfade reagieren unterschiedlich auf `null`
|
||
(`getDecryptedSmtpConfig`): `tender-mail.service.ts` protokolliert eine
|
||
Warnung und überspringt den Versand (Wiederholung beim nächsten Lauf),
|
||
`dkv-mail.service.ts` wirft einen Fehler, der die aufrufende Pipeline
|
||
abbricht — beide Formen sind bereits vor diesem Lauf so verdrahtet, ändern
|
||
sich hier nicht.
|
||
|
||
### (s4) Was dieser Durchlauf bewusst nicht löst
|
||
|
||
**(a) Der Startpfad — der sechste Fall der Hintergrunddienst-Falle,
|
||
ausgeschrieben statt still getroffen.** `mail.module.ts`'s
|
||
`MailerModule.forRootAsync({ useFactory: async ... })` ruft
|
||
`SettingsService.loadAnySmtpConfigForStartupTransport()`
|
||
(vormals `getStartupSmtpConfig()`) BEIM START, vor jedem Anfragekontext.
|
||
Zwei Zustände, beide gehören benannt:
|
||
|
||
- **Heute** ist die Abfrage bereits FALSCH, nicht nur ungenau: bei mehreren
|
||
Mandanten trägt der SMTP-Server und die Absenderadresse EINES beliebigen
|
||
Mandanten die Kennwort-Zurücksetzungs- und Willkommensmails ALLER
|
||
Mandanten (T-GWH-03, Nutzung fremder Zugangsdaten, nicht nur Sichtbarkeit).
|
||
- **Nach dem Scharfschalten** liefert `findFirst()` `null` →
|
||
`mail.module.ts` fällt auf Priorität 2 (`MAIL_*`), 3 (`TESSERA_SMTP_*`),
|
||
zuletzt 4 (`localhost:1025`, Mailhog) zurück → `MailService` fängt jeden
|
||
Transportfehler (`mail.service.ts:69-81`, T-02-12) und der Controller
|
||
antwortet `200`. Das Verstummen ist damit DOPPELT verdeckt: erst durch die
|
||
Rückfallkette (ein falscher, aber vorhandener Transport statt Leere), dann
|
||
durch das absichtliche Verschlucken im Versand.
|
||
|
||
Drei geprüfte Formen: **(a) An einen konkret aufgelösten Mandanten binden**
|
||
— nicht möglich, `useFactory` hat beim Start keinen Anfragekontext.
|
||
**(b) Umbau auf Transport je Versand** — abgelehnt als Funktion für DIESEN
|
||
Lauf, mit Grund: die Vorlage steht bereits in `DkvMailService`/
|
||
`TenderMailService` (Transport je Versand aus
|
||
`getDecryptedSmtpConfig(tenantId)`), `MailService` müsste dafür nur den
|
||
Mandanten entgegennehmen, den `requestPasswordReset` aus der Funktionszeile
|
||
bereits hat — ein Umbau des Mailmoduls, kein Bindungsumbau, NICHT dieser
|
||
Auftrag (siehe `<hard_constraints>`). **(c) Als benannte Altlast
|
||
weiterführen, mit Markierung** — GEWÄHLT: Methode umbenannt
|
||
(`loadAnySmtpConfigForStartupTransport()`, dkv-Präzedenzfall — ein Name, den
|
||
niemand für einen Anfrageweg hält), Kopfkommentar mit beiden Zuständen,
|
||
Modulkommentar in `mail.module.ts`, eigener Ledger-Eintrag.
|
||
|
||
**Die Unsymmetrie zu BEIDEN Präzedenzfällen:** `getAllActiveConfigs` (ldap)
|
||
ist heute korrekt und verstummt erst später; `loadAnyActiveConfigForScheduler`
|
||
(dkv, WINDOWS #21) ist heute bereits falsch und verstummt zusätzlich später,
|
||
ABER mit einer Protokollzeile ("no active config found — cron job not
|
||
registered"). Der Mail-Startpfad ist heute bereits falsch UND verstummt
|
||
später OHNE Protokollzeile, weil die Rückfallkette ihn überdeckt — das ist
|
||
eine DRITTE Ausprägung, keine der beiden Vorlagen deckt sie vollständig.
|
||
|
||
**Entscheidung: EIGENER Ledger-Eintrag statt Anschluss an #21.** Andere
|
||
Datei (`mail.module.ts`/`settings.service.ts` statt
|
||
`dkv-scheduler.service.ts`), andere Reparatur (Transport je Versand statt
|
||
Mehrmandanten-Planung), andere Verdeckungsform (Rückfallkette statt bloßer
|
||
Leere) — drei eigenständige Unterschiede, kein Wiederholungsfall von #21.
|
||
|
||
**(b) Befund K ist erfüllt.** Die Reihenfolgebedingung aus (t4) Befund K und
|
||
(d4) Übergaben-Absatz — `getDecryptedSmtpConfig(tenantId)` müsse gebunden
|
||
sein, bevor Etappe 4 scharfschaltet — ist mit Aufgabe 2 dieses Laufs
|
||
ERFÜLLT: die Methode läuft seither über GENAU EINEN Klienten `tenantPrisma`.
|
||
Beide Stellen bekommen in Aufgabe 3 einen Nachtrag (siehe (t4), (d4) unten in
|
||
diesem Dokument sowie den Hintergrunddienst-Abschnitt in
|
||
`docs/mandantentrennung-zugriffsklassifikation.md`). Die Etappe-4-Vorabprüfung
|
||
muss diese Bedingung ab jetzt NICHT mehr führen.
|
||
|
||
**(c) Das Frontend.** Die `getSmtpConfig`-Kette aus (s3) wird nicht
|
||
geändert; Ledger-Eintrag in Aufgabe 3 (Familie #28 — dieselbe
|
||
200-leerer-Rumpf-Kette wie `auth`).
|
||
|
||
**(d) Die Mandantenquelle `req.tenantId` im Controller.** Bewusst NICHT auf
|
||
das Claim umgestellt (anders als die Selbstbedienungs-Begründung aus
|
||
`auth`): `settings.controller.ts` ist eine ADMIN-Konfigurationsseite, für
|
||
die `req.tenantId` (per `x-tenant-id` für SUPER_ADMIN umschaltbar) die
|
||
RICHTIGE Quelle ist (D-10) — ein SUPER_ADMIN konfiguriert damit gezielt die
|
||
SMTP-Zugangsdaten eines ANDEREN Mandanten. Der Controller bleibt deshalb
|
||
unverändert und steht nicht in der Erlaubnisliste.
|
||
|
||
**(e) Die Etappe-4-Vorabprüfung.** Für einen bekannten Mandanten die
|
||
`SmtpConfig`-Zeile über die Wartungsrolle lesen und den gebundenen
|
||
`findUnique` daneben halten — dieselbe Form wie bei `dashboard`/`calendar`.
|
||
|
||
### (s5) Was dieser Durchlauf bewusst nicht anfasst
|
||
|
||
- `settings.controller.ts` — nur gelesen (siehe (s4)(d)).
|
||
- `apps/api/src/settings/dto/smtp-config.dto.ts` — nur gelesen, kein
|
||
Mandantenfeld.
|
||
- `tender-mail.service.ts` (`resolveTransport`) — nur gelesen, ruft
|
||
weiterhin `getDecryptedSmtpConfig(tenantId)` unverändert auf.
|
||
- `dkv-mail.service.ts` (`sendExportEmail`) — dieselbe Form.
|
||
- `mail.service.ts` — nur gelesen; `mail.module.ts` wird für die
|
||
Umbenennung des Startpfad-Aufrufs angefasst (Aufgabe 2), hier nur
|
||
angekündigt.
|
||
- Das Frontend (`smtp-settings-form.tsx`, `settings-api.ts`) — nur
|
||
beschrieben, nicht geändert.
|
||
- Schema und Migrationen.
|
||
- `nodemailer` — kein echter Transport in irgendeinem Test dieses Laufs
|
||
(lokal gibt es keinen `mailhog`); in der neuen Testdatei per
|
||
`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 der Mandantentrennung ist mit diesem Lauf (260911-gwh) vollständig:
|
||
jede klassifizierte Fundstelle in `apps/api/src` ist entweder gebunden oder
|
||
mit geschriebenem Grund an der Stelle ungebunden. Die Zahlen unten sind aus
|
||
den eigenen Messanweisungen des Klassifikationsdokuments abgeleitet
|
||
(Übersichtszeilen, Summenzeile, Klassen-Verteilung), nicht neu geschätzt.
|
||
|
||
**Läufe.** Zwölf Bereichs-/Regel-Läufe von 260909-ipc bis 260911-gwh (gezählt
|
||
aus `.planning/STATE.md`, Reihenfolge): `ldap` (260909-ipc), `groups`
|
||
(260909-jts), `tenders` (260909-laa), `dkv` (260909-mir), `user`
|
||
(260910-das), `module-registry` (260910-exd), `dashboard` (260910-krx), das
|
||
Regelschluss-Plan `tenders`/`SearchProvider` (260910-jab), `tenant`
|
||
(260911-e2s), `calendar` (260911-cwh), `auth` (260911-fh9), `favorites`/
|
||
`settings` (260911-gwh, dieser Lauf).
|
||
|
||
**Summenzeile der Übersichtstabelle, vorher/nachher.** Zum Kopf des
|
||
Klassifikationsdokuments (Stand 260909-eor): 227 Rohtreffer über 59
|
||
Datei-Modell-Paare. Nach Aufgabe 3 dieses Laufs — DERIVIERT aus der
|
||
Summenzeile in `docs/mandantentrennung-zugriffsklassifikation.md`, nicht
|
||
abgeschrieben: **68 ungebundene, 178 gebundene Rohtreffer** (Summe 246 —
|
||
mehr als 227, weil Aufgabe 2 dieses Laufs mit dem Widget-Besitzriegel einen
|
||
zusätzlichen gebundenen Rohtreffer einführt, der zur Planungszeit noch nicht
|
||
feststand). Jeder der 68 verbleibenden ungebundenen Rohtreffer ist einer der
|
||
in diesem Dokument (Abschnitte "## Bereich ...") oder im Klassifikationsdokument
|
||
namentlich benannten, bewusst ungebundenen Fälle — siehe die Liste unten.
|
||
|
||
**Klassen-Verteilung.** Siehe `docs/mandantentrennung-zugriffsklassifikation.md`,
|
||
Abschnitt "Klassen-Verteilung", Stand 260911-gwh (Aufgabe 3): **65 Paare**
|
||
(33 `muss-mandantengebunden`, 17 `keine-mandantengebundene-tabelle`, 13
|
||
`beides`, 2 `bewusst-uebergreifend`) — ein neues Paar
|
||
(`favorites.service.ts`/`widgetInstance`) gegenüber den 64 Paaren vor
|
||
diesem Lauf, plus zwei Paare, die nur ihre `Stand`-Spalte ändern
|
||
(`favorites.service.ts`/`favoriteLink`, `settings.service.ts`/`smtpConfig`).
|
||
|
||
**Bewusst ungebundene Reste je Bereich, mit Grund:**
|
||
|
||
- `tenders`: der D-03-Katalog (platform-global, `Tender`/`TenderSource`/
|
||
`TenderSourcePollConfig`), die zwei Fan-out-Adapter (E-Mail/RSS, ein Tick
|
||
pro Postfach bzw. Feed über alle Mandanten), die übergreifenden Hälften
|
||
der beiden Hintergrunddienste (Etappe-3-Übergabe), `createPlatform`/
|
||
`remove` in `tender-rss-feed.service.ts` (WINDOWS #24).
|
||
- `ldap`: `getAllActiveConfigs`/`onApplicationBootstrap` (Befund B),
|
||
`resolveEmailForWrite` (plattformweit eindeutiger Schlüssel, T-IPC-04).
|
||
- `dkv`: der Planer-Startpfad `loadAnyActiveConfigForScheduler()`
|
||
(WINDOWS #21).
|
||
- `user`: `findByUsername` (plattformweit eindeutiger Schlüssel), die
|
||
Erstanlage-Prüfung und beide `tenant`-Zugriffe in `admin-seed.service.ts`,
|
||
der Schleifentreiber `tenant.findMany` der Plattform-Administratorsicht.
|
||
- `module-registry`/`dashboard`: der Modulkatalog (`Module`, keine Regel
|
||
heute — Befund E).
|
||
- `auth`: die drei Anmeldefunktionen (`validateUser`,
|
||
`requestPasswordReset`, `resetPassword`) über die SECURITY-DEFINER-Wege.
|
||
- `tenant`: die Mandantentabelle selbst (`Tenant`, keine eigene
|
||
`tenantId`-Spalte, keine Regel in irgendeiner Migration).
|
||
- `settings`: der sechste Fall der Hintergrunddienst-Falle,
|
||
`loadAnySmtpConfigForStartupTransport()` (dieser Lauf, siehe (s4)(a)).
|
||
|
||
**Offene Ledger-Einträge dieser Etappe** (Nummern siehe `.planning/WINDOWS.md`,
|
||
Kopfzähler geprüft): #18 (Schalter aus), #20 (Verbindungsfehler, an dieselbe
|
||
Bedingung gebunden wie #18), #21 (dkv-Planer-Startpfad), #22
|
||
(plattformweite Eindeutigkeit von `username`/`email`), #23
|
||
(module-registry, unterscheidbares Signal fehlt), #24 (plattformweite
|
||
RSS-Verwaltung unter der Anwendungsrolle), #25 (dashboard, beweisvernichtende
|
||
Fehlerrichtung), #26 (calendar, verschluckte Leere), #27
|
||
(Relationszugriffe für die Bestandsaufnahme unsichtbar, seit 260911-mkj
|
||
geschlossen), #28 (auth,
|
||
verschluckte Leere), plus die drei neuen Einträge dieses Laufs (Startpfad
|
||
des Mailmoduls, verschluckte Leere `favorites`, verschluckte Leere
|
||
`settings` — Nummern siehe Aufgabe 3 dieses Plans).
|
||
|
||
**Prüfungen und Tests.** Werkzeug: siehe die Zeile `Alle N Prüfungen
|
||
bestanden.` des letzten Laufs von `rls-scratch-check.mjs` in dieser Aufgabe.
|
||
Tests: siehe die letzte Testausgabe von `npm --prefix apps/api run test` in
|
||
dieser Aufgabe.
|
||
|
||
**Was für Etappe 3 bleibt:**
|
||
|
||
- Der Anmeldeweg unter je Mandant eindeutigen Namen (`username`/`email`,
|
||
Etappe-3-Entscheidung (1)) — siehe (h4)(a).
|
||
- Die Benutzerdimension der Regeln (Etappe-3-Entscheidung (2)) — siehe
|
||
(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
|
||
eine Regel eingeführt wird, müssen die heute bewusst ungebundenen
|
||
Katalogzugriffe nachgezogen werden.
|
||
- Die Kennzeichnung der `bewusst-uebergreifend`-Stellen (Systemkontext,
|
||
siehe "Die drei Klassen" im Klassifikationsdokument).
|
||
- Der Mandantenwechsel im Ausschreibungs-Digest ((t4), der Sonderfall eines
|
||
Nutzers mit Treffern unter zwei verschiedenen Mandanten).
|
||
|
||
**Was Etappe 4 (`rls-preflight.mjs`) VOR dem Scharfschalten prüfen muss** —
|
||
die in den Bereichsabschnitten benannten Vorabprüfungen, als Liste (OHNE die
|
||
jetzt erfüllte Befund-K-Bedingung, die entfällt):
|
||
|
||
- `ldap`: das Verstummen von `getAllActiveConfigs` (Befund B).
|
||
- `dkv`: das Verstummen des Planer-Startpfads (WINDOWS #21).
|
||
- `tenders`: wachsende Zahl von `TenderMatch`-Zeilen mit `notifiedAt IS NULL`
|
||
ohne Versandprotokoll (t3).
|
||
- `module-registry`: aktive Aktivierungszeilen vorhanden, aber die
|
||
Auflösung liefert für einen bekannten Administrator eine leere Menge
|
||
(WINDOWS #23).
|
||
- `dashboard`: eine physisch vorhandene `DashboardLayout`-Zeile für einen
|
||
bekannten Benutzer, aber der gebundene Lesezugriff liefert `null`
|
||
(WINDOWS #25).
|
||
- `calendar`: physisch vorhandene `CalendarSource`-Zeilen je Mandant über
|
||
die Wartungsrolle zählen und mit der gebundenen Zählung vergleichen
|
||
(k4)(e).
|
||
- `auth`: einen bekannten Benutzer über die Wartungsrolle lesen und den
|
||
gebundenen `findUnique` unter seinem Claim-Mandanten daneben halten
|
||
(WINDOWS #28).
|
||
- `favorites`: für einen bekannten Nutzer/Widget die Favoritenzahl über die
|
||
Wartungsrolle und über den gebundenen `findMany` daneben halten (f4)(d).
|
||
- `settings`: für einen bekannten Mandanten die `SmtpConfig`-Zeile über die
|
||
Wartungsrolle lesen und den gebundenen `findUnique` daneben halten
|
||
(s4)(e).
|
||
- Das Verstummen des Mail-Startpfads (dieser Lauf, (s4)(a)) — EIGENES
|
||
Signal, NICHT an #21 angeschlossen.
|
||
|
||
## Verweis
|
||
|
||
Die Bestandsaufnahme, welche Fundstelle den hier beschriebenen Übergang
|
||
bereits vollzogen hat (Stand-Spalte `gebunden`/`ungebunden`/`gemischt`),
|
||
steht in `docs/mandantentrennung-zugriffsklassifikation.md`.
|