df5c5b728b
tender-digest.scheduler.ts: die uebergreifende Kandidatenabfrage (tenderMatch.findMany mit distinct:['userId']) bleibt bewusst ungebunden und waehlt zusaetzlich das denormalisierte tenantId der Treffer-Zeile mit aus; innerhalb der Schleife binden tenderNotificationPref.findUnique, tenderMatch.findMany/updateMany und user.findUnique an den Mandanten DIESER Kandidatenzeile. tender-matching.service.ts: die Profilabfrage (tenderSavedSearch.findMany) und der Lesezugriff auf den plattformweiten Tender-Katalog (D-03) bleiben ungebunden; innerhalb der Profilschleife bindet die Treffer-Anlage (tenderMatch.upsert), im nachgelagerten Instant-Dispatch binden tenderMatch.findMany/updateMany und user.findUnique — je EIN gebundener Client pro Profil, nicht neu je Treffer. Beide Dateien tragen Codekommentare, die die uebergreifenden Abfragen ausdruecklich als Etappe-3-Uebergabe benennen — die Trennlinie zwischen Etappe 2 und Etappe 3 wird hier gezogen, nicht verwischt. Alle drei betroffenen Testdateien (inkl. der gemeinsamen Integrationsdatei) bekommen den Zwei-Client-Nachweis, Tests fuer Zwei-Mandanten-Laeufe und die lautlose Fehlerform (kein Versand, notifiedAt bleibt NULL). Falsifiziert: ein probeweiser Rueckbau der user.findUnique-Bindung im Instant-Dispatch von tender-matching.service.ts machte genau den erwarteten Test rot, danach zurueckgenommen. rls-access-inventory.spec.ts gemessen und docs/mandantentrennung-zugriffsklassifikation.md nachgezogen: Stand der fuenf betroffenen Paare (tender-digest.scheduler.ts/ tenderMatch,tenderNotificationPref,user; tender-matching.service.ts/ tenderMatch,user) auf gemischt bzw. gebunden; Bereichsuebersicht und Klassen-Verteilung neu gemessen (tenders jetzt 36 ungebunden/26 gebunden); "Der Hintergrunddienst als Falle" um den Abschluss beider Dateien ergaenzt. docs/mandantentrennung-etappe2-fehlerrichtung.md: (t4) um den Nachtrag ergaenzt, dass die Je-Treffer-Haelften geschlossen sind und die uebergreifenden Haelften an Etappe 3 uebergeben bleiben. 770 Tests gruen, Typpruefung sauber, Wegwerf-Werkzeug 32/32, kein Schema-/Migrations-/Compose-/Umgebungsdatei-Diff. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01AMASaSxv5QMY7RncqZriRR
625 lines
43 KiB
Markdown
625 lines
43 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.
|
|
|
|
## Verweis
|
|
|
|
Die Bestandsaufnahme, welche Fundstelle den hier beschriebenen Übergang
|
|
bereits vollzogen hat (Stand-Spalte `gebunden`/`ungebunden`/`gemischt`),
|
|
steht in `docs/mandantentrennung-zugriffsklassifikation.md`.
|