feat(quick-260910-jab): drei zu kurz greifende RLS-Regeln schliessen (T-JTS-02, T-JTS-03, WINDOWS #19)

- Neue, handgeschriebene Migration 20260910120000_rls_widen_membership_grant_and_platform_read:
  GroupMembership prueft jetzt beide Seiten der Beziehung (Gruppe UND Benutzer),
  ModuleGrant prueft zusaetzlich die referenzierte Gruppe/den referenzierten Benutzer
  (mit Leer-Zulassung, D-04), TenderRssFeedSource bekommt vier nach Befehl getrennte
  Regeln statt einer (Lesen schliesst plattformweite Zeilen ein, Schreiben verlangt
  weiterhin einen Mandanten). SearchProvider bewusst unveraendert (Befund E: Praemisse
  widerlegt). Lokal angewandt und gegen den Systemkatalog der lebenden Datenbank
  gemessen. Der Schalter bleibt aus (Rolle tessera).
- rls-scratch-check.mjs: die drei loch-behauptenden Pruefungen umgekehrt (nicht
  geloescht), Gegenmessungen ueber die Wartungsrolle ergaenzt, vier Befehlsrichtungen
  fuer TenderRssFeedSource gemessen, neuer Abschnitt fuer SearchProvider, Extraktion
  auf die neue Migration umgeleitet und um eine mehrfach-treffer-faehige Form ergaenzt
  (extractAllPolicySql).
- migration-sql.spec.ts: neuer Beschreibungsblock fuer die neue Migration.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01AMASaSxv5QMY7RncqZriRR
This commit is contained in:
2026-09-10 14:29:25 +02:00
parent 93444aa91e
commit f4f3115d5a
3 changed files with 590 additions and 59 deletions
@@ -0,0 +1,124 @@
-- 260910-jab, Aufgabe 1 — schliesst T-JTS-02, T-JTS-03 und WINDOWS #19: drei
-- Regeln, die kuerzer greifen als sie sollen.
--
-- Loest drei ausgelieferte Regeln ab. Die betroffenen Dateien
-- (20260804130130_add_groups_and_module_grants fuer die Tabellenform,
-- 20260804130918_groups_rls_policies fuer die alte GroupMembership/
-- ModuleGrant-Regel, 20260909140000_rls_remaining_tenant_tables fuer die
-- alte TenderRssFeedSource-Regel) bleiben UNVERAENDERT stehen — Prisma
-- fuehrt ihre Pruefsumme, eine Aenderung braechte "prisma migrate deploy"
-- zum Abbruch. Praezedenzfall und Kopfform: 20260909140000_rls_remaining_
-- tenant_tables.
--
-- (1) GroupMembership (T-JTS-02): die ausgelieferte Regel prueft
-- ausschliesslich, ob die referenzierte Gruppe zum laufenden Mandanten
-- gehoert ("groupId" IN (...)) — nicht, ob der referenzierte Benutzer es
-- tut. Eine Mitgliedschaft konnte dadurch eine Gruppe des einen Mandanten
-- mit einem Benutzer eines anderen verbinden. Die neue Regel prueft beide
-- Seiten mit UND verknuepft, nach demselben Join-Muster, das
-- PasswordResetToken seit 20260618112133 fuer die Benutzerseite vormacht.
--
-- (2) ModuleGrant (T-JTS-03): die ausgelieferte Regel prueft ausschliesslich
-- die Mandantenkennung der Zeile selbst — nicht, wohin "groupId"/"userId"
-- zeigen. Eine Freigabe mit korrekter eigener Mandantenkennung, aber
-- fremder Gruppen- ODER fremder Benutzerkennung, wurde durchgelassen. Die
-- neue Regel prueft zusaetzlich beide moeglichen Ziele; die Leer-Zulassung
-- ist zwingend, weil das Modell Gruppe und Benutzer als Entweder-oder fuehrt
-- (D-04, CHECK-Constraint "ModuleGrant_group_xor_user"). WICHTIG:
-- `assertTargetBelongsToTenant` in apps/api/src/groups/module-grants.service.ts
-- bleibt UNVERAENDERT bestehen — diese Datenbankregel ist ein ZWEITES Netz,
-- kein Ersatz dafuer.
--
-- (3) TenderRssFeedSource (WINDOWS #19): "tenantId" ist nullable — NULL
-- markiert eine plattformweite Zeile (D-06). Die ausgelieferte Regel
-- "tenantId" = current_tenant_id() vergleicht NULL nie gleich; eine
-- plattformweite Zeile waere nach dem Scharfschalten fuer JEDEN Mandanten
-- unsichtbar, nicht nur fuer fremde. Ersetzt durch VIER nach Befehl
-- getrennte Regeln: die Leseregel schliesst die plattformweiten Zeilen
-- ausdruecklich ein, die drei Schreibregeln (Einfuegen/Aendern/Entfernen)
-- verlangen weiterhin ausnahmslos einen Mandanten. Vier ausdrueckliche
-- Regeln statt einer mit stillschweigender Wirkung, weil ein einzelner
-- USING-Ausdruck auch bestimmt, welche Zeilen UPDATE und DELETE ueberhaupt
-- erreichen — eine Regel, die die plattformweiten Zeilen zum Lesen
-- einschliesst, wuerde ohne die Trennung jedem Mandanten auch das Aendern
-- und Entfernen dieser Zeilen erlauben.
--
-- (4) SearchProvider — bewusst UNVERAENDERT, keine Anweisung in dieser
-- Migration. "tenantId" ist hier ebenfalls nullable, aber die Praemisse von
-- WINDOWS #19 stimmt fuer diese Tabelle nachweislich NICHT: es gibt keinen
-- Codeweg, der eine mandantenlose Zeile erzeugt — der einzige Schreibweg
-- (apps/api/src/dashboard/dashboard.service.ts) verlangt die
-- Mandantenkennung als Pflichtparameter, und die Vorgabe-Suchmaschinen sind
-- Konstanten (Entscheidung 05-02), keine Datenbankzeilen. Lokal gemessen
-- (2026-09-10): null Zeilen insgesamt in "SearchProvider". Eine Lockerung
-- waere hier die falsche Richtung — sie wuerde eine kuenftige mandantenlose
-- Zeile jedem Mandanten zeigen. Die Schliessung von WINDOWS #19 schliesst
-- diese Haelfte deshalb als WIDERLEGTE PRAEMISSE, nicht als geloestes
-- Problem.
--
-- WICHTIG: wie alle bisherigen RLS-Migrationen wirken diese Regeln erst,
-- wenn die Anwendung als Rolle ohne Umgehungsrecht verbindet (siehe
-- 20260909130000_rls_app_role und docs/mandantentrennung-datenbankrolle.md).
-- Die Verbindung ist zum Zeitpunkt dieser Migration weiterhin NICHT
-- umgestellt — `DATABASE_URL` zeigt unveraendert auf die Rolle `tessera`.
-- Ohne diesen Satz waere diese Datei genau das, wovor WINDOWS #18 warnt:
-- eine Regel, die Sicherheit vortaeuscht.
-- (1) GroupMembership — beide Seiten der Beziehung.
DROP POLICY tenant_isolation_policy ON "GroupMembership";
CREATE POLICY tenant_isolation_policy ON "GroupMembership"
USING (
"groupId" IN (
SELECT "id" FROM "Group" WHERE "tenantId" = current_tenant_id()
)
AND "userId" IN (
SELECT "id" FROM "User" WHERE "tenantId" = current_tenant_id()
)
);
-- (2) ModuleGrant — die Zeile selbst UND beide moeglichen Ziele.
DROP POLICY tenant_isolation_policy ON "ModuleGrant";
CREATE POLICY tenant_isolation_policy ON "ModuleGrant"
USING (
"tenantId" = current_tenant_id()
AND (
"groupId" IS NULL
OR "groupId" IN (
SELECT "id" FROM "Group" WHERE "tenantId" = current_tenant_id()
)
)
AND (
"userId" IS NULL
OR "userId" IN (
SELECT "id" FROM "User" WHERE "tenantId" = current_tenant_id()
)
)
);
-- (3) TenderRssFeedSource — Lesen schliesst die plattformweiten Zeilen ein,
-- Schreiben (Einfuegen/Aendern/Entfernen) verlangt ausnahmslos einen
-- Mandanten. Vier Regeln statt einer, nach Befehl getrennt (Begruendung
-- oben).
DROP POLICY tenant_isolation_policy ON "TenderRssFeedSource";
CREATE POLICY tenant_platform_read_policy ON "TenderRssFeedSource"
FOR SELECT
USING ("tenantId" = current_tenant_id() OR "tenantId" IS NULL);
CREATE POLICY tenant_insert_policy ON "TenderRssFeedSource"
FOR INSERT
WITH CHECK ("tenantId" = current_tenant_id());
CREATE POLICY tenant_update_policy ON "TenderRssFeedSource"
FOR UPDATE
USING ("tenantId" = current_tenant_id())
WITH CHECK ("tenantId" = current_tenant_id());
CREATE POLICY tenant_delete_policy ON "TenderRssFeedSource"
FOR DELETE
USING ("tenantId" = current_tenant_id());
-- (4) SearchProvider — keine Anweisung. Die ausgelieferte Regel
-- ("tenantId" = current_tenant_id()) bleibt unveraendert bestehen.