feat(quick-260911-nke): current_user_id(), Benutzerdimension in den Regeln, forTenant() mit userId — ein Pfad

- Neue Migration 20260911120000_rls_user_dimension_personal_tables: current_user_id()
  (NULLIF-gefaltet), zehn persoenliche Tabellen umgestellt (acht als eine Regel,
  SearchProvider/TenderRssFeedSource als je vier befehlsgetrennte Regeln), vier
  Verwaltungstabellen bewusst unveraendert. Lokal angewendet (migrate deploy,
  Prisma-Binary aus apps/api/node_modules/.bin), schema.prisma unveraendert.
- forTenant(prisma, tenantId, userId?): beide set_config in EINER getaggten
  Anweisung, $transaction-Array bleibt bei zwei Eintraegen (WINDOWS #20),
  Leerstring ohne Benutzer statt Weglassen.
- tender-saved-search.service.ts: alle vier forTenant()-Aufrufe reichen userId
  durch; Detektor-Regex bestaetigt 4 Treffer.
- rls-scratch-check.mjs: current_user_id() aus der neuen Migration geschnitten
  (nicht getippt), drei Funktionsfaelle gemessen, neue runUserDimensionChecks()
  mit generiertem Client fuer TenderSavedSearch (vier Wahrheiten + Spaltenabgleich),
  die alte Loch-Pruefung tendersavedsearch-fremder-nutzer-desselben-mandanten-sichtbar
  umgedreht (alte Messung unter neuem Namen erhalten, neue Umkehrung MIT Benutzer).
  sqlStateOf() um Message-Fallback ergaenzt (RLS-Ablehnung ueber generierten
  Client traegt den SQLSTATE nur im Fehlertext, nicht in .meta.code).
- Baseline: 1020/62 Tests, Typpruefung sauber, Werkzeug 146/146 bestanden.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01AMASaSxv5QMY7RncqZriRR
This commit is contained in:
2026-09-11 17:25:45 +02:00
parent 3e57d916a1
commit f0b531b712
7 changed files with 800 additions and 27 deletions
@@ -0,0 +1,221 @@
-- 260911-nke, Etappe 3b — die Benutzerdimension in den Regeln der zehn
-- persoenlichen Tabellen. Schliesst die Klasse von Befunden, die in sieben
-- Bereichs-Kritiken als "Policy hat keine Benutzerdimension" festgehalten
-- wurde (docs/mandantentrennung-etappe2-fehlerrichtung.md).
--
-- Die betroffenen Dateien (20260618112133_rls_policies fuer die urspruengliche
-- Tabellenform der acht Ein-Regel-Tabellen, 20260909140000_rls_remaining_
-- tenant_tables fuer deren zuletzt ausgelieferte Fassung, 20260910120000_rls_
-- widen_membership_grant_and_platform_read fuer TenderRssFeedSource) bleiben
-- UNVERAENDERT stehen — Prisma fuehrt ihre Pruefsumme, eine Aenderung braechte
-- "prisma migrate deploy" zum Abbruch. Praezedenzfall und Kopfform:
-- 20260910120000_rls_widen_membership_grant_and_platform_read.
--
-- 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`
-- (BYPASSRLS). Der Schalter bleibt AUS: diese Regeln sind fuer jeden
-- heutigen Aufrufer wirkungslos, bis Etappe 4 scharfschaltet.
-- Zweite Sitzungsvariable `app.current_user`, Funktion nach dem Muster von
-- `current_tenant_id()` (20260618112133). NULLIF ist Pflicht: `forTenant()`
-- sendet "kein Benutzer" ausdruecklich als Leerstring (nicht als
-- weggelassene Variable) — ohne NULLIF wuerde current_user_id() bei einem
-- Aufruf ohne Benutzer den Leerstring statt NULL liefern, und
-- "userId" = '' waere fuer jede Zeile falsch, nicht gleichbedeutend mit
-- "kein Benutzer gesetzt". Kein GRANT EXECUTE noetig — wie bei
-- current_tenant_id() (20260909130000_rls_app_role vergibt dafuer keines):
-- PostgreSQL vergibt EXECUTE auf Funktionen standardmaessig an PUBLIC.
CREATE OR REPLACE FUNCTION current_user_id() RETURNS TEXT AS $$
SELECT NULLIF(current_setting('app.current_user', true), '');
$$ LANGUAGE sql STABLE;
-- Vierzehn Tabellen tragen eine `userId`-Spalte, zehn davon sind
-- persoenliche Daten und bekommen unten eine Regel. Die vier Ausnahmen
-- bekommen KEINE Anweisung in dieser Migration:
--
-- - GroupMembership: Verwaltungsobjekt — ein Admin muss Mitgliedschaften
-- anderer Nutzer sehen und pflegen koennen, das ist keine persoenliche
-- Zeile des referenzierten Benutzers.
-- - ModuleGrant: Verwaltungsobjekt — dieselbe Begruendung, ein Admin
-- vergibt und sieht Freigaben fuer andere.
-- - PasswordResetToken: Anmelde-Artefakt — wird gelesen, BEVOR ein
-- Benutzer im Sinne von `app.current_user` bekannt ist (der Token IST
-- der Weg, den Benutzer erst zu ermitteln); eine Benutzerdimension hier
-- waere zirkulaer.
-- - TenderMatch: wird vom Hintergrunddienst (tender-matching.service.ts)
-- je Treffer geschrieben, nicht von einem eingeloggten Benutzer direkt;
-- Etappe 3c behandelt Hintergrunddienste gesondert (Systemkontext).
-- Acht Tabellen mit NOT-NULL-`userId`: ein einzelner USING-Ausdruck genuegt,
-- weil Lesen und Schreiben dieselbe Bedingung haben sollen — WITH CHECK
-- folgt USING bei einer Policy ohne FOR-Klausel, ein Einfuegen/Aendern als
-- Benutzer A mit fremder Kennung B faellt damit durch. Die `IS NULL OR`-Form
-- macht die Aenderung fuer jeden Aufruf OHNE gesetzten Benutzer (Admin,
-- Hintergrunddienst) wirkungslos: der sieht weiterhin den ganzen Mandanten,
-- exakt wie vor dieser Migration (bewusste, offene Flanke — siehe
-- .planning/WINDOWS.md). Die Regelnamen bleiben `tenant_isolation_policy`
-- (wie jab bei GroupMembership/ModuleGrant): `extractPolicySql` und
-- `pg_policies` behalten je Tabelle genau eine Regel.
DROP POLICY tenant_isolation_policy ON "CalendarSource";
CREATE POLICY tenant_isolation_policy ON "CalendarSource"
USING (
"tenantId" = current_tenant_id()
AND (current_user_id() IS NULL OR "userId" = current_user_id())
);
DROP POLICY tenant_isolation_policy ON "DashboardLayout";
CREATE POLICY tenant_isolation_policy ON "DashboardLayout"
USING (
"tenantId" = current_tenant_id()
AND (current_user_id() IS NULL OR "userId" = current_user_id())
);
DROP POLICY tenant_isolation_policy ON "FavoriteLink";
CREATE POLICY tenant_isolation_policy ON "FavoriteLink"
USING (
"tenantId" = current_tenant_id()
AND (current_user_id() IS NULL OR "userId" = current_user_id())
);
DROP POLICY tenant_isolation_policy ON "TenderEmailConfig";
CREATE POLICY tenant_isolation_policy ON "TenderEmailConfig"
USING (
"tenantId" = current_tenant_id()
AND (current_user_id() IS NULL OR "userId" = current_user_id())
);
DROP POLICY tenant_isolation_policy ON "TenderNotificationPref";
CREATE POLICY tenant_isolation_policy ON "TenderNotificationPref"
USING (
"tenantId" = current_tenant_id()
AND (current_user_id() IS NULL OR "userId" = current_user_id())
);
DROP POLICY tenant_isolation_policy ON "TenderSavedSearch";
CREATE POLICY tenant_isolation_policy ON "TenderSavedSearch"
USING (
"tenantId" = current_tenant_id()
AND (current_user_id() IS NULL OR "userId" = current_user_id())
);
DROP POLICY tenant_isolation_policy ON "TenderTriage";
CREATE POLICY tenant_isolation_policy ON "TenderTriage"
USING (
"tenantId" = current_tenant_id()
AND (current_user_id() IS NULL OR "userId" = current_user_id())
);
DROP POLICY tenant_isolation_policy ON "WidgetInstance";
CREATE POLICY tenant_isolation_policy ON "WidgetInstance"
USING (
"tenantId" = current_tenant_id()
AND (current_user_id() IS NULL OR "userId" = current_user_id())
);
-- SearchProvider — `userId` ist NULL-faehig (eine gemeinsame, mandanten-
-- gebundene Zeile ohne Besitzer ist erlaubt), die Mandantenhaelfte ist NICHT
-- gelockert: `SearchProvider` bleibt mandantenstreng (260910-jab (4),
-- widerlegte Praemisse aus WINDOWS #19 — es gibt keinen Codeweg, der eine
-- mandantenlose Zeile erzeugt). Vier nach Befehl getrennte Regeln
-- (Praezedenz 260910-jab (3)): ein einzelner USING-Ausdruck, der die
-- gemeinsame Zeile (`userId IS NULL`) zum Lesen einschliesst, wuerde sie
-- ohne Trennung auch zum Aendern/Entfernen freigeben.
DROP POLICY tenant_isolation_policy ON "SearchProvider";
CREATE POLICY tenant_user_read_policy ON "SearchProvider"
FOR SELECT
USING (
"tenantId" = current_tenant_id()
AND (
current_user_id() IS NULL
OR "userId" IS NULL
OR "userId" = current_user_id()
)
);
CREATE POLICY tenant_user_insert_policy ON "SearchProvider"
FOR INSERT
WITH CHECK (
"tenantId" = current_tenant_id()
AND (current_user_id() IS NULL OR "userId" = current_user_id())
);
CREATE POLICY tenant_user_update_policy ON "SearchProvider"
FOR UPDATE
USING (
"tenantId" = current_tenant_id()
AND (current_user_id() IS NULL OR "userId" = current_user_id())
)
WITH CHECK (
"tenantId" = current_tenant_id()
AND (current_user_id() IS NULL OR "userId" = current_user_id())
);
CREATE POLICY tenant_user_delete_policy ON "SearchProvider"
FOR DELETE
USING (
"tenantId" = current_tenant_id()
AND (current_user_id() IS NULL OR "userId" = current_user_id())
);
-- TenderRssFeedSource — loest die vier Regeln aus 20260910120000 ab (WINDOWS
-- #19), unter DENSELBEN NAMEN neu angelegt. Die plattformweite Lesezulassung
-- (`tenantId IS NULL`) und die Mandantenpflicht beim Schreiben aus jener
-- Migration bleiben unveraendert bestehen — hier kommt ausschliesslich die
-- Benutzerdimension hinzu. WINDOWS #24 (Admin-Erstellung/-Entfernen
-- plattformweiter Zeilen bleibt ungebunden) ist von dieser Migration
-- UNBERUEHRT.
DROP POLICY tenant_platform_read_policy ON "TenderRssFeedSource";
DROP POLICY tenant_insert_policy ON "TenderRssFeedSource";
DROP POLICY tenant_update_policy ON "TenderRssFeedSource";
DROP POLICY tenant_delete_policy ON "TenderRssFeedSource";
CREATE POLICY tenant_platform_read_policy ON "TenderRssFeedSource"
FOR SELECT
USING (
("tenantId" = current_tenant_id() OR "tenantId" IS NULL)
AND (
current_user_id() IS NULL
OR "userId" IS NULL
OR "userId" = current_user_id()
)
);
CREATE POLICY tenant_insert_policy ON "TenderRssFeedSource"
FOR INSERT
WITH CHECK (
"tenantId" = current_tenant_id()
AND (current_user_id() IS NULL OR "userId" = current_user_id())
);
CREATE POLICY tenant_update_policy ON "TenderRssFeedSource"
FOR UPDATE
USING (
"tenantId" = current_tenant_id()
AND (current_user_id() IS NULL OR "userId" = current_user_id())
)
WITH CHECK (
"tenantId" = current_tenant_id()
AND (current_user_id() IS NULL OR "userId" = current_user_id())
);
CREATE POLICY tenant_delete_policy ON "TenderRssFeedSource"
FOR DELETE
USING (
"tenantId" = current_tenant_id()
AND (current_user_id() IS NULL OR "userId" = current_user_id())
);
-- Was diese Migration bewusst NICHT tut:
-- - Kein Systemkontext fuer Hintergrunddienste (Etappe 3c) — die `IS NULL
-- OR`-Form macht das fuer heutige Aufrufer unnoetig.
-- - Keine SECURITY-DEFINER-Funktion (Etappe 3a, Anmeldenamen pro Mandant).
-- - Ein Aufrufer, der den Benutzer vergisst (drittes Argument an
-- `forTenant()` nicht setzt), sieht den ganzen Mandanten — heute exakt
-- der Stand VOR dieser Migration, also keine Verschlechterung, aber auch
-- kein Netz dagegen. Siehe .planning/WINDOWS.md fuer den Nachweis, dass
-- dieser Zustand aufgezeichnet, nicht uebersehen wurde.