03fb3bf9c7
- WINDOWS.md: #19 auf fixed gesetzt (Tabelle + JSON-Block), mit Beleg (Migrationsname + benannte Pruefungen) und ausdruecklicher Feststellung, dass die SearchProvider-Haelfte als widerlegte Praemisse schliesst, nicht als geloestes Problem. #18/#20/#21/#22/#23 bleiben unveraendert offen. Ein neuer Eintrag #24 haelt den fehlenden Verwaltungsweg fuer plattformweite Zeilen unter der Anwendungsrolle offen (verschwindet nicht mit #19). Die vier Kopfzahlen sind aus dem JSON-Block abgeleitet (6 offen, 17 behoben, 1 zurueckgestellt, 24 gesamt). - Klassifikation: #19-Block von offener Frage zu beantwortet, die Uebersichtszeile tenders (35/27) und Summenzeile (107/135) aus dem Quelltext neu abgeleitet, vier Bestandsaufnahme-Zeilen nachgezogen (searchProvider, groups.service.ts/user, module-grants.service.ts/ moduleGrant, tender-rss-feed.service.ts/tenderRssFeedSource), der Punkt in "Was diese Etappe NICHT entscheidet" aufgeloest. - Kritikschrift: neuer Abschnitt "Regelschluss T-JTS-02, T-JTS-03 und WINDOWS #19" mit tatsaechlich beobachteter Ausgabe, Regelliste der lebenden Datenbank, Signaltabelle mit beiden Fehlerrichtungen je Regel, der neuen Stelle aus Befund F, der selbst ausgefuehrten Messung zur fehlenden Benutzerdimension (keine zweite Sitzungsvariable gefunden) und dem, was dieser Durchlauf nicht loest/nicht anfasst. Fuenf ueberholte Bestandsstellen mit Nachtraegen versehen (g4, t4, die Signaltabellenzeile zu den RSS-Pfaden, drei aufgezeichnete Werkzeugausgaben, m4), die alten Messprotokolle bleiben woertlich stehen. - Betriebsanleitung: die eine Stelle, die #19 als offen fuehrte, nennt jetzt den Aufloesungsstand und WINDOWS #24. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01AMASaSxv5QMY7RncqZriRR
1735 lines
124 KiB
Markdown
1735 lines
124 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.
|
||
- `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.
|
||
- **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.
|
||
|
||
### (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.
|
||
|
||
**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`.
|
||
|
||
## 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.
|
||
- **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.
|
||
|
||
## Verweis
|
||
|
||
Die Bestandsaufnahme, welche Fundstelle den hier beschriebenen Übergang
|
||
bereits vollzogen hat (Stand-Spalte `gebunden`/`ungebunden`/`gemischt`),
|
||
steht in `docs/mandantentrennung-zugriffsklassifikation.md`.
|