321 lines
17 KiB
Markdown
321 lines
17 KiB
Markdown
---
|
||
phase: quick-260910-jab
|
||
plan: 01
|
||
subsystem: mandantentrennung-datenbankrolle
|
||
tags: [rls, postgresql, multi-tenancy, security, module-grants, groups, tenders]
|
||
status: complete
|
||
dependency-graph:
|
||
requires: [T-JTS-02, T-JTS-03, WINDOWS-19]
|
||
provides: [T-JAB-01..15-mitigations, rls-widen-migration-20260910120000]
|
||
affects: [apps/api/prisma, apps/api/src/groups, apps/api/src/tenders, apps/api/src/module-registry, apps/api/scripts/rls-scratch-check.mjs]
|
||
tech-stack:
|
||
added: []
|
||
patterns:
|
||
- "Vier nach Befehl getrennte RLS-Policies (SELECT/INSERT/UPDATE/DELETE) statt einer permissiven USING-Klausel, wenn Lesen und Schreiben unterschiedliche Sichtbarkeitsregeln brauchen"
|
||
- "Loch-behauptende Wegwerf-Pruefungen werden UMGEKEHRT statt geloescht, mit Verweis auf den alten Pruefungsnamen und die alte Befundkennung im Meldetext"
|
||
- "Eine Zeile, auf der spaetere Pruefungen aufsetzen, wird nach einer Regelverschaerfung ueber die Wartungsrolle (BYPASSRLS) bereitgestellt, wenn der urspruengliche Erzeugungsweg (gebundenes INSERT) jetzt abgewiesen wird"
|
||
key-files:
|
||
created:
|
||
- apps/api/prisma/migrations/20260910120000_rls_widen_membership_grant_and_platform_read/migration.sql
|
||
modified:
|
||
- apps/api/scripts/rls-scratch-check.mjs
|
||
- apps/api/src/groups/migration-sql.spec.ts
|
||
- apps/api/src/tenders/tender-rss-feed.service.ts
|
||
- apps/api/src/tenders/tender-rss-feed.service.spec.ts
|
||
- apps/api/src/tenders/tenders.controller.ts
|
||
- apps/api/src/tenders/tenders.controller.spec.ts
|
||
- apps/api/src/groups/groups.service.ts
|
||
- apps/api/src/groups/module-grants.service.ts
|
||
- apps/api/src/groups/module-grants.service.spec.ts
|
||
- apps/api/src/module-registry/module-access.service.ts
|
||
- apps/api/src/prisma/rls-coverage.spec.ts
|
||
- docs/mandantentrennung-zugriffsklassifikation.md
|
||
- docs/mandantentrennung-etappe2-fehlerrichtung.md
|
||
- docs/mandantentrennung-datenbankrolle.md
|
||
- .planning/WINDOWS.md
|
||
decisions:
|
||
- "GroupMembership-Regel prueft jetzt beide Seiten der Beziehung (Gruppe UND Benutzer, mit UND verknuepft), nach dem Join-Muster von PasswordResetToken"
|
||
- "ModuleGrant-Regel prueft zusaetzlich beide moeglichen Ziele (Gruppe/Benutzer) mit Leer-Zulassung, weil D-04 Gruppe und Benutzer als Entweder-oder fuehrt"
|
||
- "TenderRssFeedSource bekommt vier nach Befehl getrennte Policies statt einer, weil ein einzelner USING-Ausdruck auch UPDATE/DELETE mitregelt"
|
||
- "SearchProvider bewusst NICHT angefasst — die WINDOWS-#19-Praemisse ist fuer dieses Modell widerlegt (kein Codeweg erzeugt eine mandantenlose Zeile)"
|
||
- "TenderRssFeedSourceService.listForUser wird gebunden (einziger Anwendungscode-Pfad, den die Reparatur sonst still falsch gemacht haette); createPlatform/remove bleiben bewusst ungebunden"
|
||
- "WINDOWS #24 neu angelegt: der Verwaltungsweg fuer plattformweite Zeilen unter der Anwendungsrolle fehlt weiterhin — verschwindet nicht mit der Schliessung von #19"
|
||
metrics:
|
||
duration: "~70min"
|
||
completed: 2026-09-10
|
||
actuals:
|
||
tokens: 34770
|
||
tasks: 3
|
||
commits: 3
|
||
plan_head_before: 93444aa91e8fb0739ee5bbf023f7ccbbd3cee38e
|
||
---
|
||
|
||
# Quick 260910-jab: Die drei zu kurz greifenden Datenbankregeln schliessen — Summary
|
||
|
||
Eine handgeschriebene, lokal angewandte PostgreSQL-Migration schliesst
|
||
T-JTS-02 (GroupMembership prueft nur die Gruppenseite), T-JTS-03 (ModuleGrant
|
||
prueft nur die Mandantenkennung der Zeile, nicht wohin sie zeigt) und
|
||
WINDOWS #19 (plattformweite TenderRssFeedSource-Zeilen waeren nach dem
|
||
Scharfschalten fuer JEDEN Mandanten unsichtbar gewesen); das Wegwerf-Werkzeug
|
||
misst alle drei Reparaturen an der lebenden Datenbank, mit den drei
|
||
loch-behauptenden Pruefungen umgekehrt statt geloescht.
|
||
|
||
## Ausgangslage — selbst gemessen (nicht uebernommen)
|
||
|
||
Zu Beginn von Aufgabe 1 frisch gemessen, gegen HEAD `93444aa`:
|
||
|
||
- **Tests:** 833 bestanden, 56 Dateien (`npm --prefix apps/api run test`)
|
||
- **Typpruefung:** sauber (`npm --prefix apps/api run type-check`)
|
||
- **Wegwerf-Werkzeug:** 66/66 Pruefungen bestanden
|
||
(`rls-scratch-check.mjs` gegen `tessera-ctl-db-1`, Adresse `172.19.0.2`,
|
||
zur Laufzeit ermittelt)
|
||
|
||
Diese drei Zahlen decken sich mit der in der Aufgabenstellung genannten
|
||
Baseline — hier trotzdem selbst nachgemessen, nicht abgeschrieben (die
|
||
Vorgabe der Aufgabenstellung verlangt genau das).
|
||
|
||
## Am Ende gemessen
|
||
|
||
- **Tests:** 839 bestanden, 56 Dateien (+6, alle aus dem neuen
|
||
`migration-sql.spec.ts`-Beschreibungsblock)
|
||
- **Typpruefung:** sauber
|
||
- **Wegwerf-Werkzeug:** 74/74 Pruefungen bestanden (+8: siehe Aufschlüsselung
|
||
unten unter "Neue/umgekehrte Pruefungen")
|
||
|
||
## Bestandszaehlung ueber Mandantengrenzen (Befund H, vor Anwendung der Migration)
|
||
|
||
Gegen die lokale Datenbank `tessera` ausgefuehrt, vor dem Anwenden der neuen
|
||
Migration:
|
||
|
||
```
|
||
memberships-cross-tenant | 0
|
||
grants-cross-tenant-group | 0
|
||
grants-cross-tenant-user | 0
|
||
total-memberships | 3
|
||
total-grants | 4
|
||
total-tenants | 1
|
||
tenderrssfeed-rows | 2
|
||
tenderrssfeed-platform-rows | 1
|
||
searchprovider-rows | 0
|
||
searchprovider-null-tenant | 0
|
||
```
|
||
|
||
Ergebnis null — es gibt lokal keine Zeile, die von der verschärften Regel
|
||
unsichtbar würde. Für das Testsystem ist das NICHT gemessen und muss vor
|
||
Etappe 4 als eigene Vorabprüfung wiederholt werden (siehe Befund H im Plan).
|
||
|
||
## Was gebaut wurde
|
||
|
||
### Aufgabe 1 — Migration + Wegwerf-Werkzeug
|
||
|
||
Neue Migration `20260910120000_rls_widen_membership_grant_and_platform_read`
|
||
(lokal angewandt via `prisma migrate deploy` — **nicht** über `npx prisma`,
|
||
das versuchte ungefragt Prisma 8.0.0-rc.13 herunterzuladen und wurde
|
||
abgebrochen; stattdessen `apps/api/node_modules/.bin/prisma`, die im Projekt
|
||
gepinnte Version 6.19.3, dieselbe, die `rls-scratch-check.mjs` selbst
|
||
verwendet):
|
||
|
||
1. **`GroupMembership`** — die Regel prüft jetzt `groupId IN (...)` UND
|
||
`userId IN (...)`, beide gegen den laufenden Mandanten.
|
||
2. **`ModuleGrant`** — die Regel prüft weiterhin `tenantId = current_tenant_id()`
|
||
UND zusätzlich `groupId IS NULL OR groupId IN (...)` UND
|
||
`userId IS NULL OR userId IN (...)`.
|
||
3. **`TenderRssFeedSource`** — vier Regeln statt einer:
|
||
`tenant_platform_read_policy` (SELECT, schließt `tenantId IS NULL` ein),
|
||
`tenant_insert_policy`/`tenant_update_policy`/`tenant_delete_policy`
|
||
(verlangen ausnahmslos einen Mandanten).
|
||
4. **`SearchProvider`** — unverändert, nur der erklärende Absatz im
|
||
Migrationskopf.
|
||
|
||
Regelliste der lebenden Datenbank nach Anwendung (`pg_policies`, Auszug):
|
||
|
||
```
|
||
GroupMembership#tenant_isolation_policy#ALL#(groupId IN (...) AND userId IN (...))
|
||
ModuleGrant#tenant_isolation_policy#ALL#(tenantId = ... AND (groupId IS NULL OR ...) AND (userId IS NULL OR ...))
|
||
SearchProvider#tenant_isolation_policy#ALL#(tenantId = current_tenant_id())
|
||
TenderRssFeedSource#tenant_delete_policy#DELETE#(tenantId = current_tenant_id())
|
||
TenderRssFeedSource#tenant_insert_policy#INSERT##WITH CHECK (tenantId = current_tenant_id())
|
||
TenderRssFeedSource#tenant_platform_read_policy#SELECT#(tenantId = current_tenant_id() OR tenantId IS NULL)
|
||
TenderRssFeedSource#tenant_update_policy#UPDATE#USING(...)#WITH CHECK(...)
|
||
```
|
||
|
||
**Neue/umgekehrte Pruefungen im Wegwerf-Werkzeug** (66 → 74):
|
||
|
||
| Kennung | Art | Ergebnis |
|
||
|---|---|---|
|
||
| `groupmembership-schreiben-fremder-benutzer-abgelehnt` | Umkehr (T-JTS-02) | bestanden — Insert mit echtem `user-b` abgewiesen |
|
||
| `groupmembership-fremder-benutzer-ueber-die-wartungsrolle-weiterhin-moeglich` | neu | bestanden — Gegenmessung über Wartungsrolle gelingt |
|
||
| `modulegrant-fremde-gruppe-abgelehnt` | Umkehr (T-JTS-03) | bestanden |
|
||
| `modulegrant-fremder-benutzer-abgelehnt` | neu (zweiter D-04-Zweig) | bestanden |
|
||
| `modulegrant-fremde-gruppe-ueber-die-wartungsrolle-weiterhin-moeglich` | neu (legt `grant-foreign-group` an) | bestanden |
|
||
| `tenderrssfeed-plattformzeile-gebunden-sichtbar` | Umkehr (WINDOWS #19) | bestanden |
|
||
| `tenderrssfeed-eigene-zeile-gebunden-weiterhin-sichtbar` | neu | bestanden |
|
||
| `tenderrssfeed-ungebunden-nur-die-plattformzeile` | neu (Befund F) | bestanden |
|
||
| `tenderrssfeed-gebundenes-aendern-der-plattformzeile-abgelehnt` | neu | bestanden |
|
||
| `tenderrssfeed-gebundenes-loeschen-der-plattformzeile-abgelehnt` | neu | bestanden |
|
||
| `searchprovider-mandantenlose-zeile-bleibt-unter-jedem-kontext-unsichtbar` | neu (eigener Bereich) | bestanden |
|
||
|
||
Extraktion umgeleitet: `readRlsWidenMigrationSql()` liest die neue Datei
|
||
für `GroupMembership`/`ModuleGrant`; `extractAllPolicySql()` (neu, mit
|
||
globalem Regex-Flag) liefert alle vier `TenderRssFeedSource`-Policies. Die
|
||
Meldung von `gruppenpfad-gebunden-schliesst-die-fremde-gruppe-aus` im
|
||
Bereich `module-registry` ist richtiggestellt (behauptete vorher fälschlich,
|
||
die Regel lasse die fremde Zeile durch).
|
||
|
||
### Aufgabe 2 — `listForUser` binden
|
||
|
||
`TenderRssFeedSourceService.listForUser(userId, tenantId)` läuft jetzt über
|
||
`forTenant()`. `TendersController.listRssFeeds` reicht die Mandantenkennung
|
||
aus dem Sitzungsnachweis durch (Aufrufform geprüft, nicht die Antwort).
|
||
`createPlatform`/`remove` bleiben bewusst ungebunden. Fünf Aufzeichnungen im
|
||
Quelltext (drei Kopfkommentare in `tender-rss-feed.service.ts`,
|
||
`module-access.service.ts`, `groups.service.ts`, `module-grants.service.ts`,
|
||
`rls-coverage.spec.ts`) nennen die neue Migration und beschreiben die neue
|
||
Regel statt der alten. Die Mandanten-Gegenprüfung
|
||
(`assertTargetBelongsToTenant`) bleibt unverändert bestehen — zwei bestehende
|
||
Testfälle in `module-grants.service.spec.ts` wurden um einen Kommentar
|
||
ergänzt, der festhält, warum sie nach der Regeländerung NICHT entbehrlich
|
||
geworden sind.
|
||
|
||
**Deviation, gemessen statt geglaubt (siehe `<output>`-Vorgabe des Plans):**
|
||
die Behauptung "kein Anwendungscode muss sich ändern" trifft für GENAU EINEN
|
||
Pfad nicht zu — `TenderRssFeedSourceService.listForUser`. Vor der
|
||
Regeländerung lieferte der ungebundene Pfad nach dem Scharfschalten NICHTS
|
||
(gemessen: `tenderrssfeed-ungebunden-nur-die-plattformzeile` würde ohne
|
||
Bindung 0 statt 1 Zeile liefern). Nach der Regeländerung liefert derselbe
|
||
ungebundene Pfad NUR die plattformweiten Zeilen — eine kurze, glaubhafte
|
||
Teilantwort statt einer schreienden Leere. Das Binden gehört deshalb zur
|
||
Reparatur selbst, nicht daneben: ohne diese Bindung hätte 260910-jab einen
|
||
Pfad still von "meldet sich laut" auf "täuscht Vollständigkeit vor"
|
||
verschlechtert.
|
||
|
||
**Falsifizierungsnachweis** (Bindungsaufruf zurückgenommen, Test rot
|
||
gesehen, Rücknahme zurückgenommen):
|
||
|
||
```
|
||
Backup genommen → const tenantPrisma = ... entfernt, this.prisma direkt verwendet
|
||
npm --prefix apps/api run test -- src/tenders/tender-rss-feed.service.spec.ts
|
||
|
||
× listForUser() bindet — forTenant() wird mit der uebergebenen Mandantenkennung aufgerufen
|
||
AssertionError: expected "spy" to be called with arguments: [ {…(4)}, 'tenant-a' ]
|
||
Number of calls: 0
|
||
|
||
Test Files 1 failed (1)
|
||
Tests 1 failed | 30 passed (31)
|
||
|
||
→ Rücknahme rückgängig gemacht (Backup wiederhergestellt), erneuter Lauf: 31/31 grün
|
||
```
|
||
|
||
Genau EIN Test wurde rot, wie erwartet — kein anderer Test hing versehentlich
|
||
an dieser Bindung.
|
||
|
||
**Rule 1 (Auto-Fix):** `listRssFeeds` destrukturierte `feeds.map(({userId, ...rest}) => ...)`.
|
||
Da `listForUser` jetzt über `forTenant(...) as any` läuft, wurde `feeds`
|
||
implizit `any`, und TypeScript meldete `TS7031: Binding element 'ownerUserId'
|
||
implicitly has an 'any' type`. Behoben mit expliziter `(... : any)`-Annotation,
|
||
Muster aus anderen `.map((g: any) => ...)`-Stellen derselben Datei.
|
||
Typpruefung danach wieder sauber.
|
||
|
||
### Aufgabe 3 — Aktenstand kohärent
|
||
|
||
- **`.planning/WINDOWS.md`**: #19 auf `fixed` (Tabelle + JSON-Block,
|
||
`resolved_at: 2026-09-10T12:35:40.000Z`), Beleg nennt die neue Migration
|
||
und die Prüfungen für beide Fehlerrichtungen des Lese-/Schreibsplits.
|
||
#18/#20/#21/#22/#23 unverändert offen. Neuer Eintrag **#24** (offen, Kind
|
||
`deviation`): der Verwaltungsweg für plattformweite Zeilen unter der
|
||
Anwendungsrolle fehlt — weder Anlegen noch Entfernen ist unter der
|
||
Anwendungsrolle möglich, in der alten wie in der neuen Regel. Kopfzahlen
|
||
aus dem JSON-Block abgeleitet und mit einem eigenen Python-Skript
|
||
gegengeprüft (identisch zum Verify-Skript des Plans): 6 offen, 17 behoben,
|
||
1 zurückgestellt, 24 gesamt.
|
||
- **Klassifikation**: #19-Block beantwortet, die vier betroffenen
|
||
Bestandsaufnahme-Zeilen nachgezogen, Übersichtszeile `tenders` (35/27,
|
||
gemessen mit den beiden im Dokument selbst geführten grep-Anweisungen) und
|
||
Summenzeile (107/135) neu aus dem Quelltext abgeleitet — beide mit dem
|
||
exakten `awk`-Prüfskript des Plans gegengeprüft.
|
||
- **Kritikschrift**: neuer Abschnitt `## Regelschluss T-JTS-02, T-JTS-03 und
|
||
WINDOWS #19` mit (r1) tatsächlich beobachteter Ausgabe + Regelliste aus
|
||
der lebenden Datenbank, (r2) Signaltabelle mit beiden Fehlerrichtungen je
|
||
Regel, (r3) den Stellen, an denen Leere weiterhin als Abwesenheit gedeutet
|
||
wird (inklusive der neuen Stelle aus Befund F), (r4) was dieser Durchlauf
|
||
nicht löst, (r5) was er nicht anfasst. Fünf überholte Bestandsstellen mit
|
||
Nachträgen versehen (der Punkt in (g4), der Punkt in (t4), die
|
||
Signaltabellenzeile der drei RSS-Pfade, DREI aufgezeichnete
|
||
Werkzeugausgaben — eine mehr als die im Plan als Minimum genannten zwei,
|
||
weil beim Durchsuchen eine dritte literale Zitatstelle im Abschnitt
|
||
`module-registry` gefunden wurde — und der Punkt in (m4)). Alle alten
|
||
Messprotokolle bleiben wörtlich stehen, die Nachträge stehen daneben.
|
||
- **Selbst ausgeführte Messung zur fehlenden Benutzerdimension** (Aufgabe 3,
|
||
wie vom Plan verlangt): `grep -rn "current_setting\|set_config" apps/api/src
|
||
apps/api/prisma/migrations` findet GENAU EINE Sitzungsvariable,
|
||
`app.current_tenant` (`prisma-tenant.extension.ts`,
|
||
`20260618112133_rls_policies`). Es gibt KEINE zweite Sitzungsvariable für
|
||
den Benutzer — Tabellen wie `TenderSavedSearch` haben deshalb strukturell
|
||
keine Möglichkeit, eine Benutzerdimension auf Datenbankebene durchzusetzen,
|
||
ohne eine solche Variable erst einzuführen. Ergebnis in (r4) der
|
||
Kritikschrift festgehalten, nicht gebaut.
|
||
- **Betriebsanleitung**: die eine Stelle, die #19 als offen führte, nennt
|
||
jetzt den Auflösungsstand und WINDOWS #24.
|
||
|
||
## Deviations from Plan
|
||
|
||
### Auto-fixed Issues
|
||
|
||
**1. [Rule 1 - Bug] `TenderRssFeedSource`-Wegwerf-UPDATE-Prüfung zielte auf
|
||
eine nicht existierende Spalte `label`**
|
||
- **Gefunden während:** Aufgabe 1, erster Lauf des Wegwerf-Werkzeugs nach
|
||
dem Hinzufügen der neuen UPDATE-Prüfung
|
||
- **Problem:** `tenderrssfeed-gebundenes-aendern-der-plattformzeile-abgelehnt`
|
||
versuchte `SET label = ...`, aber die im selben Abschnitt angelegte
|
||
Wegwerf-Tabelle hat keine `label`-Spalte (`id, url, userId, tenantId`) —
|
||
die Prüfung "bestand", aber aus dem FALSCHEN Grund (SQL-Fehler `42703`
|
||
statt der beabsichtigten RLS-Abweisung).
|
||
- **Fix:** Spalte auf `url` geändert, die tatsächlich existiert; danach
|
||
bestand die Prüfung aus dem richtigen Grund (`tenant_update_policy`
|
||
filtert die Zielzeile heraus, 0 betroffene Zeilen).
|
||
- **Dateien:** `apps/api/scripts/rls-scratch-check.mjs`
|
||
- **Commit:** `f4f3115`
|
||
|
||
**2. [Rule 1 - Bug] Implizites `any` beim Destrukturieren in `listRssFeeds`**
|
||
- **Gefunden während:** Aufgabe 2, Typprüfung nach dem Binden von
|
||
`listForUser`
|
||
- **Problem:** `feeds.map(({ userId: ownerUserId, ...rest }) => ...)` löste
|
||
`TS7031` aus, weil `feeds` durch die neue `forTenant(...) as any`-Bindung
|
||
implizit `any` wurde.
|
||
- **Fix:** explizite `(... : any)`-Annotation am Destrukturierungsparameter.
|
||
- **Dateien:** `apps/api/src/tenders/tenders.controller.ts`
|
||
- **Commit:** `6b23735`
|
||
|
||
### Package-Legitimitätsgatter
|
||
|
||
**3. [Rule 3 – ausgeschlossen, keine Installation]** `npx prisma migrate
|
||
deploy` versuchte beim Ausführen ungefragt, `prisma@8.0.0-rc.13`
|
||
herunterzuladen (weiter als die im Projekt gepinnte 6.19.3) — abgebrochen
|
||
(`pkill`), stattdessen `apps/api/node_modules/.bin/prisma` verwendet, die
|
||
lokale, im Lockfile gepinnte Version. Keine Installation fand statt, daher
|
||
kein Checkpoint nötig — dokumentiert, weil es beinahe eine unbeabsichtigte
|
||
Fremdversion in den Migrationslauf eingeschleust hätte.
|
||
|
||
Keine weiteren Abweichungen — alle übrigen Aufgaben wie im Plan beschrieben
|
||
ausgeführt.
|
||
|
||
## Known Stubs
|
||
|
||
Keine.
|
||
|
||
## Threat Flags
|
||
|
||
Keine neuen — das Threat-Model des Plans (T-JAB-01 bis T-JAB-15) deckt alle
|
||
in diesem Durchlauf berührten Flächen bereits ab; keine neue, dort nicht
|
||
erfasste Angriffsfläche entstanden.
|
||
|
||
## Self-Check: PASSED
|
||
|
||
- `apps/api/prisma/migrations/20260910120000_rls_widen_membership_grant_and_platform_read/migration.sql` — FOUND
|
||
- Commit `f4f3115` (Aufgabe 1) — FOUND (`git log --oneline --all | grep f4f3115`)
|
||
- Commit `6b23735` (Aufgabe 2) — FOUND
|
||
- Commit `03fb3bf` (Aufgabe 3) — FOUND
|
||
- `rls-scratch-check.mjs` meldet 74/74 bestanden gegen die lebende Datenbank — bestätigt (letzter Lauf vor diesem Bericht)
|
||
- `.planning/WINDOWS.md` besteht das plan-eigene Python-Kohärenzskript — bestätigt
|
||
- `npm --prefix apps/api run test` — 839/839 grün, `type-check` sauber — bestätigt
|