349814747e
rls-scratch-check.mjs bekommt einen fuenften Abschnitt (runTendersAreaChecks) mit den fuenf Policies von TenderEmailConfig/TenderNotificationPref/ TenderRssFeedSource/TenderSavedSearch/TenderTriage, wortgleich aus der ausgelieferten Migration extrahiert. Neun neue Pruefungen belegen: die Policies haben keine Benutzerdimension (Befund E), eine plattformweite RSS-Zeile ist unter jedem Mandantenkontext unsichtbar und ein gebundenes Einfuegen ohne Mandant wird abgewiesen (WINDOWS #19), und ein gebundenes upsert auf eine unsichtbare Zeile verletzt die Eindeutigkeitsbedingung, nicht die Policy (Befund F). Alle 32 Pruefungen (23 bisherige + 9 neue) bestehen. Nachmessung bestaetigt Befund A: genau eine $transaction in diesem Bereich, Array-Form auf der plattformweiten Tabelle Tender, ausserhalb jeder Mandantenbindung — withTenantTransaction() wird hier nicht gebraucht. docs/mandantentrennung-etappe2-fehlerrichtung.md bekommt einen `## Bereich tenders`-Abschnitt mit der beobachteten Messausgabe, einer Signaltabelle je umzustellendem Pfad und einem eigenen Unterabschnitt zur lautlosen Fehlerform der beiden Hintergrunddienste (fuenf benannte Stellen, keine protokolliert etwas). 743 Tests gruen, Typpruefung sauber, kein Schema-/Migrations-/Compose-/ Umgebungsdatei-Diff. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01AMASaSxv5QMY7RncqZriRR
600 lines
41 KiB
Markdown
600 lines
41 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".
|
|
- **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`.
|