b848ba6baa
- runUserAreaChecks in rls-scratch-check.mjs: 12 neue Pruefungen gegen die
ausgelieferte User-Policy (baut auf der vom Anmeldeweg-Abschnitt
angelegten Tabelle auf, legt zusaetzlich Tenant ohne Zeilenschutz an)
- Belegt: ungebundene Suche nach vorhandenem Benutzernamen liefert 0
Zeilen, gebundene Suche nach fremdem Benutzernamen ebenso ("frei"), und
das anschliessende gebundene INSERT scheitert hart an SQLSTATE 23505
(Eindeutigkeitsverletzung), nicht an 42501 (Zeilenschutz)
- SQLSTATE wird aus err.meta.code gelesen, nicht err.code (das bei
$executeRaw-Fehlern immer den generischen Prisma-Code P2010 traegt,
empirisch gegen tessera-ctl-db-1 geprueft)
- docs/mandantentrennung-etappe2-fehlerrichtung.md: neuer Abschnitt
"Bereich user" (u1-u5) mit der tatsaechlich beobachteten Ausgabe,
Signaltabelle, der vollstaendigen Kette (Befund L) und den Grenzen zu
auth.service.ts/ldap.service.ts
- Teil 2/3 gemessen: keine Transaktion in apps/api/src/user (Befund B
haelt), findByUsername hat genau einen Treffer, die eigene Definition
(Befund D haelt)
- 789 Tests weiterhin gruen, Typpruefung sauber, Wegwerf-Werkzeug meldet
alle 53 Pruefungen bestanden (41 bisherige + 12 neue)
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01AMASaSxv5QMY7RncqZriRR
1190 lines
83 KiB
Markdown
1190 lines
83 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"]
|
|
```
|
|
|
|
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.
|
|
|
|
### (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.
|
|
```
|
|
|
|
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.
|
|
|
|
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 |
|
|
| `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.
|
|
- **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.
|
|
|
|
## Verweis
|
|
|
|
Die Bestandsaufnahme, welche Fundstelle den hier beschriebenen Übergang
|
|
bereits vollzogen hat (Stand-Spalte `gebunden`/`ungebunden`/`gemischt`),
|
|
steht in `docs/mandantentrennung-zugriffsklassifikation.md`.
|