docs(quick-260910-jab): drei Datenbankregeln geschlossen und verifiziert

This commit is contained in:
2026-09-10 14:57:20 +02:00
parent 03fb3bf9c7
commit f2051202d8
3 changed files with 495 additions and 7 deletions
@@ -0,0 +1,320 @@
---
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
@@ -0,0 +1,165 @@
---
phase: quick-260910-jab
verified: 2026-09-10T14:55:00Z
status: passed
score: 11/11 must-haves verified
covered_files:
- ".planning/WINDOWS.md"
- ".planning/quick/260910-jab-mandantentrennung-die-drei-zu-kurz-greif/260910-jab-PLAN.md"
- ".planning/quick/260910-jab-mandantentrennung-die-drei-zu-kurz-greif/260910-jab-SUMMARY.md"
- "apps/api/prisma/migrations/20260910120000_rls_widen_membership_grant_and_platform_read/migration.sql"
- "apps/api/scripts/rls-scratch-check.mjs"
- "apps/api/src/groups/groups.service.ts"
- "apps/api/src/groups/migration-sql.spec.ts"
- "apps/api/src/groups/module-grants.service.spec.ts"
- "apps/api/src/groups/module-grants.service.ts"
- "apps/api/src/module-registry/module-access.service.ts"
- "apps/api/src/prisma/rls-coverage.spec.ts"
- "apps/api/src/tenders/tender-rss-feed.service.spec.ts"
- "apps/api/src/tenders/tender-rss-feed.service.ts"
- "apps/api/src/tenders/tenders.controller.spec.ts"
- "apps/api/src/tenders/tenders.controller.ts"
- "docs/mandantentrennung-datenbankrolle.md"
- "docs/mandantentrennung-etappe2-fehlerrichtung.md"
- "docs/mandantentrennung-zugriffsklassifikation.md"
covered_digest: "v1:sha256:4ba76478ddf3d04462ca28c23051e03f469ffd731ae9db235c5cd3e25c803e6f"
behavior_unverified: 0
overrides_applied: 0
---
# Quick 260910-jab: Die drei zu kurz greifenden Datenbankregeln — Verification Report
**Task Goal:** Close T-JTS-02 (`GroupMembership` checked only the group side),
T-JTS-03 (`ModuleGrant` checked its own tenant but not the group/user it
points at) and WINDOWS #19 (nullable `tenantId` would hide platform-wide rows
from every tenant after cutover) — while leaving the written record coherent.
**Verified:** 2026-09-10, independently, against the live database container
`tessera-ctl-db-1` (address `172.19.0.2`, resolved fresh via `docker inspect`)
and the working tree at commits `f4f3115`, `6b23735`, `03fb3bf` (base `93444aa`).
**Status:** passed
## Summary
Every priority item in the verification brief was independently re-checked,
including three destructive falsification experiments (temporarily reverting
each of the three fixed policies in the migration file and re-running the
scratch tool against a throwaway database) to prove the inverted checks
would actually fail if the fix regressed. All three did. Full test suite
(839/839), type-check (clean), and the scratch tool (74/74) were re-run
independently and match the SUMMARY's claims exactly. No discrepancy found.
## Goal Achievement
### Observable Truths
| # | Truth | Status | Evidence |
|---|-------|--------|----------|
| 1 | `GroupMembership` policy checks both sides (group AND user) | ✓ VERIFIED | Live `pg_policies`: `("groupId" IN (...Group...)) AND ("userId" IN (...User...))`. Falsification: reverted to group-only text in migration file, reran scratch tool → `groupmembership-schreiben-fremder-benutzer-abgelehnt` FAILED as expected, then restored (file byte-identical after restore) |
| 2 | `ModuleGrant` policy checks both possible targets (group OR user) with null-allowance, both D-04 branches measured | ✓ VERIFIED | Live `pg_policies` shows `tenantId = ... AND (groupId IS NULL OR ...) AND (userId IS NULL OR ...)`. Falsification: reverted to tenant-only text → both `modulegrant-fremde-gruppe-abgelehnt` and `modulegrant-fremder-benutzer-abgelehnt` FAILED as expected, then restored |
| 3 | `assertTargetBelongsToTenant` in `module-grants.service.ts` unchanged, held by a test that goes red if removed | ✓ VERIFIED | 3 call sites present (`grep -n`); two dedicated spec cases in `module-grants.service.spec.ts:290-312` with an explicit comment (lines 281-289) stating why they'd go red if the app-level guard were dropped |
| 4 | `TenderRssFeedSource` read/write split: bound read returns own + platform rows; write (insert/update/delete) still requires a tenant; both error directions (too strict / too loose) separately measured | ✓ VERIFIED | Live `pg_policies`: 4 command-separated policies; only `tenant_platform_read_policy` (SELECT) contains `IS NULL`, none of insert/update/delete do. Scratch tool: `tenderrssfeed-plattformzeile-gebunden-sichtbar` + `tenderrssfeed-eigene-zeile-gebunden-weiterhin-sichtbar` (too-strict direction) and `tenderrssfeed-gebundenes-aendern-...-abgelehnt` + `...-loeschen-...-abgelehnt` (too-loose direction), all passed independently |
| 5 | The three hole-claiming scratch-tool checks are INVERTED, not deleted/loosened, named for the new truth, referencing the old finding | ✓ VERIFIED | Old check names (`...-nicht-verhindert`, `...-trotz-eigener-mandantenkennung-erlaubt`, `...-unter-jedem-mandanten-unsichtbar`) appear only as backward-references inside the new checks' message text, not as separate passing assertions (`grep` over the whole tool file). New names assert rejection/visibility, matching the fix |
| 6 | `grant-foreign-group` row (dependency for two `module-registry` checks) still gets created, now via the maintenance role, and neither dependent check passes for the wrong reason | ✓ VERIFIED | `runGroupsAreaChecks` creates it via `withAdminPrisma`/BYPASSRLS (line ~913-917) before `runModuleRegistryAreaChecks` runs (both share the same scratch DB in `main()`); `gruppenpfad-gebunden-schliesst-die-fremde-gruppe-aus` message text now correctly says the row was excluded because the maintenance-role insert bypassed a rule that would otherwise reject it — not because the rule "lets it through" |
| 7 | The claim "no application code needs to change" was measured, not assumed; the one path that does (`listForUser`) is bound | ✓ VERIFIED | `listForUser` uses `forTenant(this.prisma, tenantId)`; controller passes `tenantId` from session context (`tenders.controller.ts:267-268`). Falsification: reverted the binding, ran `tender-rss-feed.service.spec.ts` → exactly 1 test failed (`listForUser() bindet...`), 30 passed, matching SUMMARY's claim precisely; restored |
| 8 | Every record describing one of the three old rules now tells the truth, naming the new migration | ✓ VERIFIED | All 4 in-source header comments (`module-access.service.ts`, `groups.service.ts`, `module-grants.service.ts`, `tender-rss-feed.service.ts` x3) and `rls-coverage.spec.ts` description line updated and name `20260910120000_rls_widen_membership_grant_and_platform_read`; classification doc `tenders` row (35/27) and sum row (107/135) independently re-derived from source via the same grep the doc cites and matched exactly; `rls-access-inventory.spec.ts` (10/10) passes |
| 9 | WINDOWS #19 closed with evidence naming the migration and both error-direction checks; #18/#20/#21/#23 remain open; what's NOT solved recorded as its own open entry that doesn't vanish with #19 | ✓ VERIFIED | #19 `status: fixed`, `resolved_at` set, description names the migration and 5 named checks, explicitly frames SearchProvider half as REFUTED PREMISE not solved problem. #18/#20/#21/#23 byte-identical to base commit. New entry #24 (open, `deviation`) names both blocked paths (`createPlatform`/`remove`) and states explicitly this predates 260910-jab. Header counts (6 open/17 fixed/1 waived/24 total) match a fresh count of the JSON-equivalent table rows |
| 10 | Switch stays OFF; `DATABASE_URL` still points at role `tessera` | ✓ VERIFIED | `docker-compose.yml:33` and `.env.example:2` both show role `tessera` (no `_app` suffix), unchanged from base |
| 11 | Baseline held: test count doesn't drop, type-check clean, scratch tool all-passed, at every task boundary | ✓ VERIFIED | Independently re-ran: 839/839 tests (56 files), `tsc --noEmit` clean, `rls-scratch-check.mjs` 74/74 passed — all match SUMMARY's claimed end-state exactly |
**Score:** 11/11 truths verified (0 present-but-behavior-unverified)
### Falsification Experiments (independently performed by this verifier)
Three destructive experiments were run directly against the scratch tool /
migration file, each reverted afterward and confirmed byte-identical to the
original:
1. Reverted `GroupMembership` policy text to group-only → `groupmembership-schreiben-fremder-benutzer-abgelehnt` FAILED (1/74). Restored.
2. Reverted `ModuleGrant` policy text to tenant-only → `modulegrant-fremde-gruppe-abgelehnt` and `modulegrant-fremder-benutzer-abgelehnt` both FAILED (2/74). Restored.
3. Reverted `TenderRssFeedSource` to the old single-policy form → the tenders-area extraction guard correctly aborted with `tenders-policies-aus-migration-gefunden: FEHLGESCHLAGEN` (fail-closed, not a silent wrong measurement) rather than measuring the old text as if it were current. Restored.
4. Reverted `TenderRssFeedSourceService.listForUser`'s `forTenant()` binding → exactly 1 test failed (`listForUser() bindet — forTenant() wird mit der uebergebenen Mandantenkennung aufgerufen`), 30 passed. Restored.
All four confirm the guarding assertions (tests and scratch checks) genuinely
detect the regression they claim to detect — not passing by construction.
### Required Artifacts
| Artifact | Expected | Status | Details |
|----------|----------|--------|---------|
| `apps/api/prisma/migrations/20260910120000_rls_widen_membership_grant_and_platform_read/migration.sql` | New migration, applied locally | ✓ VERIFIED | Exists, `prisma migrate status` reports up to date, live `pg_policies` text matches file text exactly (diffed by hand for GroupMembership/ModuleGrant/TenderRssFeedSource/SearchProvider) |
| `apps/api/scripts/rls-scratch-check.mjs` | 3 inverted checks + gegenmessungen + 4 command directions + extraction redirect | ✓ VERIFIED | `readRlsWidenMigrationSql()`/`extractAllPolicySql()` wired into `runGroupsAreaChecks`/`runTendersAreaChecks`; old extraction (`readGroupsRlsPoliciesMigrationSql`) no longer used for GroupMembership/ModuleGrant |
| `apps/api/src/groups/migration-sql.spec.ts` | New describe block, text-only | ✓ VERIFIED | `rls_widen_membership_grant_and_platform_read migration.sql` block present, 6 tests, all pure regex/string assertions, no DB |
| `apps/api/src/tenders/tender-rss-feed.service.ts` | `listForUser` bound, 3 header comments corrected | ✓ VERIFIED | Confirmed by reading; `createPlatform`/`remove` deliberately still unbound with corrected reasoning pointing at WINDOWS #24 |
| `apps/api/src/tenders/tenders.controller.ts` + both spec files | tenant pass-through, two-client proof | ✓ VERIFIED | `extractTriageContext(req)` supplies `tenantId`; two-client proof in spec uses `__makeBoundClient`/`boundCallLog`, not an identity mock (unbound fake doesn't log, bound one does) |
| `apps/api/src/groups/groups.service.ts`, `module-grants.service.ts`, `module-access.service.ts`, `rls-coverage.spec.ts` | 4 in-source records corrected | ✓ VERIFIED | Read all 4 — each names the new migration and describes the new rule while explicitly retaining the app-level guard as "second net" |
| `docs/mandantentrennung-zugriffsklassifikation.md` | #19 block answered, 4 inventory rows updated, overview/sum rows re-derived | ✓ VERIFIED | tenders row 35/27 matches independently re-run grep exactly; sum row 107/135 matches; `rls-access-inventory.spec.ts` passes (machine-checked binding to this doc) |
| `docs/mandantentrennung-etappe2-fehlerrichtung.md` | New `## Regelschluss...` section, 5 subsections (r1-r5), annotations at superseded spots | ✓ VERIFIED | All 5 subsections present; r1 quotes an actual 74-check run (verified to match reality); r2 signal table covers both error directions per rule; old measurement blocks preserved verbatim with adjacent `NACHTRAG (260910-jab)` annotations (6+ locations found, not rewritten) |
| `docs/mandantentrennung-datenbankrolle.md` | The one #19-as-open spot updated | ✓ VERIFIED | Line 91-94 now says GESCHLOSSEN with migration name |
| `.planning/WINDOWS.md` | #19 fixed, #24 new, counters derived | ✓ VERIFIED | Header counts (6/1/17/24) match row-by-row count; #18/#20/#21/#23 byte-identical to base |
### Key Link Verification
| From | To | Via | Status | Details |
|------|-----|-----|--------|---------|
| `rls-scratch-check.mjs` extraction | `20260910120000_...` migration file | `readRlsWidenMigrationSql()` + `extractAllPolicySql()` | ✓ WIRED | Confirmed by falsification #3 above: reverting the migration file's TenderRssFeedSource text made the tool's own extraction guard fire, proving it reads the live file, not a cached/hardcoded value |
| Groups-area `grant-foreign-group` provisioning | `module-registry`-area dependent checks | shared scratch DB across `main()`'s sequential area-check calls | ✓ WIRED | Confirmed both dependent checks (`gruppenpfad-gebunden-schliesst-die-fremde-gruppe-aus`, `gruppenpfad-ueber-die-wartungsrolle-liefert-die-fremde-gruppe-mit`) pass in the live run and read code confirming ordering |
| `TendersController.listRssFeeds` | `TenderRssFeedSourceService.listForUser` | `extractTriageContext(req).tenantId` passthrough | ✓ WIRED | Read at `tenders.controller.ts:266-268` |
| `TenderRssFeedSource` command-separated policies | live database | migration applied via `prisma migrate deploy`, not `migrate dev`/`reset` | ✓ WIRED | `prisma migrate status` → up to date; `pg_policies` text matches file text |
### Behavioral Spot-Checks / Probe Execution
| Behavior | Command | Result | Status |
|----------|---------|--------|--------|
| Scratch tool passes fully | `node apps/api/scripts/rls-scratch-check.mjs` against live container | `Alle 74 Pruefungen bestanden.` | ✓ PASS |
| Full test suite | `npm --prefix apps/api run test` | 839/839, 56 files | ✓ PASS |
| Type check | `npm --prefix apps/api run type-check` | clean, no output | ✓ PASS |
| `rls-access-inventory.spec.ts` (machine clamp doc↔code) | `npm --prefix apps/api run test -- src/prisma/rls-access-inventory.spec.ts` | 10/10 | ✓ PASS |
| Falsification: GroupMembership reverted | scratch tool against reverted migration text | 1/74 failed (correct check) | ✓ PASS (as regression detector) |
| Falsification: ModuleGrant reverted | scratch tool against reverted migration text | 2/74 failed (correct checks) | ✓ PASS (as regression detector) |
| Falsification: TenderRssFeedSource reverted | scratch tool against reverted migration text | extraction guard fired, 1/62 failed | ✓ PASS (fail-closed, not silently wrong) |
| Falsification: listForUser binding reverted | `npm --prefix apps/api run test -- src/tenders/tender-rss-feed.service.spec.ts` | 1/31 failed (correct test) | ✓ PASS (as regression detector) |
### Requirements Coverage
| Requirement | Source Plan | Description | Status | Evidence |
|-------------|-------------|-------------|--------|----------|
| WINDOWS-19 | 260910-jab-PLAN.md | Platform-wide rows visible to every tenant, not just their own, after cutover | ✓ SATISFIED | 4-policy split live, both error directions measured and independently falsified |
| T-JTS-02 | 260910-jab-PLAN.md | `GroupMembership` policy checks user side too | ✓ SATISFIED | Live policy text confirmed, falsified |
| T-JTS-03 | 260910-jab-PLAN.md | `ModuleGrant` policy checks referenced group/user, not just own tenant | ✓ SATISFIED | Live policy text confirmed, falsified, both D-04 branches measured |
### Anti-Patterns Found
None. Searched all 16 files in `files_modified` for `TBD|FIXME|XXX|TODO|HACK|PLACEHOLDER` — zero hits. No stray stub/empty-return patterns found in the reviewed code.
### Scope Discipline
- `apps/api/prisma/schema.prisma` — untouched (`git diff --stat` empty against base)
- `docker-compose*.yml`, `.env.example`, `.env.prod.example` — untouched
- `DATABASE_URL` still role `tessera` (no `_app` suffix) — switch remains OFF
- `assertTargetBelongsToTenant` present with 3 call/definition sites, unchanged behavior
- WINDOWS #21, #23 — byte-identical to base commit (not touched by this task)
- Full diff scope (16 files changed) matches the plan's declared `files_modified` list exactly, plus `module-grants.service.spec.ts` (listed in SUMMARY key-files, consistent with Aufgabe 2's test-first requirement)
### Human Verification Required
None. All must-haves are verifiable via the live database, the scratch tool,
and static analysis, and were independently re-derived rather than accepted
from the SUMMARY.
### Gaps Summary
No gaps found. This is an unusually well-evidenced quick task: every claim in
the SUMMARY that could be independently re-measured was re-measured (not
re-read), including four destructive falsification experiments this verifier
ran fresh (beyond the two the executor already ran), and every number
matched exactly (74/74, 839/839, 35/27, 107/135, 6/17/1/24). The framing of
the SearchProvider half of WINDOWS #19 as a refuted premise (rather than a
solved problem) is accurate and consistent across the migration header, the
ledger entry, and the classification document.
---
_Verified: 2026-09-10T14:55:00Z_
_Verifier: Claude (gsd-verifier)_