9c0eefee90
- module-registry.service.ts: findActiveForTenant, activateForTenant, isModuleActive binden je einen Aktivierungszugriff, deactivateForTenant bindet beide (Lesen+Schreiben) ueber EINEN Klienten unter tenantPrisma; alle sechs Katalogzugriffe (findAll/findBySlug/beide Existenzpruefungen/isModuleActive-Katalogsuche/seedModule) bleiben bewusst ungebunden, mit Kommentar der Messung von Bedingung trennt - isModuleActive-Kopfkommentar richtiggestellt: der Waechter ruft sie nicht auf (0 Aufrufer, TEIL 3 von Aufgabe 1) — Waechter nimmt findBySlug + ModuleAccessService.getAccessibleModuleIds - module-registry.service.spec.ts: NEU, Zwei-Klienten-Nachweis, deckt die bislang ungetestete Datei mit elf der siebzehn Zugriffe des Bereichs ab, inkl. der lauten (deactivate ohne Aktivierung) und stillen (isModuleActive ohne Aktivierung) Richtung und dem Katalog-Wachhund - tender-scheduler.service.spec.ts: forTenant() auf Identitaet gemockt (dieselbe Konvention wie ldap.service.spec.ts) — cross-area Bruch durch die Umstellung von activateForTenant behoben (Rule 1/3) - docs/mandantentrennung-zugriffsklassifikation.md: alle fuenf handgepflegten Stellen nachgezogen (Bestandsaufnahme, Uebersichtszeile 7/10, Summenzeile 108/134, Klassen-Verteilung unveraendert bei 63 Paaren, Hintergrunddienst-Abschnitt haelt die Abwesenheit eines sechsten Falls fest) — alle gemessen, nicht abgeschrieben, Befund K haelt exakt - docs/mandantentrennung-etappe2-fehlerrichtung.md: Nachtrag mit tatsaechlich umgesetzten Pfaden, beiden Falsifizierungsnachweisen (Testname+Meldung), und der Feststellung zum unveraenderten Controller-Kommentar - .planning/WINDOWS.md: neuer offener Eintrag #23 (deviation) — kein Signal unterscheidet "keine Freigabe" von "Abfrage fand nichts", mit Vorabpruefung fuer Etappe 4 und begruendeter Verwerfung einer Laufzeitwarnung - 833 Tests gruen (56 Dateien), Typpruefung sauber, Wegwerf-Werkzeug 66/66 Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01AMASaSxv5QMY7RncqZriRR
1548 lines
110 KiB
Markdown
1548 lines
110 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.
|
||
|
||
**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.
|
||
```
|
||
|
||
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.
|
||
- **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`.
|
||
|
||
## Verweis
|
||
|
||
Die Bestandsaufnahme, welche Fundstelle den hier beschriebenen Übergang
|
||
bereits vollzogen hat (Stand-Spalte `gebunden`/`ungebunden`/`gemischt`),
|
||
steht in `docs/mandantentrennung-zugriffsklassifikation.md`.
|