4 Commits

Author SHA1 Message Date
schalli b62a905adb docs(quick-260911-nke): Aktenstand kohaerent — Regelschluss Benutzerdimension, Nachtraege, Ledger
Tessera CI/CD / Lint & Type Check (push) Successful in 46s
Tessera CI/CD / Tests (push) Successful in 52s
Tessera CI/CD / Build & Publish Images (push) Successful in 27s
- Neuer Abschnitt "Regelschluss Benutzerdimension (Etappe 3b, 260911-nke)"
  in der Kritikschrift mit b1 (woertliche Werkzeugausgabe + pg_policies-Liste),
  b2 (Signaltabelle beide Fehlerrichtungen), b3 (NotFound-statt-Forbidden
  je Methode), b4/b5 (bewusst nicht geloest/angefasst). Zehn datierte
  Nachtraege an allen Stellen, die zuvor "keine Benutzerdimension" als
  Stand beschrieben (t1/t4/r4/w1/w4/k1/k4/f1/f4/Abschluss) — historische
  Messung bleibt lesbar.
- Klassifikation: drei Bestandsaufnahme-Zeilen (calendarSource,
  widgetInstance, favoriteLink) mit Zusatz "Benutzerdimension seit
  20260911120000 (260911-nke)"; neuer Punkt "Aufgelöst (260911-nke)" im
  Abschnitt "Was diese Etappe NICHT entscheidet"; neuer Stand-Absatz —
  Paarzahl (72) und Klassen-Verteilung bleiben unveraendert.
- Betriebsanleitung: `forTenant(prisma, tenantId, userId?)` und die zehn/
  vier-Tabellen-Aufteilung nachgezogen.
- Datenbankrolle: neuer Absatz zu `app.current_user`/`current_user_id()`
  neben `app.current_tenant`; SECURITY-DEFINER-Kopfkommentare unangetastet.
- Auftrag: 3b als erledigt markiert (Migrationsname, sechs statt drei
  Umkehrungen, Endzahlen); 3a/3c unveraendert.
- WINDOWS.md: neuer Eintrag #34 (open, deviation) fuer die bewusst offene
  Flanke — Aufrufer ohne userId sieht den ganzen Mandanten, kein Waechter
  gebaut.
- Baseline: 1020/62 Tests, Typpruefung sauber, Werkzeug 203/203 bestanden.
  Erlaubnisliste gegen 8829999 eingehalten, schema.prisma/Compose/.env/3a
  unveraendert.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01AMASaSxv5QMY7RncqZriRR
2026-09-11 17:47:14 +02:00
schalli 07fc653f52 feat(quick-260911-nke): Benutzer an 34 Aufrufstellen gesetzt, zehn Tabellen gemessen, sechs Pruefungen umgedreht
- 30 verbleibende forTenant()-Aufrufstellen in sieben Diensten (calendar 6,
  dashboard 9, favorites 5, tender-email-config 3, tender-notification-pref 2,
  tender-rss-feed 2, tender-triage 3) reichen userId als drittes Argument
  durch. tender-digest.scheduler.ts bleibt zweistellig (Hintergrunddienst,
  Etappe 3c), mit Begruendung im Kommentar. Keine Methodensignatur, kein
  Controller angefasst, keine anwendungsseitige userId-Filterung entfernt.
- rls-scratch-check.mjs: zwoelf Extraktionsstellen auf die neue Migration
  umgeleitet (TenderEmailConfig/TenderNotificationPref/TenderSavedSearch/
  TenderTriage/TenderRssFeedSource in runTendersAreaChecks, SearchProvider in
  runSearchProviderAreaChecks/runDashboardAreaChecks, DashboardLayout/
  WidgetInstance, CalendarSource/FavoriteLink samt regelstand-eindeutig-Gates).
  SearchProvider/TenderRssFeedSource jetzt mit extractAllPolicySql (4 Regeln).
  runUserDimensionChecks() um die uebrigen neun Tabellen erweitert (neue
  Routine runCommandSeparatedPersonalTableCheck fuer die zwei NULL-faehigen
  Tabellen inkl. gemeinsame-Zeile-Pruefungen).
- Sechs Loch-Pruefungen umgedreht (dashboardlayout, widgetinstance,
  searchprovider, calendarsource, favoritelink-Doppelaussage getrennt) —
  alte Messung ohne Benutzer bleibt unter neuem Namen, Umkehrung MIT
  Benutzer erwartet das Gegenteil; kein alter Name mehr als Kennung.
- Baseline: 1020/62 Tests weiterhin gruen, Typpruefung sauber, Werkzeug
  203/203 bestanden (vorher 146).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01AMASaSxv5QMY7RncqZriRR
2026-09-11 17:39:17 +02:00
schalli f0b531b712 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
2026-09-11 17:25:45 +02:00
schalli 3e57d916a1 docs(quick-260911-nke): Plan fuer Etappe 3b, Benutzerdimension
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01AMASaSxv5QMY7RncqZriRR
2026-09-11 17:12:57 +02:00
29 changed files with 2128 additions and 139 deletions
+16 -3
View File
@@ -1,10 +1,10 @@
--- ---
schema_version: 1 schema_version: 1
open_count: 14 open_count: 15
waived_count: 1 waived_count: 1
fixed_count: 18 fixed_count: 18
total_count: 33 total_count: 34
last_updated: 2026-09-11T14:48:15.447Z last_updated: 2026-09-11T15:46:08.295Z
--- ---
# Broken Windows Ledger # Broken Windows Ledger
@@ -48,6 +48,7 @@ last_updated: 2026-09-11T14:48:15.447Z
| 31 | quick-260911-gwh | deviation | apps/web/src/components/dashboard/widgets/favorites-widget.tsx | | Bereich favorites: ein nach dem Scharfschalten (#18) zu klein gebliebenes Leseergebnis auf list() sieht aus wie 'Noch keine Favoriten.' (favorites-widget.tsx Zeile um 212, de.json favorites.empty) -- fetchFavorites (favorites-api.ts) reicht die leere Liste durch. 'Nie einen gespeichert' und 'Zeile unsichtbar' sind fuer das Frontend derselbe Wert []. Etappe-4-Vorabpruefung (f4)(d): fuer einen bekannten Nutzer/Widget die Favoritenzahl ueber die Wartungsrolle und ueber den gebundenen findMany daneben halten. Familie #23/#25/#26/#28. Das Frontend wird von 260911-gwh NICHT geaendert. Siehe docs/mandantentrennung-etappe2-fehlerrichtung.md, Abschnitt 'Bereich favorites' (f3)/(f4). | open | | 2026-09-11T11:57:50.276Z | | | 31 | quick-260911-gwh | deviation | apps/web/src/components/dashboard/widgets/favorites-widget.tsx | | Bereich favorites: ein nach dem Scharfschalten (#18) zu klein gebliebenes Leseergebnis auf list() sieht aus wie 'Noch keine Favoriten.' (favorites-widget.tsx Zeile um 212, de.json favorites.empty) -- fetchFavorites (favorites-api.ts) reicht die leere Liste durch. 'Nie einen gespeichert' und 'Zeile unsichtbar' sind fuer das Frontend derselbe Wert []. Etappe-4-Vorabpruefung (f4)(d): fuer einen bekannten Nutzer/Widget die Favoritenzahl ueber die Wartungsrolle und ueber den gebundenen findMany daneben halten. Familie #23/#25/#26/#28. Das Frontend wird von 260911-gwh NICHT geaendert. Siehe docs/mandantentrennung-etappe2-fehlerrichtung.md, Abschnitt 'Bereich favorites' (f3)/(f4). | open | | 2026-09-11T11:57:50.276Z | |
| 32 | quick-260911-gwh | deviation | apps/web/src/components/settings/smtp-settings-form.tsx | | Bereich settings: getSmtpConfig liefert nach dem Scharfschalten (#18) null, der Controller antwortet 200 mit leerem Rumpf, fetchSmtp (settings-api.ts) laeuft mit res.json() auf den leeren Rumpf und wirft, smtp-settings-form.tsx verschluckt das in .catch(() => {}) -- leeres Formular 'nicht eingerichtet', waehrend die Zugangsdaten physisch da sind. Ein erneutes Speichern unter der ungebundenen Form scheitert am Eindeutigkeitsindex SmtpConfig_tenantId_key (PrismaClientUnknownRequestError, gemessen in Aufgabe 1 Pruefung 8) -- nach diesem Lauf ist saveSmtpConfig gebunden und trifft die eigene Zeile, dieser Rest bestand nur unter der ungebundenen Form vor dieser Aenderung. Dieselbe 200-leerer-Rumpf-Kette wie #28. Etappe-4-Vorabpruefung (s4)(e). Das Frontend wird von 260911-gwh NICHT geaendert. Siehe docs/mandantentrennung-etappe2-fehlerrichtung.md, Abschnitt 'Bereich settings' (s3)/(s4). | open | | 2026-09-11T11:57:50.484Z | | | 32 | quick-260911-gwh | deviation | apps/web/src/components/settings/smtp-settings-form.tsx | | Bereich settings: getSmtpConfig liefert nach dem Scharfschalten (#18) null, der Controller antwortet 200 mit leerem Rumpf, fetchSmtp (settings-api.ts) laeuft mit res.json() auf den leeren Rumpf und wirft, smtp-settings-form.tsx verschluckt das in .catch(() => {}) -- leeres Formular 'nicht eingerichtet', waehrend die Zugangsdaten physisch da sind. Ein erneutes Speichern unter der ungebundenen Form scheitert am Eindeutigkeitsindex SmtpConfig_tenantId_key (PrismaClientUnknownRequestError, gemessen in Aufgabe 1 Pruefung 8) -- nach diesem Lauf ist saveSmtpConfig gebunden und trifft die eigene Zeile, dieser Rest bestand nur unter der ungebundenen Form vor dieser Aenderung. Dieselbe 200-leerer-Rumpf-Kette wie #28. Etappe-4-Vorabpruefung (s4)(e). Das Frontend wird von 260911-gwh NICHT geaendert. Siehe docs/mandantentrennung-etappe2-fehlerrichtung.md, Abschnitt 'Bereich settings' (s3)/(s4). | open | | 2026-09-11T11:57:50.484Z | |
| 33 | quick-260911-mkj | unmet-truth | apps/api/src/tenders/tenders.seed.ts | | Modellaufrufe auf Empfaengern, die weder this.prisma noch eine const X = forTenant(-Zuweisung noch ein Transaktionsparameter sind, sind fuer ALLE vier Erkennungsformen der Bestandsaufnahme unsichtbar. Gemessen 260911-mkj: tenders/tenders.seed.ts (Funktionsparameter prisma: PrismaService, tenderRssFeedSource.findFirst/create, kein Eintrag in der Bestandsaufnahme) und tenders/backfill-tender-source.ts (eigenstaendiges Skript mit new PrismaClient(), tender.findMany/update, durch RELATION_SPEC_EXCEPTIONS laut gehalten). Beide beruehren nur den plattformglobalen Katalog bzw. die plattformweite RSS-Verwaltung (WINDOWS #24), heute ungefaehrlich; OFFEN ist der Mechanismus (ein kuenftiger Dienst mit Parameter-Empfaenger auf einer geschuetzten Tabelle bliebe unsichtbar). Zu schliessen vor Etappe 4 durch eine Zaehlung ALLER <Kennung>.<Modell>.<Operation>(-Anker gegen die bekannte Empfaengermenge, Ueberschuss laut. | open | | 2026-09-11T14:48:09.723Z | | | 33 | quick-260911-mkj | unmet-truth | apps/api/src/tenders/tenders.seed.ts | | Modellaufrufe auf Empfaengern, die weder this.prisma noch eine const X = forTenant(-Zuweisung noch ein Transaktionsparameter sind, sind fuer ALLE vier Erkennungsformen der Bestandsaufnahme unsichtbar. Gemessen 260911-mkj: tenders/tenders.seed.ts (Funktionsparameter prisma: PrismaService, tenderRssFeedSource.findFirst/create, kein Eintrag in der Bestandsaufnahme) und tenders/backfill-tender-source.ts (eigenstaendiges Skript mit new PrismaClient(), tender.findMany/update, durch RELATION_SPEC_EXCEPTIONS laut gehalten). Beide beruehren nur den plattformglobalen Katalog bzw. die plattformweite RSS-Verwaltung (WINDOWS #24), heute ungefaehrlich; OFFEN ist der Mechanismus (ein kuenftiger Dienst mit Parameter-Empfaenger auf einer geschuetzten Tabelle bliebe unsichtbar). Zu schliessen vor Etappe 4 durch eine Zaehlung ALLER <Kennung>.<Modell>.<Operation>(-Anker gegen die bekannte Empfaengermenge, Ueberschuss laut. | open | | 2026-09-11T14:48:09.723Z | |
| 34 | quick-260911-nke | deviation | apps/api/src/prisma/prisma-tenant.extension.ts | | Etappe 3b: ein Nutzer-CRUD-Aufrufer, der den Benutzer an forTenant() vergisst, sieht den ganzen Mandanten (IS-NULL-Form) — gleicher Stand wie vor 20260911120000, keine Verschlechterung, aber kein Netz. Die Bestandsaufnahme unterscheidet nur mandanten-gebunden/ungebunden, nicht benutzer-gebunden; ein Waechter, der jede Methode mit userId-Parameter auf das dritte Argument prueft, ist NICHT gebaut. Bis dahin sind die dreistelligen Spec-Zusicherungen je Dienst das einzige Netz. Vor dem Scharfschalten (Etappe 4, rls-preflight.mjs) zu entscheiden: Waechter bauen oder Rest benennen. | open | | 2026-09-11T15:46:08.295Z | |
````json ````json
[ [
@@ -446,6 +447,18 @@ last_updated: 2026-09-11T14:48:15.447Z
"reason": "", "reason": "",
"recorded_at": "2026-09-11T14:48:09.723Z", "recorded_at": "2026-09-11T14:48:09.723Z",
"resolved_at": null "resolved_at": null
},
{
"id": 34,
"kind": "deviation",
"phase": "quick-260911-nke",
"file": "apps/api/src/prisma/prisma-tenant.extension.ts",
"line": null,
"description": "Etappe 3b: ein Nutzer-CRUD-Aufrufer, der den Benutzer an forTenant() vergisst, sieht den ganzen Mandanten (IS-NULL-Form) — gleicher Stand wie vor 20260911120000, keine Verschlechterung, aber kein Netz. Die Bestandsaufnahme unterscheidet nur mandanten-gebunden/ungebunden, nicht benutzer-gebunden; ein Waechter, der jede Methode mit userId-Parameter auf das dritte Argument prueft, ist NICHT gebaut. Bis dahin sind die dreistelligen Spec-Zusicherungen je Dienst das einzige Netz. Vor dem Scharfschalten (Etappe 4, rls-preflight.mjs) zu entscheiden: Waechter bauen oder Rest benennen.",
"status": "open",
"reason": "",
"recorded_at": "2026-09-11T15:46:08.295Z",
"resolved_at": null
} }
] ]
```` ````
File diff suppressed because one or more lines are too long
@@ -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.
File diff suppressed because it is too large Load Diff
@@ -238,6 +238,8 @@ describe('CalendarService — Bindung an forTenant() (260911-cwh)', () => {
expect(result).toHaveLength(1); expect(result).toHaveLength(1);
expect(result[0].hasCredentials).toBe(true); expect(result[0].hasCredentials).toBe(true);
expect((result[0] as any).encryptedPassword).toBeUndefined(); expect((result[0] as any).encryptedPassword).toBeUndefined();
// Benutzerdimension (260911-nke): forTenant() bekommt userId als drittes Argument.
expect(forTenant).toHaveBeenCalledWith(prisma, 't1', 'user-a1');
}); });
it('getSources von Nutzer A liefert NICHT die Quellen von Nutzer B desselben Mandanten — der userId-Filter bleibt, die Bindung ergaenzt ihn', async () => { it('getSources von Nutzer A liefert NICHT die Quellen von Nutzer B desselben Mandanten — der userId-Filter bleibt, die Bindung ergaenzt ihn', async () => {
+18 -12
View File
@@ -108,13 +108,19 @@ const CACHE_TTL_MS = 5 * 60 * 1000;
* The three ownership checks (`updateSource`/`deleteSource`/ * The three ownership checks (`updateSource`/`deleteSource`/
* `testConnection`, comparing `existing.userId` against the calling user) * `testConnection`, comparing `existing.userId` against the calling user)
* are kept UNCHANGED alongside the binding, not replaced by it: the RLS * are kept UNCHANGED alongside the binding, not replaced by it: the RLS
* policy on `CalendarSource` carries no user dimension (measured * policy on `CalendarSource` carried no user dimension when measured
* 260911-cwh, Aufgabe 1 — `calendarsource-fremder-nutzer-desselben-mandanten-gebunden-sichtbar`), * 260911-cwh, Aufgabe 1 (`calendarsource-fremder-nutzer-desselben-mandanten-gebunden-sichtbar`),
* so a colleague of the SAME tenant would otherwise see and modify a * so a colleague of the SAME tenant would otherwise see and modify a
* fellow user's encrypted Exchange/CalDAV credentials. Until the RLS * fellow user's encrypted Exchange/CalDAV credentials.
* policy itself gains a user dimension (Etappe-3-Entscheidung (2)), these *
* application-level checks remain the only protection between users of the * Nachtrag (260911-nke, Etappe 3b): seit Migration 20260911120000 traegt die
* same tenant. * `tenant_isolation_policy` auf `CalendarSource` die Benutzerdimension
* (`current_user_id() IS NULL OR "userId" = current_user_id()`) — jeder
* `forTenant()`-Aufruf oben reicht `userId` als drittes Argument durch. Die
* drei anwendungsseitigen Besitzpruefungen bleiben trotzdem UNVERAENDERT
* bestehen: die Datenbankregel ist ein ZWEITES Netz, kein Ersatz dafuer, und
* ein Aufrufer, der `userId` vergisst, saehe ohne sie den ganzen Mandanten
* (siehe .planning/WINDOWS.md).
* *
* Credentials encrypted at rest via CryptoService (T-05-10). * Credentials encrypted at rest via CryptoService (T-05-10).
*/ */
@@ -155,7 +161,7 @@ export class CalendarService {
* Adds a `hasCredentials` boolean so the UI knows if credentials are set. * Adds a `hasCredentials` boolean so the UI knows if credentials are set.
*/ */
async getSources(userId: string, tenantId: string) { async getSources(userId: string, tenantId: string) {
const tenantPrisma = forTenant(this.prisma, tenantId); const tenantPrisma = forTenant(this.prisma, tenantId, userId);
const sources = await tenantPrisma.calendarSource.findMany({ const sources = await tenantPrisma.calendarSource.findMany({
where: { userId }, where: { userId },
select: { select: {
@@ -195,7 +201,7 @@ export class CalendarService {
data.encryptedPassword = this.crypto.encrypt(dto.password); data.encryptedPassword = this.crypto.encrypt(dto.password);
} }
const tenantPrisma = forTenant(this.prisma, tenantId); const tenantPrisma = forTenant(this.prisma, tenantId, userId);
const created = await tenantPrisma.calendarSource.create({ const created = await tenantPrisma.calendarSource.create({
data: data as any, data: data as any,
select: SOURCE_SAFE_SELECT, select: SOURCE_SAFE_SELECT,
@@ -209,7 +215,7 @@ export class CalendarService {
* Re-encrypts password if provided; T-05-12 ownership enforcement. * Re-encrypts password if provided; T-05-12 ownership enforcement.
*/ */
async updateSource(id: string, userId: string, tenantId: string, dto: UpdateCalendarSourceDto) { async updateSource(id: string, userId: string, tenantId: string, dto: UpdateCalendarSourceDto) {
const tenantPrisma = forTenant(this.prisma, tenantId); const tenantPrisma = forTenant(this.prisma, tenantId, userId);
const existing = await tenantPrisma.calendarSource.findUnique({ const existing = await tenantPrisma.calendarSource.findUnique({
where: { id }, where: { id },
select: { userId: true, type: true }, select: { userId: true, type: true },
@@ -261,7 +267,7 @@ export class CalendarService {
* Deletes a calendar source. Ownership check enforced (T-05-12). * Deletes a calendar source. Ownership check enforced (T-05-12).
*/ */
async deleteSource(id: string, userId: string, tenantId: string) { async deleteSource(id: string, userId: string, tenantId: string) {
const tenantPrisma = forTenant(this.prisma, tenantId); const tenantPrisma = forTenant(this.prisma, tenantId, userId);
const existing = await tenantPrisma.calendarSource.findUnique({ const existing = await tenantPrisma.calendarSource.findUnique({
where: { id }, where: { id },
select: { userId: true }, select: { userId: true },
@@ -283,7 +289,7 @@ export class CalendarService {
* Updates lastSyncAt/lastSyncError on the source record. * Updates lastSyncAt/lastSyncError on the source record.
*/ */
async testConnection(id: string, userId: string, tenantId: string): Promise<{ success: boolean; error?: string }> { async testConnection(id: string, userId: string, tenantId: string): Promise<{ success: boolean; error?: string }> {
const tenantPrisma = forTenant(this.prisma, tenantId); const tenantPrisma = forTenant(this.prisma, tenantId, userId);
const source = await tenantPrisma.calendarSource.findUnique({ where: { id } }); const source = await tenantPrisma.calendarSource.findUnique({ where: { id } });
if (!source) throw new NotFoundException('Calendar source not found'); if (!source) throw new NotFoundException('Calendar source not found');
if (source.userId !== userId) throw new ForbiddenException('Not your calendar source'); if (source.userId !== userId) throw new ForbiddenException('Not your calendar source');
@@ -399,7 +405,7 @@ export class CalendarService {
to: Date, to: Date,
cacheKey: string, cacheKey: string,
): Promise<CalendarEvent[]> { ): Promise<CalendarEvent[]> {
const tenantPrisma = forTenant(this.prisma, tenantId); const tenantPrisma = forTenant(this.prisma, tenantId, userId);
const sources = await tenantPrisma.calendarSource.findMany({ const sources = await tenantPrisma.calendarSource.findMany({
where: { userId, isVisible: true }, where: { userId, isVisible: true },
}); });
@@ -388,6 +388,8 @@ describe('DashboardService — Anordnung und Widgets gebunden an forTenant() (26
expect(result).toEqual({ lg: [{ i: 'w1' }] }); expect(result).toEqual({ lg: [{ i: 'w1' }] });
expectBoundCall(prisma, 'tenant-1', 'dashboardLayout', 'findUnique'); expectBoundCall(prisma, 'tenant-1', 'dashboardLayout', 'findUnique');
// Benutzerdimension (260911-nke): forTenant() bekommt userId als drittes Argument.
expect(forTenant).toHaveBeenCalledWith(prisma, 'tenant-1', 'user-1');
}); });
it('getLayout: kein Widget/keine Anordnung vorhanden liefert die Vorgabeanordnung, keinen Fehler — heutiges Verhalten, damit eine spätere Änderung sichtbar wird', async () => { it('getLayout: kein Widget/keine Anordnung vorhanden liefert die Vorgabeanordnung, keinen Fehler — heutiges Verhalten, damit eine spätere Änderung sichtbar wird', async () => {
+26 -11
View File
@@ -58,12 +58,27 @@ const DEFAULT_SEARCH_PROVIDERS = [
* three ownership checks in this file (`updateWidgetConfig`, `removeWidget`, * three ownership checks in this file (`updateWidgetConfig`, `removeWidget`,
* `removeSearchProvider`) compare against the user id from the session proof * `removeSearchProvider`) compare against the user id from the session proof
* and are NOT decorative: the RLS rules on `DashboardLayout`, `WidgetInstance` * and are NOT decorative: the RLS rules on `DashboardLayout`, `WidgetInstance`
* and `SearchProvider` know only the tenant dimension, not the user dimension * and `SearchProvider` knew only the tenant dimension, not the user dimension,
* (measured 260910-krx, Aufgabe 1, Befund G) — until the switch is flipped * when measured 260910-krx, Aufgabe 1, Befund G — until the switch is flipped
* (WINDOWS #18) they remain the only actually effective protection against * (WINDOWS #18) they remain the only actually effective protection against
* cross-reading/cross-deleting between two users of the SAME tenant, and the * cross-reading/cross-deleting between two users of the SAME tenant, and the
* `forTenant()` binding below ADDS a tenant boundary on top of them, it never * `forTenant()` binding below ADDS a tenant boundary on top of them, it never
* replaces them. * replaces them.
*
* Nachtrag (260911-nke, Etappe 3b): seit Migration 20260911120000 tragen die
* Regeln auf `DashboardLayout`, `WidgetInstance` und `SearchProvider` die
* Benutzerdimension (`current_user_id() IS NULL OR "userId" = current_user_id()`,
* fuer `SearchProvider` zusaetzlich als vier befehlsgetrennte Regeln) — jeder
* `forTenant()`-Aufruf unten reicht `userId` als drittes Argument durch. Die
* drei anwendungsseitigen Besitzpruefungen bleiben UNVERAENDERT: zweites Netz,
* kein Ersatz. Ein Aufrufer, der `userId` vergisst, saehe ohne sie den ganzen
* Mandanten (siehe .planning/WINDOWS.md). Beobachtung fuer die Kritikschrift:
* `removeWidget`/`updateWidgetConfig`/`removeSearchProvider` holen die Zeile
* per `findUnique({ where: { id } })` und vergleichen danach `userId` — nach
* dem Scharfschalten liefert `findUnique` fuer die Zeile eines Kollegen
* bereits `null` (die Regel blendet sie aus), die Anwendung meldet dann
* NotFoundException statt der heutigen Forbidden-Form — beides eine
* Abweisung, nur die Fehlerart aendert sich.
*/ */
@Injectable() @Injectable()
export class DashboardService { export class DashboardService {
@@ -77,7 +92,7 @@ export class DashboardService {
* with all breakpoint arrays initialized. * with all breakpoint arrays initialized.
*/ */
async getLayout(userId: string, tenantId: string) { async getLayout(userId: string, tenantId: string) {
const tenantPrisma = forTenant(this.prisma, tenantId); const tenantPrisma = forTenant(this.prisma, tenantId, userId);
const record = await tenantPrisma.dashboardLayout.findUnique({ const record = await tenantPrisma.dashboardLayout.findUnique({
where: { userId }, where: { userId },
}); });
@@ -108,7 +123,7 @@ export class DashboardService {
* deferred as a product decision to Etappe 3, same as WINDOWS #22. * deferred as a product decision to Etappe 3, same as WINDOWS #22.
*/ */
async saveLayout(userId: string, tenantId: string, dto: SaveLayoutDto) { async saveLayout(userId: string, tenantId: string, dto: SaveLayoutDto) {
const tenantPrisma = forTenant(this.prisma, tenantId); const tenantPrisma = forTenant(this.prisma, tenantId, userId);
try { try {
return await tenantPrisma.dashboardLayout.upsert({ return await tenantPrisma.dashboardLayout.upsert({
where: { userId }, where: { userId },
@@ -144,7 +159,7 @@ export class DashboardService {
* betroffene Widget entfernt (Fail-Closed). * betroffene Widget entfernt (Fail-Closed).
*/ */
async getWidgets(userId: string, tenantId: string, role: Role) { async getWidgets(userId: string, tenantId: string, role: Role) {
const tenantPrisma = forTenant(this.prisma, tenantId); const tenantPrisma = forTenant(this.prisma, tenantId, userId);
const widgets = await tenantPrisma.widgetInstance.findMany({ const widgets = await tenantPrisma.widgetInstance.findMany({
where: { userId }, where: { userId },
orderBy: { createdAt: 'asc' }, orderBy: { createdAt: 'asc' },
@@ -195,7 +210,7 @@ export class DashboardService {
* Creates a new widget instance for the user. * Creates a new widget instance for the user.
*/ */
async addWidget(userId: string, tenantId: string, dto: CreateWidgetDto) { async addWidget(userId: string, tenantId: string, dto: CreateWidgetDto) {
const tenantPrisma = forTenant(this.prisma, tenantId); const tenantPrisma = forTenant(this.prisma, tenantId, userId);
return tenantPrisma.widgetInstance.create({ return tenantPrisma.widgetInstance.create({
data: { data: {
userId, userId,
@@ -223,7 +238,7 @@ export class DashboardService {
tenantId: string, tenantId: string,
dto: UpdateWidgetConfigDto, dto: UpdateWidgetConfigDto,
) { ) {
const tenantPrisma = forTenant(this.prisma, tenantId); const tenantPrisma = forTenant(this.prisma, tenantId, userId);
const widget = await tenantPrisma.widgetInstance.findUnique({ const widget = await tenantPrisma.widgetInstance.findUnique({
where: { id }, where: { id },
}); });
@@ -253,7 +268,7 @@ export class DashboardService {
* queries run over the SAME bound client and tenant id. * queries run over the SAME bound client and tenant id.
*/ */
async removeWidget(id: string, userId: string, tenantId: string) { async removeWidget(id: string, userId: string, tenantId: string) {
const tenantPrisma = forTenant(this.prisma, tenantId); const tenantPrisma = forTenant(this.prisma, tenantId, userId);
const widget = await tenantPrisma.widgetInstance.findUnique({ const widget = await tenantPrisma.widgetInstance.findUnique({
where: { id }, where: { id },
}); });
@@ -279,7 +294,7 @@ export class DashboardService {
* below and are always prepended unchanged. * below and are always prepended unchanged.
*/ */
async getSearchProviders(userId: string, tenantId: string) { async getSearchProviders(userId: string, tenantId: string) {
const tenantPrisma = forTenant(this.prisma, tenantId); const tenantPrisma = forTenant(this.prisma, tenantId, userId);
const custom = await tenantPrisma.searchProvider.findMany({ const custom = await tenantPrisma.searchProvider.findMany({
where: { userId }, where: { userId },
orderBy: { createdAt: 'asc' }, orderBy: { createdAt: 'asc' },
@@ -300,7 +315,7 @@ export class DashboardService {
tenantId: string, tenantId: string,
dto: CreateSearchProviderDto, dto: CreateSearchProviderDto,
) { ) {
const tenantPrisma = forTenant(this.prisma, tenantId); const tenantPrisma = forTenant(this.prisma, tenantId, userId);
return tenantPrisma.searchProvider.create({ return tenantPrisma.searchProvider.create({
data: { data: {
userId, userId,
@@ -319,7 +334,7 @@ export class DashboardService {
* above: both queries run over the SAME bound client and tenant id. * above: both queries run over the SAME bound client and tenant id.
*/ */
async removeSearchProvider(id: string, userId: string, tenantId: string) { async removeSearchProvider(id: string, userId: string, tenantId: string) {
const tenantPrisma = forTenant(this.prisma, tenantId); const tenantPrisma = forTenant(this.prisma, tenantId, userId);
// Default providers have hardcoded IDs that won't exist in DB // Default providers have hardcoded IDs that won't exist in DB
const provider = await tenantPrisma.searchProvider.findUnique({ const provider = await tenantPrisma.searchProvider.findUnique({
where: { id }, where: { id },
@@ -203,6 +203,8 @@ describe('FavoritesService — Bindung an forTenant() (260911-gwh)', () => {
expect(result.map((r: any) => r.id)).toEqual(['f2', 'f1']); expect(result.map((r: any) => r.id)).toEqual(['f2', 'f1']);
expectBoundCall(prisma, 't1', 'favoriteLink', 'findMany'); expectBoundCall(prisma, 't1', 'favoriteLink', 'findMany');
// Benutzerdimension (260911-nke): forTenant() bekommt userId als drittes Argument.
expect(forTenant).toHaveBeenCalledWith(prisma, 't1', 'user-a1');
}); });
it('liefert unter einem FREMDEN Mandanten eine leere Liste, kein Fehler (der Wert, aus dem das Widget "Noch keine Favoriten." macht)', async () => { it('liefert unter einem FREMDEN Mandanten eine leere Liste, kein Fehler (der Wert, aus dem das Widget "Noch keine Favoriten." macht)', async () => {
+14 -10
View File
@@ -23,11 +23,15 @@ import { IconDiscoveryService, normalizeUrl } from './icon-discovery.service';
* wuerde Widget und Link unter einem `x-tenant-id`-Wechsel eines * wuerde Widget und Link unter einem `x-tenant-id`-Wechsel eines
* SUPER_ADMIN in verschiedenen Mandanten auseinanderreissen. * SUPER_ADMIN in verschiedenen Mandanten auseinanderreissen.
* *
* Die Regel auf `FavoriteLink` kennt KEINE Benutzerdimension (260911-gwh, * Die Regel auf `FavoriteLink` trug bei der Messung 260911-gwh (Aufgabe 1,
* Aufgabe 1, Pruefung 4 — dieselbe Lehre wie `CalendarSource`/ * Pruefung 4) KEINE Benutzerdimension — dieselbe Lehre wie `CalendarSource`/
* `DashboardLayout`/`WidgetInstance`) — die `userId`-Filter unten bleiben * `DashboardLayout`/`WidgetInstance`. Nachtrag (260911-nke, Etappe 3b): seit
* deshalb der einzige Schutz gegen Quer-Lesen zwischen Nutzern DESSELBEN * Migration 20260911120000 traegt die Regel auf `FavoriteLink` die
* Mandanten (Etappe-3-Entscheidung (2) traegt das nach). * Benutzerdimension (`current_user_id() IS NULL OR "userId" = current_user_id()`)
* — jeder `forTenant()`-Aufruf unten reicht `userId` als drittes Argument
* durch. Die `userId`-Filter unten bleiben trotzdem UNVERAENDERT bestehen:
* zweites Netz, kein Ersatz — ein Aufrufer, der `userId` vergisst, saehe
* ohne sie den ganzen Mandanten (siehe .planning/WINDOWS.md).
* *
* Access control (T-08-06 / Pitfall 3): * Access control (T-08-06 / Pitfall 3):
* - Every query is scoped by userId (prevents cross-user access). * - Every query is scoped by userId (prevents cross-user access).
@@ -58,7 +62,7 @@ export class FavoritesService {
async list(tenantId: string, userId: string, widgetId: string) { async list(tenantId: string, userId: string, widgetId: string) {
if (!widgetId) throw new BadRequestException('widgetId is required'); if (!widgetId) throw new BadRequestException('widgetId is required');
const tenantPrisma = forTenant(this.prisma, tenantId) as any; const tenantPrisma = forTenant(this.prisma, tenantId, userId) as any;
return tenantPrisma.favoriteLink.findMany({ return tenantPrisma.favoriteLink.findMany({
where: { userId, widgetId }, where: { userId, widgetId },
orderBy: [{ position: 'asc' }, { title: 'asc' }], orderBy: [{ position: 'asc' }, { title: 'asc' }],
@@ -72,7 +76,7 @@ export class FavoritesService {
* If iconUrl is not provided, triggers server-side icon discovery with SSRF protection. * If iconUrl is not provided, triggers server-side icon discovery with SSRF protection.
*/ */
async create(tenantId: string, userId: string, dto: CreateFavoriteDto) { async create(tenantId: string, userId: string, dto: CreateFavoriteDto) {
const tenantPrisma = forTenant(this.prisma, tenantId) as any; const tenantPrisma = forTenant(this.prisma, tenantId, userId) as any;
// T-GWH-05: der Fremdschluessel prueft an der Zeilenschutz-Regel von // T-GWH-05: der Fremdschluessel prueft an der Zeilenschutz-Regel von
// WidgetInstance vorbei (Aufgabe 1, Pruefung 7) — ohne diesen Riegel // WidgetInstance vorbei (Aufgabe 1, Pruefung 7) — ohne diesen Riegel
@@ -116,7 +120,7 @@ export class FavoritesService {
* Accepts null as an explicit value for iconUrl (clears stored icon). * Accepts null as an explicit value for iconUrl (clears stored icon).
*/ */
async update(tenantId: string, id: string, userId: string, dto: UpdateFavoriteDto) { async update(tenantId: string, id: string, userId: string, dto: UpdateFavoriteDto) {
const tenantPrisma = forTenant(this.prisma, tenantId) as any; const tenantPrisma = forTenant(this.prisma, tenantId, userId) as any;
const link = await tenantPrisma.favoriteLink.findUnique({ where: { id } }); const link = await tenantPrisma.favoriteLink.findUnique({ where: { id } });
if (!link || link.userId !== userId) { if (!link || link.userId !== userId) {
@@ -157,7 +161,7 @@ export class FavoritesService {
* Verifies userId ownership before deleting (T-08-06). * Verifies userId ownership before deleting (T-08-06).
*/ */
async remove(tenantId: string, id: string, userId: string) { async remove(tenantId: string, id: string, userId: string) {
const tenantPrisma = forTenant(this.prisma, tenantId) as any; const tenantPrisma = forTenant(this.prisma, tenantId, userId) as any;
const link = await tenantPrisma.favoriteLink.findUnique({ where: { id } }); const link = await tenantPrisma.favoriteLink.findUnique({ where: { id } });
if (!link || link.userId !== userId) { if (!link || link.userId !== userId) {
@@ -183,7 +187,7 @@ export class FavoritesService {
id: string, id: string,
userId: string, userId: string,
): Promise<{ contentType: string; body: Buffer }> { ): Promise<{ contentType: string; body: Buffer }> {
const tenantPrisma = forTenant(this.prisma, tenantId) as any; const tenantPrisma = forTenant(this.prisma, tenantId, userId) as any;
const link = await tenantPrisma.favoriteLink.findUnique({ where: { id } }); const link = await tenantPrisma.favoriteLink.findUnique({ where: { id } });
if (!link || link.userId !== userId || !link.iconUrl) { if (!link || link.userId !== userId || !link.iconUrl) {
+82
View File
@@ -173,6 +173,88 @@ describe('rls_widen_membership_grant_and_platform_read migration.sql (T-JTS-02,
}); });
}); });
describe('rls_user_dimension_personal_tables migration.sql (Etappe 3b, 260911-nke)', () => {
const sql = readMigrationSql('_rls_user_dimension_personal_tables');
const PERSONAL_TABLES = [
'CalendarSource',
'DashboardLayout',
'FavoriteLink',
'SearchProvider',
'TenderEmailConfig',
'TenderNotificationPref',
'TenderRssFeedSource',
'TenderSavedSearch',
'TenderTriage',
'WidgetInstance',
];
const EXCLUDED_TABLES = ['GroupMembership', 'ModuleGrant', 'PasswordResetToken', 'TenderMatch'];
function nonCommentLines(source: string): string {
return source
.split('\n')
.filter((line) => !line.trim().startsWith('--'))
.join('\n');
}
it('legt current_user_id() mit NULLIF an', () => {
expect(sql).toContain('CREATE OR REPLACE FUNCTION current_user_id() RETURNS TEXT AS $$');
expect(sql).toContain("NULLIF(current_setting('app.current_user', true), '')");
});
it('nennt fuer jede der zehn persoenlichen Tabellen mindestens eine CREATE POLICY-Anweisung', () => {
for (const table of PERSONAL_TABLES) {
expect(sql).toMatch(new RegExp(`CREATE POLICY [\\w]+ ON "${table}"`));
}
});
it('jede CREATE-POLICY-Anweisung der zehn Tabellen enthaelt current_user_id() IS NULL OR', () => {
for (const table of PERSONAL_TABLES) {
const re = /CREATE POLICY [\w]+[\s\S]*?ON "([A-Za-z]+)"[\s\S]*?;/g;
let match: RegExpExecArray | null;
let found = 0;
while ((match = re.exec(sql)) !== null) {
if (match[1] !== table) continue;
found += 1;
expect(match[0].replace(/\s+/g, ' ')).toContain('current_user_id() IS NULL OR');
}
expect(found).toBeGreaterThan(0);
}
});
it('legt genau 8 DROP POLICY tenant_isolation_policy auf den NOT-NULL-Tabellen, einen weiteren auf SearchProvider, und 4 DROPs auf TenderRssFeedSource an', () => {
const NOT_NULL_TABLES = [
'CalendarSource',
'DashboardLayout',
'FavoriteLink',
'TenderEmailConfig',
'TenderNotificationPref',
'TenderSavedSearch',
'TenderTriage',
'WidgetInstance',
];
const dropIsolationOnNotNullTables = NOT_NULL_TABLES.filter((table) =>
sql.includes(`DROP POLICY tenant_isolation_policy ON "${table}"`),
).length;
expect(dropIsolationOnNotNullTables).toBe(8);
expect(sql).toContain('DROP POLICY tenant_isolation_policy ON "SearchProvider"');
const dropRssFeed = (sql.match(/DROP POLICY \w+ ON "TenderRssFeedSource"/g) ?? []).length;
expect(dropRssFeed).toBe(4);
});
it('nennt die vier Ausnahmen namentlich im Kopf', () => {
for (const table of EXCLUDED_TABLES) {
expect(sql).toContain(table);
}
});
it('enthaelt KEINE Anweisung auf GroupMembership/ModuleGrant/PasswordResetToken/TenderMatch (ausserhalb von Kommentaren)', () => {
const codeOnly = nonCommentLines(sql);
for (const table of EXCLUDED_TABLES) {
expect(codeOnly).not.toMatch(new RegExp(`(DROP|CREATE) POLICY [\\w ]*ON "${table}"`));
}
});
});
describe('add_group_internal_name_and_object_guid migration.sql (D-04)', () => { describe('add_group_internal_name_and_object_guid migration.sql (D-04)', () => {
const sql = readMigrationSql('_add_group_internal_name_and_object_guid'); const sql = readMigrationSql('_add_group_internal_name_and_object_guid');
@@ -132,6 +132,73 @@ describe('forTenant() — Array-Form von $transaction (WINDOWS #20)', () => {
expect(forTenantSource).toMatch(/\$transaction\(\s*\[/); expect(forTenantSource).toMatch(/\$transaction\(\s*\[/);
expect(forTenantSource).not.toMatch(/\$transaction\(\s*async/); expect(forTenantSource).not.toMatch(/\$transaction\(\s*async/);
}); });
// Benutzerdimension (Etappe 3b, 260911-nke): drei neue Tests fuer den
// optionalen dritten Parameter `userId`.
it('ohne userId: die Parameterliste des Templates enthaelt den Leerstring an zweiter Stelle, der Template-Text nennt app.current_user', async () => {
const fakePrisma: any = {
$transaction: vi.fn(() => Promise.resolve(['set-config-result', 'query-result'])),
$extends: (config: any) => ({
async __invoke(args: unknown, query: (args: unknown) => unknown) {
return config.query.$allOperations({ args, query });
},
}),
$executeRaw: vi.fn((strings: TemplateStringsArray, ...values: unknown[]) => {
expect(strings.join('')).toContain('app.current_user');
expect(values[1]).toBe('');
return 'set-config-promise';
}),
};
const scoped = forTenant(fakePrisma, 'tenant-a') as any;
await scoped.__invoke({}, () => 'query-result');
expect(fakePrisma.$executeRaw).toHaveBeenCalledTimes(1);
});
it('mit userId: der Wert geht als Template-PARAMETER (values), nicht im Text (T-02-05 bleibt gewahrt)', async () => {
const fakePrisma: any = {
$transaction: vi.fn(() => Promise.resolve(['set-config-result', 'query-result'])),
$extends: (config: any) => ({
async __invoke(args: unknown, query: (args: unknown) => unknown) {
return config.query.$allOperations({ args, query });
},
}),
$executeRaw: vi.fn((strings: TemplateStringsArray, ...values: unknown[]) => {
expect(Array.isArray(strings)).toBe(true);
expect(strings.join('')).not.toContain("user-with-quote-' OR 1=1");
expect(values).toContain("user-with-quote-' OR 1=1");
return 'set-config-promise';
}),
};
const scoped = forTenant(fakePrisma, 'tenant-a', "user-with-quote-' OR 1=1") as any;
await scoped.__invoke({}, () => 'query-result');
expect(fakePrisma.$executeRaw).toHaveBeenCalledTimes(1);
});
it('mit userId: das $transaction-Feld behaelt weiterhin genau zwei Eintraege', async () => {
const transactionCalls: unknown[] = [];
const fakePrisma: any = {
$transaction: vi.fn((arg: unknown) => {
transactionCalls.push(arg);
return Promise.resolve(['set-config-result', 'query-result']);
}),
$extends: (config: any) => ({
async __invoke(args: unknown, query: (args: unknown) => unknown) {
return config.query.$allOperations({ args, query });
},
}),
$executeRaw: vi.fn(() => 'set-config-promise'),
};
const scoped = forTenant(fakePrisma, 'tenant-a', 'user-a') as any;
await scoped.__invoke({}, () => 'query-result');
expect(transactionCalls).toHaveLength(1);
expect((transactionCalls[0] as unknown[]).length).toBe(2);
});
}); });
describe('withTenantTransaction() — interaktive Callback-Form auf dem UNgebundenen Client (260909-jts, Aufgabe 1)', () => { describe('withTenantTransaction() — interaktive Callback-Form auf dem UNgebundenen Client (260909-jts, Aufgabe 1)', () => {
+43 -4
View File
@@ -107,16 +107,55 @@ import { PrismaClient } from '@prisma/client';
* Transaktion gilt weiterhin: vor jedem neuen Fall erneut pruefen, nicht * Transaktion gilt weiterhin: vor jedem neuen Fall erneut pruefen, nicht
* von hier abschreiben — eine andere Lastform oder ein anderer Pool koennte * von hier abschreiben — eine andere Lastform oder ein anderer Pool koennte
* ein anderes Ergebnis liefern. * ein anderes Ergebnis liefern.
*
* BENUTZERDIMENSION (Etappe 3b, 260911-nke):
*
* `forTenant()` bekommt einen OPTIONALEN dritten Parameter `userId` statt
* eines Schwesterhelfers (`forTenantAndUser()`). Grund: der Detektor der
* Bestandsaufnahme (`rls-access-inventory.spec.ts`) erkennt gebundene
* Aufrufstellen ueber den Regex `const X = forTenant(` — ein anders
* benannter Schwesterhelfer waere fuer ihn UNSICHTBAR, jeder damit
* gebundene Zugriff wuerde faelschlich als ungebunden gezaehlt. Ein
* dritter Parameter aendert am Match des Regex nichts, weil er nur den
* Funktionsnamen und das oeffnende `(` prueft. Praezedenz fuer "Helfer
* erweitern statt zweiten bauen": `withTenantTransaction()` oben, das
* ebenfalls keinen Zwilling bekam.
*
* Ohne `userId` wird `app.current_user` auf den LEERSTRING gesetzt, nicht
* weggelassen. Grund: `set_config(..., true)` gilt nur transaktionslokal
* (siehe WINDOWS-#20-Herleitung oben) — ein Aufruf ohne Benutzer koennte
* sonst theoretisch einen Benutzer aus einer fruaheren Transaktion
* DERSELBEN Verbindung erben, sollte spaeter jemand `local=false`
* einfuehren. Der Leerstring schliesst das aus. `current_user_id()`
* (neue Migration 20260911120000) faltet den Leerstring per `NULLIF` auf
* NULL — die Regeln der zehn persoenlichen Tabellen behandeln "ungesetzt"
* und "leer" dadurch gleich.
*
* Beide `set_config`-Aufrufe stehen in EINER getaggten Anweisung
* (kommasepariert) — das `$transaction`-Array behaelt weiterhin GENAU ZWEI
* Eintraege (Kontext-Anweisung, eigentliche Abfrage), das WINDOWS-#20-Muster
* bleibt unangetastet.
*
* Wer den Benutzer setzt: NUR Nutzer-CRUD-Aufrufer (die zehn persoenlichen
* Tabellen betreffende Methoden in calendar/dashboard/favorites/tenders).
* Hintergrunddienste (`tender-digest.scheduler.ts`) und Verwaltungswege
* (ldap, groups, user, tenant, auth, dkv, module-registry) rufen weiterhin
* OHNE Benutzer — die `IS NULL OR`-Form der Regeln macht das zu einer
* bewussten Eigenschaft (Admin/Hintergrunddienst sieht den ganzen
* Mandanten), nicht zu einer Luecke. `withTenantTransaction()` bekommt
* KEINEN dritten Parameter: kein Nutzer-CRUD-Aufrufer nutzt diese Funktion
* (nur `groups`, ein Verwaltungsweg) — ein unbenutzter Parameter waere
* Spekulation ohne heutigen Aufrufer.
*/ */
export function forTenant(prisma: PrismaClient, tenantId: string) { export function forTenant(prisma: PrismaClient, tenantId: string, userId?: string) {
return prisma.$extends({ return prisma.$extends({
query: { query: {
$allOperations({ args, query }: { args: any; query: (args: any) => any }) { $allOperations({ args, query }: { args: any; query: (args: any) => any }) {
const setTenantContext = (prisma as any) const setContext = (prisma as any)
.$executeRaw`SELECT set_config('app.current_tenant', ${tenantId}, true)`; .$executeRaw`SELECT set_config('app.current_tenant', ${tenantId}, true), set_config('app.current_user', ${userId ?? ''}, true)`;
return (prisma as any) return (prisma as any)
.$transaction([setTenantContext, query(args)]) .$transaction([setContext, query(args)])
.then((results: any[]) => results[1]); .then((results: any[]) => results[1]);
}, },
}, },
@@ -132,6 +132,14 @@ export class TenderDigestScheduler implements OnModuleInit {
// Je-Treffer-Haelfte, gebunden an den Mandanten DIESER // Je-Treffer-Haelfte, gebunden an den Mandanten DIESER
// Kandidatenzeile (260909-laa, Aufgabe 3) — ein einziger gebundener // Kandidatenzeile (260909-laa, Aufgabe 3) — ein einziger gebundener
// Client fuer alle Zugriffe dieses Schleifendurchlaufs. // Client fuer alle Zugriffe dieses Schleifendurchlaufs.
//
// Bewusst OHNE Benutzer (260911-nke, Etappe 3b): dieser Scheduler ist
// ein Hintergrunddienst, kein Nutzer-CRUD-Aufrufer — er liest UND
// schreibt fuer den Nutzer, nicht ALS ihn eingeloggt. Die `IS NULL
// OR`-Form der Regeln macht das zur bewussten Eigenschaft: ohne
// `userId` sieht dieser Zugriff den ganzen Mandanten, exakt wie vor
// der Migration. Ein Systemkontext fuer Hintergrunddienste ist
// Etappe 3c, nicht Teil dieser Aenderung.
const tenantPrisma = forTenant(this.prisma, tenantId) as any; const tenantPrisma = forTenant(this.prisma, tenantId) as any;
const pref = await tenantPrisma.tenderNotificationPref.findUnique({ const pref = await tenantPrisma.tenderNotificationPref.findUnique({
@@ -1,5 +1,6 @@
import { describe, expect, it, vi } from 'vitest'; import { describe, expect, it, vi } from 'vitest';
import { TenderEmailConfigService } from './tender-email-config.service'; import { TenderEmailConfigService } from './tender-email-config.service';
import { forTenant } from '../prisma/prisma-tenant.extension';
/** /**
* TenderEmailConfigService.spec — Phase 14, Plan 03 (CONFIG-02, D-06/D-07). * TenderEmailConfigService.spec — Phase 14, Plan 03 (CONFIG-02, D-06/D-07).
@@ -375,6 +376,8 @@ describe('TenderEmailConfigService', () => {
(c: any) => c.tenantId === 't1' && c.model === 'tenderEmailConfig' && c.method === 'findUnique', (c: any) => c.tenantId === 't1' && c.model === 'tenderEmailConfig' && c.method === 'findUnique',
); );
expect(findUniqueCalls.length).toBe(2); expect(findUniqueCalls.length).toBe(2);
// Benutzerdimension (260911-nke): forTenant() bekommt userId als drittes Argument.
expect(forTenant).toHaveBeenCalledWith(prisma, 't1', 'user-k');
}); });
it('saveConfig() bindet den credChanged-Lesezugriff UND das upsert an den uebergebenen Mandanten', async () => { it('saveConfig() bindet den credChanged-Lesezugriff UND das upsert an den uebergebenen Mandanten', async () => {
@@ -54,6 +54,12 @@ const EMAIL_CONFIG_SAFE_SELECT = {
* uniqueness constraint on `userId` and surfaces as a translated * uniqueness constraint on `userId` and surfaces as a translated
* ConflictException, not a raw 500 (T-LAA-07, Befund F, Aufgabe 1). * ConflictException, not a raw 500 (T-LAA-07, Befund F, Aufgabe 1).
* *
* Benutzerdimension seit 20260911120000 (Etappe 3b, 260911-nke): every
* `forTenant()` call above also passes `userId` as the third argument, so
* the `tenant_isolation_policy` on TenderEmailConfig ALSO enforces
* `userId = current_user_id()` — a second net, not a replacement for the
* `userId @unique` ownership model above.
*
* Security: * Security:
* - T-07-12: encryptedInboxCreds is excluded from every read-path select; * - T-07-12: encryptedInboxCreds is excluded from every read-path select;
* getConfigForApi returns `hasPassword: boolean` instead of the password. * getConfigForApi returns `hasPassword: boolean` instead of the password.
@@ -96,7 +102,7 @@ export class TenderEmailConfigService {
* by userId (T-17-01) — a user only ever reads their own mailbox. * by userId (T-17-01) — a user only ever reads their own mailbox.
*/ */
async getConfigForApi(userId: string, tenantId: string) { async getConfigForApi(userId: string, tenantId: string) {
const tenantPrisma = forTenant(this.prisma, tenantId) as any; const tenantPrisma = forTenant(this.prisma, tenantId, userId) as any;
const safe = await tenantPrisma.tenderEmailConfig.findUnique({ const safe = await tenantPrisma.tenderEmailConfig.findUnique({
where: { userId }, where: { userId },
select: EMAIL_CONFIG_SAFE_SELECT, select: EMAIL_CONFIG_SAFE_SELECT,
@@ -146,7 +152,7 @@ export class TenderEmailConfigService {
*/ */
async saveConfig(ctx: { userId: string; tenantId: string }, dto: TenderEmailConfigDto) { async saveConfig(ctx: { userId: string; tenantId: string }, dto: TenderEmailConfigDto) {
const { userId, tenantId } = ctx; const { userId, tenantId } = ctx;
const tenantPrisma = forTenant(this.prisma, tenantId) as any; const tenantPrisma = forTenant(this.prisma, tenantId, userId) as any;
let encryptedInboxCreds: string | undefined; let encryptedInboxCreds: string | undefined;
const credChanged = const credChanged =
@@ -234,7 +240,7 @@ export class TenderEmailConfigService {
if (!username || !password) { if (!username || !password) {
try { try {
const tenantPrisma = forTenant(this.prisma, tenantId) as any; const tenantPrisma = forTenant(this.prisma, tenantId, userId) as any;
const existing = await tenantPrisma.tenderEmailConfig.findUnique({ where: { userId } }); const existing = await tenantPrisma.tenderEmailConfig.findUnique({ where: { userId } });
if (existing?.encryptedInboxCreds) { if (existing?.encryptedInboxCreds) {
const stored = JSON.parse(this.crypto.decrypt(existing.encryptedInboxCreds)) as { const stored = JSON.parse(this.crypto.decrypt(existing.encryptedInboxCreds)) as {
@@ -1,6 +1,7 @@
import { ConflictException } from '@nestjs/common'; import { ConflictException } from '@nestjs/common';
import { describe, expect, it, vi } from 'vitest'; import { describe, expect, it, vi } from 'vitest';
import { TenderNotificationPrefService } from './tender-notification-pref.service'; import { TenderNotificationPrefService } from './tender-notification-pref.service';
import { forTenant } from '../prisma/prisma-tenant.extension';
/** /**
* TenderNotificationPrefService.spec — RED-first (TDD) proof for NOTIFY-01 * TenderNotificationPrefService.spec — RED-first (TDD) proof for NOTIFY-01
@@ -144,6 +145,8 @@ describe('TenderNotificationPrefService', () => {
await service.getForUser('u1', 't1'); await service.getForUser('u1', 't1');
expectBoundCall(prisma, 't1', 'findUnique'); expectBoundCall(prisma, 't1', 'findUnique');
// Benutzerdimension (260911-nke): forTenant() bekommt userId als drittes Argument.
expect(forTenant).toHaveBeenCalledWith(prisma, 't1', 'u1');
}); });
it('setForUser() bindet tenderNotificationPref.upsert an den uebergebenen Mandanten', async () => { it('setForUser() bindet tenderNotificationPref.upsert an den uebergebenen Mandanten', async () => {
@@ -26,6 +26,12 @@ import { forTenant } from '../prisma/prisma-tenant.extension';
* the failure is a P2002 unique-constraint violation, not an RLS * the failure is a P2002 unique-constraint violation, not an RLS
* rejection. Translated below into a German message, same pattern as * rejection. Translated below into a German message, same pattern as
* `tender-saved-search.service.ts`, instead of surfacing as a raw 500. * `tender-saved-search.service.ts`, instead of surfacing as a raw 500.
*
* Nachtrag (260911-nke, Etappe 3b): seit Migration 20260911120000 traegt
* die Regel auf TenderNotificationPref die Benutzerdimension
* (`current_user_id() IS NULL OR "userId" = current_user_id()`) — beide
* `forTenant()`-Aufrufe unten reichen `userId` als drittes Argument durch.
* Die anwendungsseitige userId-Filterung bleibt zweites Netz, kein Ersatz.
*/ */
@Injectable() @Injectable()
export class TenderNotificationPrefService { export class TenderNotificationPrefService {
@@ -39,7 +45,7 @@ export class TenderNotificationPrefService {
* autowrite needed to represent "using the default". * autowrite needed to represent "using the default".
*/ */
async getForUser(userId: string, tenantId: string): Promise<{ digestInterval: string }> { async getForUser(userId: string, tenantId: string): Promise<{ digestInterval: string }> {
const tenantPrisma = forTenant(this.prisma, tenantId) as any; const tenantPrisma = forTenant(this.prisma, tenantId, userId) as any;
const existing = await tenantPrisma.tenderNotificationPref.findUnique({ const existing = await tenantPrisma.tenderNotificationPref.findUnique({
where: { userId }, where: { userId },
}); });
@@ -57,7 +63,7 @@ export class TenderNotificationPrefService {
* than creating a new one. * than creating a new one.
*/ */
async setForUser(userId: string, tenantId: string, digestInterval: string) { async setForUser(userId: string, tenantId: string, digestInterval: string) {
const tenantPrisma = forTenant(this.prisma, tenantId) as any; const tenantPrisma = forTenant(this.prisma, tenantId, userId) as any;
try { try {
return await tenantPrisma.tenderNotificationPref.upsert({ return await tenantPrisma.tenderNotificationPref.upsert({
where: { userId }, where: { userId },
@@ -509,6 +509,7 @@ describe('TenderRssFeedSourceService', () => {
expectBoundCall(prisma, 'tenant-a', 'count'); expectBoundCall(prisma, 'tenant-a', 'count');
expectBoundCall(prisma, 'tenant-a', 'create'); expectBoundCall(prisma, 'tenant-a', 'create');
expect(forTenant).toHaveBeenCalledWith(prisma, 'tenant-a', 'user-a');
}); });
// Umkehr von 'listForUser() bindet NICHT' (260910-jab, Aufgabe 2): seit // Umkehr von 'listForUser() bindet NICHT' (260910-jab, Aufgabe 2): seit
@@ -530,7 +531,7 @@ describe('TenderRssFeedSourceService', () => {
await service.listForUser('u-anyone', 'tenant-a'); await service.listForUser('u-anyone', 'tenant-a');
expect(forTenant).toHaveBeenCalledWith(prisma, 'tenant-a'); expect(forTenant).toHaveBeenCalledWith(prisma, 'tenant-a', 'u-anyone');
expectBoundCall(prisma, 'tenant-a', 'findMany'); expectBoundCall(prisma, 'tenant-a', 'findMany');
}); });
@@ -69,7 +69,7 @@ export class TenderRssFeedSourceService {
* Bindung nicht überflüssig, sondern das zweite Netz. * Bindung nicht überflüssig, sondern das zweite Netz.
*/ */
async listForUser(userId: string, tenantId: string) { async listForUser(userId: string, tenantId: string) {
const tenantPrisma = forTenant(this.prisma, tenantId) as any; const tenantPrisma = forTenant(this.prisma, tenantId, userId) as any;
return tenantPrisma.tenderRssFeedSource.findMany({ return tenantPrisma.tenderRssFeedSource.findMany({
where: { OR: [{ userId: null }, { userId }] }, where: { OR: [{ userId: null }, { userId }] },
orderBy: { createdAt: 'asc' }, orderBy: { createdAt: 'asc' },
@@ -93,7 +93,7 @@ export class TenderRssFeedSourceService {
) { ) {
this.assertUrlAllowed(dto.url); this.assertUrlAllowed(dto.url);
const tenantPrisma = forTenant(this.prisma, ctx.tenantId) as any; const tenantPrisma = forTenant(this.prisma, ctx.tenantId, ctx.userId) as any;
const existingCount = await tenantPrisma.tenderRssFeedSource.count({ const existingCount = await tenantPrisma.tenderRssFeedSource.count({
where: { userId: ctx.userId }, where: { userId: ctx.userId },
}); });
@@ -130,7 +130,10 @@ export class TenderRssFeedSourceService {
* Zeile laesst sich unter der Anwendungsrolle grundsaetzlich nicht * Zeile laesst sich unter der Anwendungsrolle grundsaetzlich nicht
* anlegen, weil jede Schreibregel einen Mandanten verlangt. Kein * anlegen, weil jede Schreibregel einen Mandanten verlangt. Kein
* Verwaltungsweg dafuer existiert heute; WINDOWS #24 haelt das als eigenen * Verwaltungsweg dafuer existiert heute; WINDOWS #24 haelt das als eigenen
* offenen Punkt fest, der NICHT mit #19 verschwindet. * offenen Punkt fest, der NICHT mit #19 verschwindet. Nachtrag (260911-nke,
* Etappe 3b): dieselbe Begruendung gilt fuer die neue Benutzerdimension
* (20260911120000) — `createPlatform` bleibt bewusst ungebunden, WINDOWS #24
* unveraendert offen.
*/ */
async createPlatform(dto: TenderRssFeedDto) { async createPlatform(dto: TenderRssFeedDto) {
this.assertUrlAllowed(dto.url); this.assertUrlAllowed(dto.url);
@@ -173,7 +176,10 @@ export class TenderRssFeedSourceService {
* Anweisungen zu zerlegen, um nur die persoenliche Haelfte zu binden, * Anweisungen zu zerlegen, um nur die persoenliche Haelfte zu binden,
* wuerde ausserdem das Pruef-/Nutzungsfenster wieder oeffnen, das dieser * wuerde ausserdem das Pruef-/Nutzungsfenster wieder oeffnen, das dieser
* Kommentar oben (T-17-07) vermeidet — deshalb bleibt die gesamte Methode * Kommentar oben (T-17-07) vermeidet — deshalb bleibt die gesamte Methode
* ungebunden, nicht nur ihre plattformweite Haelfte. * ungebunden, nicht nur ihre plattformweite Haelfte. Nachtrag (260911-nke,
* Etappe 3b): dieselbe Begruendung gilt fuer die neue Benutzerdimension
* (20260911120000) — `remove` bleibt bewusst ungebunden, WINDOWS #24
* unveraendert offen.
*/ */
async remove(id: string, ctx: { userId: string; isAdmin: boolean }) { async remove(id: string, ctx: { userId: string; isAdmin: boolean }) {
const { userId, isAdmin } = ctx; const { userId, isAdmin } = ctx;
@@ -1,6 +1,7 @@
import { ConflictException, NotFoundException } from '@nestjs/common'; import { ConflictException, NotFoundException } from '@nestjs/common';
import { describe, expect, it, vi } from 'vitest'; import { describe, expect, it, vi } from 'vitest';
import { TenderSavedSearchService } from './tender-saved-search.service'; import { TenderSavedSearchService } from './tender-saved-search.service';
import { forTenant } from '../prisma/prisma-tenant.extension';
/** /**
* TenderSavedSearchService.spec — RED-first (TDD) proof for FILTER-06 * TenderSavedSearchService.spec — RED-first (TDD) proof for FILTER-06
@@ -311,4 +312,48 @@ describe('TenderSavedSearchService', () => {
expectBoundCall(prisma, 't1', 'delete'); expectBoundCall(prisma, 't1', 'delete');
}); });
}); });
// --- Benutzerdimension (Etappe 3b, 260911-nke): forTenant() bekommt den
// Benutzer als drittes Argument — je Methode mindestens ein dreistelliger
// Aufruf festgenagelt, damit ein vergessenes drittes Argument den Test
// bricht statt still zu verschwinden.
describe('Benutzerdimension: forTenant() bekommt userId als drittes Argument (260911-nke)', () => {
it('list() ruft forTenant(prisma, tenantId, userId) auf', async () => {
const prisma = makeFakePrisma();
const service = new TenderSavedSearchService(prisma as any);
await service.list('u1', 't1');
expect(forTenant).toHaveBeenCalledWith(prisma, 't1', 'u1');
});
it('create() ruft forTenant(prisma, tenantId, userId) auf', async () => {
const prisma = makeFakePrisma();
const service = new TenderSavedSearchService(prisma as any);
await service.create('u1', 't1', { name: 'A', filters: {} });
expect(forTenant).toHaveBeenCalledWith(prisma, 't1', 'u1');
});
it('update() ruft forTenant(prisma, tenantId, userId) auf', async () => {
const prisma = makeFakePrisma();
const service = new TenderSavedSearchService(prisma as any);
const created = await service.create('u1', 't1', { name: 'A', filters: {} });
await service.update(created.id, 'u1', 't1', { name: 'B' });
expect(forTenant).toHaveBeenCalledWith(prisma, 't1', 'u1');
});
it('remove() ruft forTenant(prisma, tenantId, userId) auf', async () => {
const prisma = makeFakePrisma();
const service = new TenderSavedSearchService(prisma as any);
const created = await service.create('u1', 't1', { name: 'A', filters: {} });
await service.remove(created.id, 'u1', 't1');
expect(forTenant).toHaveBeenCalledWith(prisma, 't1', 'u1');
});
});
}); });
@@ -24,6 +24,12 @@ import { CreateSavedSearchDto, UpdateSavedSearchDto } from './dto/saved-search.d
* method call, never shared across methods (same convention as * method call, never shared across methods (same convention as
* `groups.service.ts`). * `groups.service.ts`).
* *
* Benutzerdimension seit 20260911120000 (Etappe 3b, 260911-nke): every
* `forTenant()` call above also passes `userId` as the third argument, so
* the database-level `tenant_isolation_policy` on TenderSavedSearch now
* ALSO enforces `userId = current_user_id()` — a second net alongside the
* application-level scoping above, which stays exactly as it was.
*
* @@unique([userId, name]) (T-11-14): a second profile with the same name * @@unique([userId, name]) (T-11-14): a second profile with the same name
* for the same user is rejected by Postgres (P2002) — this service * for the same user is rejected by Postgres (P2002) — this service
* translates that into a 409 ConflictException so the frontend can show a * translates that into a 409 ConflictException so the frontend can show a
@@ -38,7 +44,7 @@ export class TenderSavedSearchService {
* strictly by userId (V4/IDOR) — a foreign userId sees nothing. * strictly by userId (V4/IDOR) — a foreign userId sees nothing.
*/ */
async list(userId: string, tenantId: string) { async list(userId: string, tenantId: string) {
const tenantPrisma = forTenant(this.prisma, tenantId) as any; const tenantPrisma = forTenant(this.prisma, tenantId, userId) as any;
return tenantPrisma.tenderSavedSearch.findMany({ return tenantPrisma.tenderSavedSearch.findMany({
where: { userId }, where: { userId },
orderBy: { name: 'asc' }, orderBy: { name: 'asc' },
@@ -52,7 +58,7 @@ export class TenderSavedSearchService {
* users, since the uniqueness is scoped per-user. * users, since the uniqueness is scoped per-user.
*/ */
async create(userId: string, tenantId: string, dto: CreateSavedSearchDto) { async create(userId: string, tenantId: string, dto: CreateSavedSearchDto) {
const tenantPrisma = forTenant(this.prisma, tenantId) as any; const tenantPrisma = forTenant(this.prisma, tenantId, userId) as any;
try { try {
return await tenantPrisma.tenderSavedSearch.create({ return await tenantPrisma.tenderSavedSearch.create({
data: { data: {
@@ -81,7 +87,7 @@ export class TenderSavedSearchService {
* leaking whether another user's profile exists). * leaking whether another user's profile exists).
*/ */
async update(id: string, userId: string, tenantId: string, dto: UpdateSavedSearchDto) { async update(id: string, userId: string, tenantId: string, dto: UpdateSavedSearchDto) {
const tenantPrisma = forTenant(this.prisma, tenantId) as any; const tenantPrisma = forTenant(this.prisma, tenantId, userId) as any;
const existing = await tenantPrisma.tenderSavedSearch.findUnique({ const existing = await tenantPrisma.tenderSavedSearch.findUnique({
where: { id }, where: { id },
}); });
@@ -118,7 +124,7 @@ export class TenderSavedSearchService {
* update(). * update().
*/ */
async remove(id: string, userId: string, tenantId: string) { async remove(id: string, userId: string, tenantId: string) {
const tenantPrisma = forTenant(this.prisma, tenantId) as any; const tenantPrisma = forTenant(this.prisma, tenantId, userId) as any;
const existing = await tenantPrisma.tenderSavedSearch.findUnique({ const existing = await tenantPrisma.tenderSavedSearch.findUnique({
where: { id }, where: { id },
}); });
@@ -1,6 +1,7 @@
import { ConflictException } from '@nestjs/common'; import { ConflictException } from '@nestjs/common';
import { describe, expect, it, vi } from 'vitest'; import { describe, expect, it, vi } from 'vitest';
import { TenderTriageService } from './tender-triage.service'; import { TenderTriageService } from './tender-triage.service';
import { forTenant } from '../prisma/prisma-tenant.extension';
/** /**
* TenderTriageService.spec — RED-first (TDD) proof for UI-03/04 (D-09/D-10/ * TenderTriageService.spec — RED-first (TDD) proof for UI-03/04 (D-09/D-10/
@@ -188,6 +189,8 @@ describe('TenderTriageService', () => {
await service.setTriage('u1', 't1', 'tender-x', { isRead: true }); await service.setTriage('u1', 't1', 'tender-x', { isRead: true });
expectBoundCall(prisma, 't1', 'upsert'); expectBoundCall(prisma, 't1', 'upsert');
// Benutzerdimension (260911-nke): forTenant() bekommt userId als drittes Argument.
expect(forTenant).toHaveBeenCalledWith(prisma, 't1', 'u1');
}); });
it('listForUser() bindet tenderTriage.findMany an den uebergebenen Mandanten', async () => { it('listForUser() bindet tenderTriage.findMany an den uebergebenen Mandanten', async () => {
@@ -25,6 +25,12 @@ export interface SetTriageInput {
* TenderSavedSearch policy in Aufgabe 1; all five policies of this area * TenderSavedSearch policy in Aufgabe 1; all five policies of this area
* share the identical `"tenantId" = current_tenant_id()` text). * share the identical `"tenantId" = current_tenant_id()` text).
* *
* Nachtrag (260911-nke, Etappe 3b): seit Migration 20260911120000 traegt
* die Regel auf TenderTriage die Benutzerdimension (`current_user_id() IS
* NULL OR "userId" = current_user_id()`) — alle drei `forTenant()`-Aufrufe
* unten reichen `userId` als drittes Argument durch. Die anwendungsseitige
* userId-Filterung bleibt zweites Netz, kein Ersatz.
*
* Cascade (Pitfall 6): the schema's `Tender @relation(..., onDelete: * Cascade (Pitfall 6): the schema's `Tender @relation(..., onDelete:
* Cascade)` removes a tender's triage rows automatically when Phase 10's * Cascade)` removes a tender's triage rows automatically when Phase 10's
* retention job deletes the tender — no manual cleanup needed here. * retention job deletes the tender — no manual cleanup needed here.
@@ -69,7 +75,7 @@ export class TenderTriageService {
update.favoritedAt = dto.isFavorite ? now : null; update.favoritedAt = dto.isFavorite ? now : null;
} }
const tenantPrisma = forTenant(this.prisma, tenantId) as any; const tenantPrisma = forTenant(this.prisma, tenantId, userId) as any;
try { try {
return await tenantPrisma.tenderTriage.upsert({ return await tenantPrisma.tenderTriage.upsert({
where: { userId_tenderId: { userId, tenderId } }, where: { userId_tenderId: { userId, tenderId } },
@@ -105,7 +111,7 @@ export class TenderTriageService {
*/ */
async listForUser(userId: string, tenantId: string, tenderIds: string[]) { async listForUser(userId: string, tenantId: string, tenderIds: string[]) {
if (!tenderIds.length) return []; if (!tenderIds.length) return [];
const tenantPrisma = forTenant(this.prisma, tenantId) as any; const tenantPrisma = forTenant(this.prisma, tenantId, userId) as any;
return tenantPrisma.tenderTriage.findMany({ return tenantPrisma.tenderTriage.findMany({
where: { userId, tenderId: { in: tenderIds } }, where: { userId, tenderId: { in: tenderIds } },
}); });
@@ -117,7 +123,7 @@ export class TenderTriageService {
* tender-query.builder.ts's buildTenderWhere. * tender-query.builder.ts's buildTenderWhere.
*/ */
async favoriteIds(userId: string, tenantId: string): Promise<string[]> { async favoriteIds(userId: string, tenantId: string): Promise<string[]> {
const tenantPrisma = forTenant(this.prisma, tenantId) as any; const tenantPrisma = forTenant(this.prisma, tenantId, userId) as any;
const rows = await tenantPrisma.tenderTriage.findMany({ const rows = await tenantPrisma.tenderTriage.findMany({
where: { userId, isFavorite: true }, where: { userId, isFavorite: true },
select: { tenderId: true }, select: { tenderId: true },
+14 -2
View File
@@ -320,8 +320,20 @@ die vollständige, maschinell geprüfte Liste — von dort ableiten, nicht raten
**Was ein Entwickler nie vergessen darf:** jeder Zugriff auf eine mandantengebundene Tabelle läuft **Was ein Entwickler nie vergessen darf:** jeder Zugriff auf eine mandantengebundene Tabelle läuft
dienst-intern über einen mit `forTenant()` gebundenen Klienten `tenantPrisma` dienst-intern über einen mit `forTenant()` gebundenen Klienten `tenantPrisma`
(`apps/api/src/prisma/prisma-tenant.extension.ts`) — die zusätzlichen `where`-Filter über (`apps/api/src/prisma/prisma-tenant.extension.ts`) — `forTenant(prisma, tenantId, userId?)`
`userId` bleiben bestehen, wo die Regel selbst keine Benutzerdimension kennt (siehe trägt seit Migration `20260911120000_rls_user_dimension_personal_tables` (Etappe 3b,
260911-nke) einen optionalen dritten Parameter: zehn persönliche Tabellen
(CalendarSource, DashboardLayout, FavoriteLink, SearchProvider,
TenderEmailConfig, TenderNotificationPref, TenderRssFeedSource,
TenderSavedSearch, TenderTriage, WidgetInstance) tragen die Benutzerdimension
in der Regel (`current_user_id() IS NULL OR "userId" = current_user_id()`),
vier Tabellen mit `userId`-Spalte aber ohne persönliche Daten
(GroupMembership, ModuleGrant, PasswordResetToken, TenderMatch) nicht. Nur
Nutzer-CRUD-Aufrufer setzen `userId`; Hintergrunddienste und Verwaltungswege
rufen weiterhin ohne ihn — das macht die `IS NULL OR`-Form fuer sie
wirkungslos, keine Verschlechterung. Die zusätzlichen `where`-Filter über
`userId` im Anwendungscode bleiben in JEDEM Fall bestehen — zweites Netz,
kein Ersatz (siehe
`docs/mandantentrennung-etappe2-fehlerrichtung.md`). Bei den Tabellen ohne eigene `tenantId` `docs/mandantentrennung-etappe2-fehlerrichtung.md`). Bei den Tabellen ohne eigene `tenantId`
(oben) filtert die Anwendung stattdessen — wo relevant — über den zutreffenden Bezug (z. B. (oben) filtert die Anwendung stattdessen — wo relevant — über den zutreffenden Bezug (z. B.
plattformweiter Katalog, kein Mandantenfilter nötig); siehe plattformweiter Katalog, kein Mandantenfilter nötig); siehe
+18
View File
@@ -67,6 +67,24 @@ noetig sind:
`20260909140000_rls_remaining_tenant_tables` ergaenzt die bislang `20260909140000_rls_remaining_tenant_tables` ergaenzt die bislang
fehlenden 16 Tabellen; alle 20 Tabellen mit `tenantId` tragen jetzt eine fehlenden 16 Tabellen; alle 20 Tabellen mit `tenantId` tragen jetzt eine
Regel. Regel.
- **Eine zweite Sitzungsvariable fuer die Benutzerdimension.** Neben
`app.current_tenant` (Migration `20260618112133_rls_policies`,
`current_tenant_id()`) setzt die Rolle seit Migration
`20260911120000_rls_user_dimension_personal_tables` (Etappe 3b,
260911-nke) zusaetzlich `app.current_user`. Die zugehoerige Funktion
`current_user_id()` faltet den Leerstring per `NULLIF` auf `NULL` — der
Helfer `forTenant()` sendet "kein Benutzer" ausdruecklich als Leerstring,
nicht als weggelassene Variable, damit ein Aufruf ohne Benutzer nie einen
Benutzer aus einer fruaheren Transaktion derselben Verbindung erben kann.
Kein `GRANT EXECUTE` noetig — wie bei `current_tenant_id()` vergibt
PostgreSQL EXECUTE auf Funktionen standardmaessig an PUBLIC. Die Regeln
der zehn persoenlichen Tabellen (CalendarSource, DashboardLayout,
FavoriteLink, SearchProvider, TenderEmailConfig, TenderNotificationPref,
TenderRssFeedSource, TenderSavedSearch, TenderTriage, WidgetInstance)
pruefen `current_user_id() IS NULL OR "userId" = current_user_id()` —
ein Aufruf OHNE gesetzten Benutzer (Admin, Hintergrunddienst) sieht
weiterhin den ganzen Mandanten, das macht die Aenderung fuer heutige
Aufrufer wirkungslos.
## 3. Der Sperrgrund — warum die Umstellung noch nicht erfolgt ist ## 3. Der Sperrgrund — warum die Umstellung noch nicht erfolgt ist
@@ -460,6 +460,14 @@ Bereich brauchte:
anwendungsseitige `userId`-Filterung, die alle fünf umzustellenden anwendungsseitige `userId`-Filterung, die alle fünf umzustellenden
Dienste bereits führen, bleibt deshalb der einzige Schutz gegen Dienste bereits führen, bleibt deshalb der einzige Schutz gegen
Quer-Lesen zwischen Nutzern und wird bei der Umstellung NICHT entfernt. Quer-Lesen zwischen Nutzern und wird bei der Umstellung NICHT entfernt.
**Nachtrag (260911-nke):** seit Migration `20260911120000_rls_user_dimension_personal_tables`
trägt die Regel auf `TenderSavedSearch` die Benutzerdimension — die alte
Messung bleibt unter dem Namen `tendersavedsearch-ohne-benutzer-sieht-beide-nutzer-desselben-mandanten`
als die gewollte Eigenschaft für Admin/Hintergrunddienst bestehen, die
Umkehrung `tendersavedsearch-benutzer-a-sieht-kollegen-nicht-gebunden`
misst MIT Benutzer und erwartet das Gegenteil. Siehe Abschnitt
"Regelschluss Benutzerdimension (Etappe 3b, 260911-nke)" unten.
- `tenderrssfeed-plattformzeile-unter-jedem-mandanten-unsichtbar` — die - `tenderrssfeed-plattformzeile-unter-jedem-mandanten-unsichtbar` — die
plattformweite RSS-Zeile (`userId`/`tenantId` beide `NULL`, wie der plattformweite RSS-Zeile (`userId`/`tenantId` beide `NULL`, wie der
geseedete `service.bund.de`-Feed) ist unter TENANT-A UND TENANT-B geseedete `service.bund.de`-Feed) ist unter TENANT-A UND TENANT-B
@@ -654,6 +662,15 @@ Warnung wäre Dauerlärm und verlöre ihr Signal.
Zuständigkeitsfrage im Umfeld von WINDOWS #19, kein Auftrag dieser Zuständigkeitsfrage im Umfeld von WINDOWS #19, kein Auftrag dieser
Aufgabe. Aufgabe.
**Nachtrag (260911-nke):** die Benutzerdimension der `TenderRssFeedSource`-Regeln
ist seit Migration `20260911120000_rls_user_dimension_personal_tables`
Teil der vier befehlsgetrennten Regeln (Lesen schließt gemeinsame/eigene
Zeilen ein, Schreiben verlangt weiterhin `userId = current_user_id()`),
gemessen in `tenderrssfeed-gemeinsame-zeile-*`. Befund G (Admin eines
beliebigen Mandanten entfernt eine plattformweite Zeile) bleibt
unverändert — WINDOWS #24 hält das offen, siehe Abschnitt "Regelschluss
Benutzerdimension (Etappe 3b, 260911-nke)" unten.
### (t5) Was dieser Durchlauf bewusst nicht anfasst ### (t5) Was dieser Durchlauf bewusst nicht anfasst
Die zwölf Paare des plattformweiten Ausschreibungskatalogs (D-03, Die zwölf Paare des plattformweiten Ausschreibungskatalogs (D-03,
@@ -1749,6 +1766,13 @@ Tabellen, inklusive der NEUEN Stelle aus Befund F:
eine Benutzerdimension auf Datenbankebene durchzusetzen, ohne eine solche eine Benutzerdimension auf Datenbankebene durchzusetzen, ohne eine solche
Variable erst einzuführen. Nicht gebaut in diesem Durchlauf — die Variable erst einzuführen. Nicht gebaut in diesem Durchlauf — die
anwendungsseitige `userId`-Filterung bleibt der einzige Schutz. anwendungsseitige `userId`-Filterung bleibt der einzige Schutz.
**Nachtrag (260911-nke): ÜBERHOLT — die zweite Sitzungsvariable ist
gebaut.** Migration `20260911120000_rls_user_dimension_personal_tables`
führt `app.current_user`/`current_user_id()` ein und trägt die
Benutzerdimension in die Regeln der zehn persönlichen Tabellen, darunter
`TenderSavedSearch`. Siehe Abschnitt "Regelschluss Benutzerdimension
(Etappe 3b, 260911-nke)" unten.
- **Die plattformweite Eindeutigkeit von Anmeldename und Adresse (WINDOWS - **Die plattformweite Eindeutigkeit von Anmeldename und Adresse (WINDOWS
#22)** — unverändert, nicht Gegenstand dieses Plans. #22)** — unverändert, nicht Gegenstand dieses Plans.
@@ -1809,6 +1833,21 @@ searchprovider-gebundenes-einfuegen-ohne-mandant-abgelehnt: bestanden — gebund
Alle 87 Pruefungen bestanden. Alle 87 Pruefungen bestanden.
``` ```
**Nachtrag (260911-nke):** die drei oben zitierten Ausgabezeilen
(`dashboardlayout-fremder-nutzer-desselben-mandanten-gebunden-sichtbar`,
`widgetinstance-fremder-nutzer-desselben-mandanten-gebunden-sichtbar`,
`searchprovider-fremder-nutzer-desselben-mandanten-gebunden-sichtbar`)
bleiben als historische Messung stehen — sie sind seit Migration
`20260911120000_rls_user_dimension_personal_tables` UMGEDREHT, nicht
gelöscht: die alte Messung lebt unter den neuen Namen
`dashboardlayout-ohne-benutzer-sieht-beide-nutzer-desselben-mandanten`,
`widgetinstance-ohne-benutzer-sieht-beide-nutzer-desselben-mandanten` und
`searchprovider-ohne-benutzer-sieht-beide-nutzer-desselben-mandanten`
weiter (jetzt als gewollte Eigenschaft für Admin/Hintergrunddienst), dazu je
eine neue Umkehrung `<tabelle>-benutzer-a-sieht-kollegen-nicht-gebunden` MIT
Benutzer. Siehe Abschnitt "Regelschluss Benutzerdimension (Etappe 3b,
260911-nke)" unten.
Dreizehn neue Prüfungen, nicht zwölf wie in der Aufzählung des Plans Dreizehn neue Prüfungen, nicht zwölf wie in der Aufzählung des Plans
namentlich vorgezeichnet — die dreizehnte namentlich vorgezeichnet — die dreizehnte
(`widgetinstance-fremder-nutzer-desselben-mandanten-gebunden-sichtbar`) (`widgetinstance-fremder-nutzer-desselben-mandanten-gebunden-sichtbar`)
@@ -1984,6 +2023,15 @@ Abweichung von seiner eigenen Auswahl liest, bleibt das unbemerkt.
gebunden wie #18 — er wird erst gebunden wie #18 — er wird erst
nach dem Scharfschalten beobachtbar. nach dem Scharfschalten beobachtbar.
**Nachtrag (260911-nke):** die Benutzerdimension der Regeln auf
`DashboardLayout`/`WidgetInstance`/`SearchProvider` (Lesen UND Schreiben
je Kollegenzeile) ist seit Migration `20260911120000_rls_user_dimension_personal_tables`
geschlossen — siehe (w1) oben und Abschnitt "Regelschluss
Benutzerdimension (Etappe 3b, 260911-nke)" unten. Die plattformweite
Eindeutigkeit von `DashboardLayout.userId` (Befund K, dieser Punkt) ist
davon UNBERÜHRT und bleibt für Etappe 3 vorgemerkt — 3b ändert keine
Unique-Constraints.
### (w5) Was dieser Durchlauf bewusst nicht anfasst ### (w5) Was dieser Durchlauf bewusst nicht anfasst
- **Das Frontend** — geprüft (Befund I/J, (w3) oben) und bewusst gelassen, - **Das Frontend** — geprüft (Befund I/J, (w3) oben) und bewusst gelassen,
@@ -2086,6 +2134,16 @@ calendarsource-generierter-client-gebundenes-anlegen-eigener-mandant-gelingt: be
Alle 101 Pruefungen bestanden. Alle 101 Pruefungen bestanden.
``` ```
**Nachtrag (260911-nke):** `calendarsource-fremder-nutzer-desselben-mandanten-gebunden-sichtbar`
ist seit Migration `20260911120000_rls_user_dimension_personal_tables`
UMGEDREHT — die alte Messung lebt unter dem Namen
`calendarsource-ohne-benutzer-sieht-beide-nutzer-desselben-mandanten`
weiter (gewollte Eigenschaft ohne Benutzer), die neue Umkehrung
`calendarsource-benutzer-a-sieht-kollegen-nicht-gebunden` misst MIT
Benutzer und bestätigt, dass `encryptedPassword` eines Kollegen jetzt NICHT
mehr lesbar ist. Siehe Abschnitt "Regelschluss Benutzerdimension (Etappe
3b, 260911-nke)" unten.
Dreizehn neue Prüfungen (101 = 88 + 13), nicht zwölf wie in der Aufzählung Dreizehn neue Prüfungen (101 = 88 + 13), nicht zwölf wie in der Aufzählung
des Plans namentlich vorgezeichnet — die dreizehnte des Plans namentlich vorgezeichnet — die dreizehnte
(`calendarsource-regelstand-eindeutig`) wurde ergänzt, weil sie die (`calendarsource-regelstand-eindeutig`) wurde ergänzt, weil sie die
@@ -2235,6 +2293,14 @@ Browsers und im API-Log, an keiner Stelle in der Benutzeroberfläche.
aufnimmt (`CalendarSource` steht dort ausdrücklich in der Liste). Bis aufnimmt (`CalendarSource` steht dort ausdrücklich in der Liste). Bis
dahin bleiben der `userId`-Filter in `getSources`/`fetchAndCacheEvents` dahin bleiben der `userId`-Filter in `getSources`/`fetchAndCacheEvents`
und die drei Besitzprüfungen der EINZIGE Schutz. und die drei Besitzprüfungen der EINZIGE Schutz.
**Nachtrag (260911-nke): ÜBERHOLT — die Benutzerdimension ist in der
Regel.** Migration `20260911120000_rls_user_dimension_personal_tables`
trägt `current_user_id() IS NULL OR "userId" = current_user_id()` in die
`CalendarSource`-Regel; `calendarsource-benutzer-a-sieht-kollegen-nicht-gebunden`
bestätigt, dass die verschlüsselten Zugangsdaten eines Kollegen jetzt
NICHT mehr lesbar sind. Der `userId`-Filter und die Besitzprüfungen
bleiben zusätzlich bestehen (zweites Netz, kein Ersatz).
- **(d) 403 statt 404 als Existenzpreisgabe zwischen Kollegen (Befund D):** - **(d) 403 statt 404 als Existenzpreisgabe zwischen Kollegen (Befund D):**
`updateSource`, `deleteSource` und `testConnection` werfen bei fremdem `updateSource`, `deleteSource` und `testConnection` werfen bei fremdem
Besitz `ForbiddenException('Not your calendar source')` (403), bei Besitz `ForbiddenException('Not your calendar source')` (403), bei
@@ -2749,6 +2815,17 @@ favoritelink-gebundenes-anlegen-eigener-mandant-gelingt: bestanden — bound.fav
Alle 137 Pruefungen bestanden. Alle 137 Pruefungen bestanden.
``` ```
**Nachtrag (260911-nke):** die Doppelaussage in
`favoritelink-liste-generierter-client-gebunden-eigener-mandant-liefert-eigene-zeilen`
ist GETRENNT — die Prüfung behält nur die erste Hälfte (die eigenen Zeilen
kommen). Die zweite Hälfte (Kollege sichtbar) lebt jetzt als eigene Prüfung
`favoritelink-ohne-benutzer-sieht-beide-nutzer-desselben-mandanten` (alte
Messung, gewollte Eigenschaft ohne Benutzer) plus die Umkehrung
`favoritelink-benutzer-a-sieht-kollegen-nicht-gebunden` (MIT Benutzer,
Kollege NICHT mehr sichtbar) — seit Migration
`20260911120000_rls_user_dimension_personal_tables`. Siehe Abschnitt
"Regelschluss Benutzerdimension (Etappe 3b, 260911-nke)" unten.
Die tragende Belegzeile ist `favoritelink-liste-generierter-client-ungebunden-liefert-leere-liste`: der Die tragende Belegzeile ist `favoritelink-liste-generierter-client-ungebunden-liefert-leere-liste`: der
IDENTISCHE `findMany`, den `list` heute stellt, liefert UNGEBUNDEN `[]`, IDENTISCHE `findMany`, den `list` heute stellt, liefert UNGEBUNDEN `[]`,
während die Wartungsrolle zwei Zeilen sieht — das ist der Wert, aus dem während die Wartungsrolle zwei Zeilen sieht — das ist der Wert, aus dem
@@ -2817,6 +2894,14 @@ Scharfschalten neu entsteht.
`userId`-Filterung bleibt bestehen und ist bis zur Etappe-3-Entscheidung `userId`-Filterung bleibt bestehen und ist bis zur Etappe-3-Entscheidung
(2) der einzige Schutz gegen Quer-Lesen zwischen Nutzern desselben (2) der einzige Schutz gegen Quer-Lesen zwischen Nutzern desselben
Mandanten. Mandanten.
**Nachtrag (260911-nke): ÜBERHOLT — die Benutzerdimension ist in der
Regel.** Migration `20260911120000_rls_user_dimension_personal_tables`
trägt `current_user_id() IS NULL OR "userId" = current_user_id()` in die
`FavoriteLink`-Regel; `favoritelink-benutzer-a-sieht-kollegen-nicht-gebunden`
bestätigt, dass die Kollegenzeile über die `widgetId` jetzt NICHT mehr
sichtbar ist. Die anwendungsseitige `userId`-Filterung bleibt zusätzlich
bestehen (zweites Netz, kein Ersatz).
- **(b) Das Frontend.** Die `list`-Kette aus (f3) wird nicht geändert; - **(b) Das Frontend.** Die `list`-Kette aus (f3) wird nicht geändert;
Ledger-Eintrag in Aufgabe 3. Ledger-Eintrag in Aufgabe 3.
- **(c) Die Mandantenquelle — warum der dashboard-Präzedenzfall und nicht - **(c) Die Mandantenquelle — warum der dashboard-Präzedenzfall und nicht
@@ -3029,6 +3114,118 @@ unverändert und steht nicht in der Erlaubnisliste.
(lokal gibt es keinen `mailhog`); in der neuen Testdatei per (lokal gibt es keinen `mailhog`); in der neuen Testdatei per
`vi.mock('nodemailer')` ersetzt. `vi.mock('nodemailer')` ersetzt.
## Regelschluss Benutzerdimension (Etappe 3b, 260911-nke)
Reiner Datenbank- und Helfer-Umbau: Migration `20260911120000_rls_user_dimension_personal_tables`
bringt die Funktion `current_user_id()` und die Benutzerdimension in die
Regeln der zehn persönlichen Tabellen. `forTenant(prisma, tenantId, userId?)`
bekommt einen optionalen dritten Parameter; 34 Nutzer-CRUD-Aufrufstellen in
acht Diensten reichen ihn durch. Der Schalter bleibt AUS — nichts hiervon
wirkt, bis Etappe 4 scharfschaltet.
### (b1) Die Messung
Wörtliche Werkzeugausgabe der drei Funktionsfälle:
```
current-user-id-ungesetzt-ist-null: bestanden — current_user_id() ohne gesetzte Variable=null
current-user-id-leer-ist-null: bestanden — current_user_id() nach set_config('app.current_user', '', true)=null
current-user-id-gesetzt-liefert-wert: bestanden — current_user_id() nach set_config('app.current_user', 'user-a1', true)="user-a1"
```
Wörtliche Werkzeugausgabe zweier der sechs Umkehrungen (eine Ein-Regel-Tabelle,
eine der beiden vier-Regel-Tabellen):
```
tendersavedsearch-benutzer-a-sieht-kollegen-nicht-gebunden: bestanden — forTenant(TENANT-A, user-a1) liefert sichtbare Nutzer: ["user-a1"]
calendarsource-benutzer-a-sieht-kollegen-nicht-gebunden: bestanden — forTenant(TENANT-A, user-a1) liefert die Quelle von 'user-a2' mit: undefined — encryptedPassword des Kollegen ist damit auf Datenbankebene nicht mehr lesbar
searchprovider-gemeinsame-zeile-als-benutzer-a-nicht-entfernbar: bestanden — bound(TENANT-A, user-a1).searchProvider.deleteMany({ id: 'search-shared-a' }) liefert count=0 — eine Regel ohne Befehlstrennung wuerde hier 1 liefern
tenderrssfeed-gemeinsame-zeile-ohne-benutzer-weiterhin-entfernbar: bestanden — bound(TENANT-A, ohne Benutzer).tenderRssFeedSource.deleteMany({ id: 'rss-platform' }) liefert count=0 — WINDOWS #24: die plattformweite Zeile hat keinen Mandanten, die Schreibregel verlangt aber einen; das gilt VOR wie NACH dieser Migration unveraendert und ist kein neu entdecktes Loch
Alle 203 Pruefungen bestanden.
```
`pg_policies` der lebenden Datenbank (`tessera-ctl-db-1`) für die zehn
umgestellten und die vier bewusst unveränderten Tabellen, gemessen nach dem
Anwenden von `20260911120000` (Tabelle#Regelname#Befehl#USING#WITH CHECK):
```
CalendarSource#tenant_isolation_policy#ALL#(("tenantId" = current_tenant_id()) AND ((current_user_id() IS NULL) OR ("userId" = current_user_id())))#
DashboardLayout#tenant_isolation_policy#ALL#(("tenantId" = current_tenant_id()) AND ((current_user_id() IS NULL) OR ("userId" = current_user_id())))#
FavoriteLink#tenant_isolation_policy#ALL#(("tenantId" = current_tenant_id()) AND ((current_user_id() IS NULL) OR ("userId" = current_user_id())))#
GroupMembership#tenant_isolation_policy#ALL#(("groupId" IN ( SELECT "Group".id FROM "Group" WHERE ("Group"."tenantId" = current_tenant_id()))) AND ("userId" IN ( SELECT "User".id FROM "User" WHERE ("User"."tenantId" = current_tenant_id()))))#
ModuleGrant#tenant_isolation_policy#ALL#(("tenantId" = current_tenant_id()) AND (("groupId" IS NULL) OR ("groupId" IN ( SELECT "Group".id FROM "Group" WHERE ("Group"."tenantId" = current_tenant_id())))) AND (("userId" IS NULL) OR ("userId" IN ( SELECT "User".id FROM "User" WHERE ("User"."tenantId" = current_tenant_id())))))#
PasswordResetToken#tenant_isolation_policy#ALL#("userId" IN ( SELECT "User".id FROM "User" WHERE ("User"."tenantId" = current_tenant_id())))#
SearchProvider#tenant_user_delete_policy#DELETE#(("tenantId" = current_tenant_id()) AND ((current_user_id() IS NULL) OR ("userId" = current_user_id())))#
SearchProvider#tenant_user_insert_policy#INSERT##(("tenantId" = current_tenant_id()) AND ((current_user_id() IS NULL) OR ("userId" = current_user_id())))
SearchProvider#tenant_user_read_policy#SELECT#(("tenantId" = current_tenant_id()) AND ((current_user_id() IS NULL) OR ("userId" IS NULL) OR ("userId" = current_user_id())))#
SearchProvider#tenant_user_update_policy#UPDATE#(("tenantId" = current_tenant_id()) AND ((current_user_id() IS NULL) OR ("userId" = current_user_id())))#(("tenantId" = current_tenant_id()) AND ((current_user_id() IS NULL) OR ("userId" = current_user_id())))
TenderEmailConfig#tenant_isolation_policy#ALL#(("tenantId" = current_tenant_id()) AND ((current_user_id() IS NULL) OR ("userId" = current_user_id())))#
TenderMatch#tenant_isolation_policy#ALL#("tenantId" = current_tenant_id())#
TenderNotificationPref#tenant_isolation_policy#ALL#(("tenantId" = current_tenant_id()) AND ((current_user_id() IS NULL) OR ("userId" = current_user_id())))#
TenderRssFeedSource#tenant_delete_policy#DELETE#(("tenantId" = current_tenant_id()) AND ((current_user_id() IS NULL) OR ("userId" = current_user_id())))#
TenderRssFeedSource#tenant_insert_policy#INSERT##(("tenantId" = current_tenant_id()) AND ((current_user_id() IS NULL) OR ("userId" = current_user_id())))
TenderRssFeedSource#tenant_platform_read_policy#SELECT#((("tenantId" = current_tenant_id()) OR ("tenantId" IS NULL)) AND ((current_user_id() IS NULL) OR ("userId" IS NULL) OR ("userId" = current_user_id())))#
TenderRssFeedSource#tenant_update_policy#UPDATE#(("tenantId" = current_tenant_id()) AND ((current_user_id() IS NULL) OR ("userId" = current_user_id())))#(("tenantId" = current_tenant_id()) AND ((current_user_id() IS NULL) OR ("userId" = current_user_id())))
TenderSavedSearch#tenant_isolation_policy#ALL#(("tenantId" = current_tenant_id()) AND ((current_user_id() IS NULL) OR ("userId" = current_user_id())))#
TenderTriage#tenant_isolation_policy#ALL#(("tenantId" = current_tenant_id()) AND ((current_user_id() IS NULL) OR ("userId" = current_user_id())))#
WidgetInstance#tenant_isolation_policy#ALL#(("tenantId" = current_tenant_id()) AND ((current_user_id() IS NULL) OR ("userId" = current_user_id())))#
```
Werkzeug-Endstand nach Aufgabe 2: `Alle 203 Pruefungen bestanden.` (Baseline vor
diesem Lauf: 137). Tests am Ende von Aufgabe 2: 1020 bestanden / 62 Dateien,
Typprüfung sauber.
### (b2) Signaltabelle — beide Fehlerrichtungen je Regel
| Fehlerrichtung | Erwartung | Gemessen | Befund |
|---|---|---|---|
| Zu streng: ein Aufruf OHNE Benutzer (Admin, Hintergrunddienst) sähe nur EINEN Nutzer statt des ganzen Mandanten | Regel müsste beide Nutzer weiterhin liefern | `<tabelle>-ohne-benutzer-sieht-beide` (zehn Tabellen) — JEDE Tabelle liefert beide Nutzer | NICHT der Fall — die `IS NULL OR`-Form wirkt wie entworfen |
| Zu locker: ein Aufruf MIT Benutzer sähe die Zeile eines Kollegen DESSELBEN Mandanten | Regel müsste die Kollegenzeile ausblenden | `<tabelle>-benutzer-a-sieht-kollegen-nicht(-gebunden)` (zehn Tabellen) — JEDE Tabelle blendet aus; `<tabelle>-schreiben-als-a-mit-kennung-b-abgelehnt` (SQLSTATE 42501) für alle zehn | NICHT der Fall — Lesen UND Schreiben sind durchgesetzt |
| Bewusst offene Flanke: ein Nutzer-CRUD-Aufrufer, der `userId` VERGISST | Sähe den ganzen Mandanten, keine Fehlermeldung | Nicht durch einen Test erzwungen — kein Wächter über das dritte Argument gebaut | Akzeptiert, aufgezeichnet (T-NKE-02, WINDOWS-Eintrag unten) — heute exakt der Stand vor dieser Migration |
### (b3) Welcher Code Leere anders deutet als vorher
Erst wirksam NACH dem Scharfschalten (Etappe 4) — heute mit BYPASSRLS ohne
Wirkung, hier vorab aufgezeichnet, weil der Codepfad schon jetzt geschrieben
ist. Methoden, die eine Zeile per `findUnique({ where: { id } })` holen und
danach `userId` gegen den Aufrufer vergleichen, sehen nach dem Scharfschalten
die Kollegenzeile bereits als `null` (die Regel blendet sie aus, BEVOR die
Anwendung überhaupt vergleicht) — die Anwendung meldet dann `NotFoundException`
statt der heutigen `Forbidden`-artigen Abweisung über den `userId`-Vergleich.
Betroffen: `dashboard.service.ts` (`removeWidget`, `updateWidgetConfig`,
`removeSearchProvider`), `calendar.service.ts` (`updateSource`,
`deleteSource`), `favorites.service.ts` (`update`, `remove`). Beides ist eine
Abweisung — kein Informationsleck entsteht, nur die Fehlerart ändert sich von
Forbidden zu NotFound.
### (b4) Was dieser Durchlauf bewusst nicht löst
- Systemkontext für Hintergrunddienste (Etappe 3c) — `tender-digest.scheduler.ts`
bleibt bewusst zweistellig, mit Kommentar.
- Anmeldenamen pro Mandant (Etappe 3a) — nicht Teil dieses Laufs.
- Kein Wächter, der jede Nutzer-CRUD-Aufrufstelle auf das dritte Argument von
`forTenant()` prüft. Die Bestandsaufnahme (`rls-access-inventory.spec.ts`)
unterscheidet heute nur mandanten-gebunden/ungebunden, nicht
benutzer-gebunden — ein Aufrufer, der `userId` vergisst, ist für sie
unsichtbar. Netz bis dahin: die dreistelligen Spec-Zusicherungen je Dienst.
Siehe der neue WINDOWS-Eintrag unten.
- WINDOWS #24 (Admin-Erstellung/-Entfernen plattformweiter `TenderRssFeedSource`-Zeilen
bleibt ungebunden) bleibt unverändert offen — diese Migration ändert daran
nichts, `tenderrssfeed-gemeinsame-zeile-ohne-benutzer-weiterhin-entfernbar`
bestätigt das lediglich erneut.
### (b5) Was dieser Durchlauf bewusst nicht anfasst
- Die vier Tabellen mit `userId`-Spalte, die KEINE persönlichen Daten tragen
(`GroupMembership`, `ModuleGrant`, `PasswordResetToken`, `TenderMatch`) —
Verwaltungsobjekte, Anmelde-Artefakt, Hintergrunddienst-Schreibweg,
begründet im Kopf der Migration.
- Die drei SECURITY-DEFINER-Anmeldefunktionen (`auth_lookup_user_by_username`,
`auth_lookup_user_by_email`, `auth_lookup_reset_token`) — unangetastet.
- `schema.prisma` — unverändert, Gate gegen `8829999` in jeder Aufgabe.
- Der Schalter (`DATABASE_URL` → Rolle `tessera`, BYPASSRLS) — bleibt AUS.
- Compose-/Umgebungsdateien — unangetastet.
## Etappe 2 — Abschluss ## Etappe 2 — Abschluss
Etappe 2 der Mandantentrennung ist mit diesem Lauf (260911-gwh) vollständig: Etappe 2 der Mandantentrennung ist mit diesem Lauf (260911-gwh) vollständig:
@@ -3111,6 +3308,15 @@ dieser Aufgabe.
Etappe-3-Entscheidung (1)) — siehe (h4)(a). Etappe-3-Entscheidung (1)) — siehe (h4)(a).
- Die Benutzerdimension der Regeln (Etappe-3-Entscheidung (2)) — siehe - Die Benutzerdimension der Regeln (Etappe-3-Entscheidung (2)) — siehe
(k4)/(f4)(a) und die übrigen Bereiche mit derselben Beobachtung. (k4)/(f4)(a) und die übrigen Bereiche mit derselben Beobachtung.
**Nachtrag (260911-nke): ERLEDIGT.** Migration
`20260911120000_rls_user_dimension_personal_tables` (Etappe 3b) trägt die
Benutzerdimension in die Regeln aller zehn persönlichen Tabellen; 34
Nutzer-CRUD-Aufrufstellen reichen `userId` an `forTenant()` durch. Siehe
Abschnitt "Regelschluss Benutzerdimension (Etappe 3b, 260911-nke)" oben.
Was davon bewusst offenbleibt: ein Aufrufer, der `userId` vergisst, sieht
weiterhin den ganzen Mandanten (kein Wächter gebaut, siehe
`.planning/WINDOWS.md`); Systemkontext für Hintergrunddienste (Etappe 3c)
und Anmeldenamen pro Mandant (Etappe 3a) bleiben offen.
- Die Modulkatalog-Regel für `Module` (Befund E, `module-registry`) — sobald - Die Modulkatalog-Regel für `Module` (Befund E, `module-registry`) — sobald
eine Regel eingeführt wird, müssen die heute bewusst ungebundenen eine Regel eingeführt wird, müssen die heute bewusst ungebundenen
Katalogzugriffe nachgezogen werden. Katalogzugriffe nachgezogen werden.
+13
View File
@@ -38,6 +38,19 @@ der Grundlagen beginnen koennen. Alles hier ist gemessen, nicht erinnert.
### 3b zuerst: Benutzerdimension in den Regeln ### 3b zuerst: Benutzerdimension in den Regeln
**Erledigt (260911-nke, f0b531b/07fc653 plus der Dokumentationscommit dieser
Aufgabe):** Migration
`20260911120000_rls_user_dimension_personal_tables` bringt `current_user_id()`
und die Benutzerdimension in die Regeln der zehn persoenlichen Tabellen;
`forTenant(prisma, tenantId, userId?)` bekommt den optionalen dritten
Parameter, 34 Nutzer-CRUD-Aufrufstellen in acht Diensten reichen ihn durch.
Gemessene Zahl der umgedrehten Loch-Pruefungen: SECHS, nicht drei wie unten
noch angenommen. Endzahlen: Tests 1020/62 Dateien, Werkzeug
`rls-scratch-check.mjs` 203/203 bestanden (Baseline vor diesem Lauf: 137).
Siehe `docs/mandantentrennung-etappe2-fehlerrichtung.md`, Abschnitt
"Regelschluss Benutzerdimension (Etappe 3b, 260911-nke)". Der ursprüngliche
Auftragstext unten bleibt unveraendert stehen (historische Planungsgrundlage).
Warum zuerst: reiner Datenbank- und Helfer-Umbau, beruehrt den Anmeldeweg Warum zuerst: reiner Datenbank- und Helfer-Umbau, beruehrt den Anmeldeweg
NICHT, und schliesst die Klasse von Befunden, die in sieben Bereichen als NICHT, und schliesst die Klasse von Befunden, die in sieben Bereichen als
"Policy hat keine Benutzerdimension" festgehalten wurde. "Policy hat keine Benutzerdimension" festgehalten wurde.
@@ -278,6 +278,15 @@ Unterabfrage-Form, die WINDOWS #27 aufgedeckt hat.
Die Zahl 72 ist der Ausgabe von `rls-access-inventory.spec.ts` entnommen, Die Zahl 72 ist der Ausgabe von `rls-access-inventory.spec.ts` entnommen,
nicht geschaetzt. nicht geschaetzt.
**Stand 260911-nke:** die Paarzahl (72) und die Klassen-Verteilung sind
UNVERÄNDERT — Etappe 3b (Benutzerdimension in den Regeln, `forTenant()`
bekommt ein drittes Argument) fügt keine neue Fundstelle hinzu und ändert
keine bestehende von mandanten-gebunden auf mandanten-ungebunden oder
umgekehrt; benutzer-gebunden ist keine eigene Klasse in diesem Schema. Die
Benutzerdimension steht stattdessen in der Begründungsspalte der drei
betroffenen Bestandsaufnahme-Zeilen (`calendarSource`, `widgetInstance`,
`favoriteLink`) oben und im Abschnitt "Was diese Etappe NICHT entscheidet".
| Klasse | Anzahl Paare | | Klasse | Anzahl Paare |
|---|---| |---|---|
| muss-mandantengebunden | 35 | | muss-mandantengebunden | 35 |
@@ -572,15 +581,15 @@ werden.
|---|---|---|---|---| |---|---|---|---|---|
| apps/api/src/auth/auth.service.ts | passwordResetToken | muss-mandantengebunden | gebunden | Kein eigenes `tenantId`, RLS ueber Join auf `User` (Migration 20260618112133). `requestPasswordReset`/`resetPassword` laufen vollstaendig ueber `forTenant()` (Etappe 1, WINDOWS #20, Aufgabe 1) — von der alten, nur `this.prisma.*` erkennenden Suche nie erfasst, weil bereits gebunden; die erweiterte Erkennung aus Aufgabe 2 (260909-ipc) macht diese Fundstelle erstmals sichtbar. | | apps/api/src/auth/auth.service.ts | passwordResetToken | muss-mandantengebunden | gebunden | Kein eigenes `tenantId`, RLS ueber Join auf `User` (Migration 20260618112133). `requestPasswordReset`/`resetPassword` laufen vollstaendig ueber `forTenant()` (Etappe 1, WINDOWS #20, Aufgabe 1) — von der alten, nur `this.prisma.*` erkennenden Suche nie erfasst, weil bereits gebunden; die erweiterte Erkennung aus Aufgabe 2 (260909-ipc) macht diese Fundstelle erstmals sichtbar. |
| apps/api/src/auth/auth.service.ts | user | muss-mandantengebunden | gebunden | Klassenkorrektur (260911-fh9, Aufgabe 2/3): wechselt von `gemischt` auf `gebunden` — `getMe`, `changePassword`, `adminResetPassword` binden seit Aufgabe 2 je über GENAU EINEN Klienten `tenantPrisma` an den Mandanten aus dem Sitzungsnachweis (`@CurrentUser().tenantId`); für die oberste Rolle (SUPER_ADMIN) löst der Controller den Mandanten des ZIELS über den gebundenen Fan-out `UserService.findByIdForPlatformAdmin` auf. `adminResetPassword` verweigert zusätzlich einem Nicht-SUPER_ADMIN das Kennwort eines SUPER_ADMIN (T-FH9-04). Die drei Anmeldesuchen (`validateUser`, `requestPasswordReset`, `resetPassword`) laufen weiterhin über die drei SECURITY-DEFINER-Funktionen (`$queryRaw`, keine Modellzugriffe — `$` liegt nicht in `[a-zA-Z]`) und bleiben unverändert auf dem ungebundenen Klienten. Etappe-3-Vorbehalt: die Bindung hängt am Claim `tenantId` und an `User.id` (plattformweite UUID), nicht an `username`/`email` — der Anmeldeweg-Umbau für je Mandant eindeutige Anmeldenamen betrifft diese Bindung nicht, siehe `docs/mandantentrennung-etappe2-fehlerrichtung.md`, Abschnitt "## Bereich auth", (h4)(a). | | apps/api/src/auth/auth.service.ts | user | muss-mandantengebunden | gebunden | Klassenkorrektur (260911-fh9, Aufgabe 2/3): wechselt von `gemischt` auf `gebunden` — `getMe`, `changePassword`, `adminResetPassword` binden seit Aufgabe 2 je über GENAU EINEN Klienten `tenantPrisma` an den Mandanten aus dem Sitzungsnachweis (`@CurrentUser().tenantId`); für die oberste Rolle (SUPER_ADMIN) löst der Controller den Mandanten des ZIELS über den gebundenen Fan-out `UserService.findByIdForPlatformAdmin` auf. `adminResetPassword` verweigert zusätzlich einem Nicht-SUPER_ADMIN das Kennwort eines SUPER_ADMIN (T-FH9-04). Die drei Anmeldesuchen (`validateUser`, `requestPasswordReset`, `resetPassword`) laufen weiterhin über die drei SECURITY-DEFINER-Funktionen (`$queryRaw`, keine Modellzugriffe — `$` liegt nicht in `[a-zA-Z]`) und bleiben unverändert auf dem ungebundenen Klienten. Etappe-3-Vorbehalt: die Bindung hängt am Claim `tenantId` und an `User.id` (plattformweite UUID), nicht an `username`/`email` — der Anmeldeweg-Umbau für je Mandant eindeutige Anmeldenamen betrifft diese Bindung nicht, siehe `docs/mandantentrennung-etappe2-fehlerrichtung.md`, Abschnitt "## Bereich auth", (h4)(a). |
| apps/api/src/calendar/calendar.service.ts | calendarSource | muss-mandantengebunden | gebunden | Kalenderquellen eines Nutzers je Mandant gebunden (encryptedPassword traegt Zugangsdaten zu externen Exchange-/CalDAV-Servern), `tenantId`-Spalte vorhanden. Seit 260911-cwh (Aufgabe 2) laufen alle zwoelf Zugriffe (`getSources`, `addSource`, beide Abfragen von `updateSource`/`deleteSource`, alle drei Abfragen von `testConnection`, Laden plus beide Synchronstatus-Rueckschreibungen von `fetchAndCacheEvents`) ueber `forTenant()`, ein Klient je Methode; `fetchAndCacheEvents`/`refreshCacheInBackground` nehmen die Mandantenkennung als Parameter, Letztere traegt die Kennung der urspruenglichen Anfrage. Die drei Besitzpruefungen (`updateSource`/`deleteSource`/`testConnection`, Vergleich gegen `userId` aus dem Sitzungsnachweis) bleiben zusaetzlich bestehen — die Regel auf `CalendarSource` kennt keine Benutzerdimension (260911-cwh, Aufgabe 1, gemessen), sie sind bis zur Etappe-3-Entscheidung (2) der einzige Schutz zwischen Kollegen DESSELBEN Mandanten. | | apps/api/src/calendar/calendar.service.ts | calendarSource | muss-mandantengebunden | gebunden | Kalenderquellen eines Nutzers je Mandant gebunden (encryptedPassword traegt Zugangsdaten zu externen Exchange-/CalDAV-Servern), `tenantId`-Spalte vorhanden. Seit 260911-cwh (Aufgabe 2) laufen alle zwoelf Zugriffe (`getSources`, `addSource`, beide Abfragen von `updateSource`/`deleteSource`, alle drei Abfragen von `testConnection`, Laden plus beide Synchronstatus-Rueckschreibungen von `fetchAndCacheEvents`) ueber `forTenant()`, ein Klient je Methode; `fetchAndCacheEvents`/`refreshCacheInBackground` nehmen die Mandantenkennung als Parameter, Letztere traegt die Kennung der urspruenglichen Anfrage. Die drei Besitzpruefungen (`updateSource`/`deleteSource`/`testConnection`, Vergleich gegen `userId` aus dem Sitzungsnachweis) bleiben zusaetzlich bestehen — die Regel auf `CalendarSource` kennt keine Benutzerdimension (260911-cwh, Aufgabe 1, gemessen), sie sind bis zur Etappe-3-Entscheidung (2) der einzige Schutz zwischen Kollegen DESSELBEN Mandanten. Benutzerdimension seit 20260911120000 (260911-nke). |
| apps/api/src/dashboard/dashboard.service.ts | dashboardLayout | muss-mandantengebunden | gebunden | Widget-Anordnung eines Nutzers, `tenantId`-Spalte vorhanden. Seit 260910-krx (Aufgabe 2) laufen `getLayout`/`saveLayout` GEMEINSAM ueber `forTenant()`, ein Klient je Methode; `saveLayout` uebersetzt eine `PrismaClientUnknownRequestError` (RLS-Konflikt auf der plattformweit eindeutigen `userId`, gemessen in Aufgabe 1 — NICHT die `P2002`-Form, die der Bereich `tenders` abfaengt) in eine deutsche Konfliktmeldung. | | apps/api/src/dashboard/dashboard.service.ts | dashboardLayout | muss-mandantengebunden | gebunden | Widget-Anordnung eines Nutzers, `tenantId`-Spalte vorhanden. Seit 260910-krx (Aufgabe 2) laufen `getLayout`/`saveLayout` GEMEINSAM ueber `forTenant()`, ein Klient je Methode; `saveLayout` uebersetzt eine `PrismaClientUnknownRequestError` (RLS-Konflikt auf der plattformweit eindeutigen `userId`, gemessen in Aufgabe 1 — NICHT die `P2002`-Form, die der Bereich `tenders` abfaengt) in eine deutsche Konfliktmeldung. |
| apps/api/src/dashboard/dashboard.service.ts | module | keine-mandantengebundene-tabelle | ungebunden | Modulkatalog ist plattformweit, kein `tenantId` (Migration 20260909140000, Gruppe b). MESSUNG (260910-krx, Aufgabe 1, uebernommen aus `module-registry`-Pruefung `module-tabelle-traegt-keinen-zeilenschutz`): die Tabelle traegt heute keinen Zeilenschutz, eine Bindung waere heute wirkungslos, nicht katastrophal. BEDINGUNG: katastrophal wuerde sie erst, WENN Etappe 3 dieser Tabelle eine Regel gibt. Die Katalogaufloesung fuer den Widget-Modulfilter (`ModuleAccessService.getAccessibleModuleIds`) bindet bereits seit 260910-exd in ihrem eigenen Dienst — hier NICHT ein zweites Mal gebunden. | | apps/api/src/dashboard/dashboard.service.ts | module | keine-mandantengebundene-tabelle | ungebunden | Modulkatalog ist plattformweit, kein `tenantId` (Migration 20260909140000, Gruppe b). MESSUNG (260910-krx, Aufgabe 1, uebernommen aus `module-registry`-Pruefung `module-tabelle-traegt-keinen-zeilenschutz`): die Tabelle traegt heute keinen Zeilenschutz, eine Bindung waere heute wirkungslos, nicht katastrophal. BEDINGUNG: katastrophal wuerde sie erst, WENN Etappe 3 dieser Tabelle eine Regel gibt. Die Katalogaufloesung fuer den Widget-Modulfilter (`ModuleAccessService.getAccessibleModuleIds`) bindet bereits seit 260910-exd in ihrem eigenen Dienst — hier NICHT ein zweites Mal gebunden. |
| apps/api/src/dashboard/dashboard.service.ts | searchProvider | muss-mandantengebunden | gebunden | `tenantId` nullbar. WINDOWS #19 geschlossen (260910-jab) als **widerlegte Prämisse** für dieses Modell. In diesem Durchlauf (260910-krx, Aufgabe 1) EIGENSTAENDIG nachgeprueft, nicht aus 260910-jab abgeschrieben: `grep -rn "searchProvider\|SearchProvider" apps packages prisma --include=*.ts --include=*.mjs --include=*.js --include=*.sql --include=*.json` (ohne `node_modules`, `dist/`, `.next/`) findet weiterhin genau einen Schreibweg, `dashboard.service.ts:addSearchProvider` (`create`), mit `tenantId: string` als Pflichtparameter — keine Seed-Datei, kein Skript. Seit Aufgabe 2/3 laufen `getSearchProviders`/`addSearchProvider`/`removeSearchProvider` ueber `forTenant()`; die Regel auf `SearchProvider` bleibt UNVERAENDERT streng, zusaetzlich datenbankseitig verteidigt durch `searchprovider-gebundenes-einfuegen-ohne-mandant-abgelehnt` (Aufgabe 1). | | apps/api/src/dashboard/dashboard.service.ts | searchProvider | muss-mandantengebunden | gebunden | `tenantId` nullbar. WINDOWS #19 geschlossen (260910-jab) als **widerlegte Prämisse** für dieses Modell. In diesem Durchlauf (260910-krx, Aufgabe 1) EIGENSTAENDIG nachgeprueft, nicht aus 260910-jab abgeschrieben: `grep -rn "searchProvider\|SearchProvider" apps packages prisma --include=*.ts --include=*.mjs --include=*.js --include=*.sql --include=*.json` (ohne `node_modules`, `dist/`, `.next/`) findet weiterhin genau einen Schreibweg, `dashboard.service.ts:addSearchProvider` (`create`), mit `tenantId: string` als Pflichtparameter — keine Seed-Datei, kein Skript. Seit Aufgabe 2/3 laufen `getSearchProviders`/`addSearchProvider`/`removeSearchProvider` ueber `forTenant()`; die Regel auf `SearchProvider` bleibt UNVERAENDERT streng, zusaetzlich datenbankseitig verteidigt durch `searchprovider-gebundenes-einfuegen-ohne-mandant-abgelehnt` (Aufgabe 1). |
| apps/api/src/dashboard/dashboard.service.ts | widgetInstance | muss-mandantengebunden | gebunden | Platzierte Dashboard-Widgets eines Nutzers, `tenantId`-Spalte vorhanden. Seit 260910-krx (Aufgabe 2) laufen `getWidgets`, `addWidget` sowie beide Paare aus Besitzpruefung und Schreibzugriff (`updateWidgetConfig`/`removeWidget`) ueber `forTenant()`; die vorgeschalteten Besitzpruefungen ueber die Benutzerkennung bleiben zusaetzlich bestehen (die Regeln dieses Bereichs kennen keine Benutzerdimension). | | apps/api/src/dashboard/dashboard.service.ts | widgetInstance | muss-mandantengebunden | gebunden | Platzierte Dashboard-Widgets eines Nutzers, `tenantId`-Spalte vorhanden. Seit 260910-krx (Aufgabe 2) laufen `getWidgets`, `addWidget` sowie beide Paare aus Besitzpruefung und Schreibzugriff (`updateWidgetConfig`/`removeWidget`) ueber `forTenant()`; die vorgeschalteten Besitzpruefungen ueber die Benutzerkennung bleiben zusaetzlich bestehen (die Regeln dieses Bereichs kennen keine Benutzerdimension). Benutzerdimension seit 20260911120000 (260911-nke). |
| apps/api/src/dkv/dkv.service.ts | dkvInvoiceHistory | muss-mandantengebunden | gebunden | DKV-Rechnungshistorie je Mandant, `tenantId`-Spalte vorhanden. Seit 260909-mir (Aufgabe 3) laufen beide Historien-Schreibzugriffe der Verarbeitungsstrecke, beide parallelen Lesezugriffe von `getHistory` und der neue Riegel vor dem Ausfuhrdatei-Download vollstaendig ueber `forTenant()`. | | apps/api/src/dkv/dkv.service.ts | dkvInvoiceHistory | muss-mandantengebunden | gebunden | DKV-Rechnungshistorie je Mandant, `tenantId`-Spalte vorhanden. Seit 260909-mir (Aufgabe 3) laufen beide Historien-Schreibzugriffe der Verarbeitungsstrecke, beide parallelen Lesezugriffe von `getHistory` und der neue Riegel vor dem Ausfuhrdatei-Download vollstaendig ueber `forTenant()`. |
| apps/api/src/dkv/dkv.service.ts | dkvModuleConfig | muss-mandantengebunden | gemischt | Postfach-/Zugangsdaten des DKV-Moduls je Mandant. Seit 260909-mir (Aufgabe 2) laufen `loadConfig`, `getConfigForApi`, `saveConfig`, `testConnection` und der Konfigurations-Lesezugriff der Verarbeitungsstrecke ueber `forTenant()`. Die Mischung stammt ausschliesslich vom einen benannten, bewusst ungebundenen Planer-Startpfad `loadAnyActiveConfigForScheduler()` (WINDOWS #21) — keine uebersehene Fundstelle. | | apps/api/src/dkv/dkv.service.ts | dkvModuleConfig | muss-mandantengebunden | gemischt | Postfach-/Zugangsdaten des DKV-Moduls je Mandant. Seit 260909-mir (Aufgabe 2) laufen `loadConfig`, `getConfigForApi`, `saveConfig`, `testConnection` und der Konfigurations-Lesezugriff der Verarbeitungsstrecke ueber `forTenant()`. Die Mischung stammt ausschliesslich vom einen benannten, bewusst ungebundenen Planer-Startpfad `loadAnyActiveConfigForScheduler()` (WINDOWS #21) — keine uebersehene Fundstelle. |
| apps/api/src/dkv/dkv.service.ts | dkvVehicleMaster | muss-mandantengebunden | gebunden | Fahrzeugstammdaten des DKV-Moduls je Mandant. Seit 260909-mir (Aufgabe 3) laufen Fahrzeugliste, Anlegen, beide Paare aus Besitzpruefung und Schreibzugriff (Aendern/Loeschen), beide Zweige des CSV-Imports und der gebuendelte Lesezugriff beim Aufbau der Ausfuhrzeilen vollstaendig ueber `forTenant()`; die vorgeschalteten Besitzpruefungen bei Aendern/Loeschen bleiben zusaetzlich bestehen (Befund G — ein gebundenes UPDATE ueber die Kennung allein trifft eine fremde Zeile still, nicht laut). | | apps/api/src/dkv/dkv.service.ts | dkvVehicleMaster | muss-mandantengebunden | gebunden | Fahrzeugstammdaten des DKV-Moduls je Mandant. Seit 260909-mir (Aufgabe 3) laufen Fahrzeugliste, Anlegen, beide Paare aus Besitzpruefung und Schreibzugriff (Aendern/Loeschen), beide Zweige des CSV-Imports und der gebuendelte Lesezugriff beim Aufbau der Ausfuhrzeilen vollstaendig ueber `forTenant()`; die vorgeschalteten Besitzpruefungen bei Aendern/Loeschen bleiben zusaetzlich bestehen (Befund G — ein gebundenes UPDATE ueber die Kennung allein trifft eine fremde Zeile still, nicht laut). |
| apps/api/src/favorites/favorites.service.ts | favoriteLink | muss-mandantengebunden | gebunden | Favoriten-Links eines Nutzers, `tenantId`-Spalte vorhanden. Seit 260911-gwh (Aufgabe 2) laufen `list`, `create`, `update`, `remove`, `getIconBytes` vollstaendig ueber `forTenant()`, je Methode EIN Klient `tenantPrisma`; die Besitzpruefungen (`findUnique`, Vergleich `link.userId !== userId`, dann Schreibzugriff auf DEMSELBEN Klienten) bleiben zusaetzlich bestehen — die Regel auf `FavoriteLink` kennt keine Benutzerdimension (Aufgabe 1, Pruefung 4), die `userId`-Filter sind bis zur Etappe-3-Entscheidung (2) der einzige Schutz gegen Quer-Lesen zwischen Nutzern DESSELBEN Mandanten. Die Mandantenquelle ist dieselbe wie bei `dashboard` (`extractContext` im Controller), nicht das Claim wie bei `auth`. | | apps/api/src/favorites/favorites.service.ts | favoriteLink | muss-mandantengebunden | gebunden | Favoriten-Links eines Nutzers, `tenantId`-Spalte vorhanden. Seit 260911-gwh (Aufgabe 2) laufen `list`, `create`, `update`, `remove`, `getIconBytes` vollstaendig ueber `forTenant()`, je Methode EIN Klient `tenantPrisma`; die Besitzpruefungen (`findUnique`, Vergleich `link.userId !== userId`, dann Schreibzugriff auf DEMSELBEN Klienten) bleiben zusaetzlich bestehen — die Regel auf `FavoriteLink` kennt keine Benutzerdimension (Aufgabe 1, Pruefung 4), die `userId`-Filter sind bis zur Etappe-3-Entscheidung (2) der einzige Schutz gegen Quer-Lesen zwischen Nutzern DESSELBEN Mandanten. Die Mandantenquelle ist dieselbe wie bei `dashboard` (`extractContext` im Controller), nicht das Claim wie bei `auth`. Benutzerdimension seit 20260911120000 (260911-nke). |
| apps/api/src/favorites/favorites.service.ts | widgetInstance | muss-mandantengebunden | gebunden | NEUE Fundstelle (260911-gwh, Aufgabe 2): `create()` prueft ueber einen gebundenen `widgetInstance.findUnique` (`select: { userId: true }`), dass das Ziel-Widget (`dto.widgetId`) dem Aufrufer gehoert, BEVOR die Zeile angelegt wird — der Fremdschluessel `FavoriteLink.widgetId` prueft an der Zeilenschutz-Regel von `WidgetInstance` VORBEI (dokumentiertes PostgreSQL-Verhalten, Aufgabe 1 Pruefung 7 hat das GELINGEN eines gebundenen `create` mit einer fremdmandantigen `widgetId` bestaetigt); ohne den Riegel waere der Unterschied zwischen "Widget existiert nicht" (FK-Verletzung) und "gehoert einem fremden Mandanten" (gelingt) ein Existenzorakel ueber Mandantengrenzen (T-GWH-05). | | apps/api/src/favorites/favorites.service.ts | widgetInstance | muss-mandantengebunden | gebunden | NEUE Fundstelle (260911-gwh, Aufgabe 2): `create()` prueft ueber einen gebundenen `widgetInstance.findUnique` (`select: { userId: true }`), dass das Ziel-Widget (`dto.widgetId`) dem Aufrufer gehoert, BEVOR die Zeile angelegt wird — der Fremdschluessel `FavoriteLink.widgetId` prueft an der Zeilenschutz-Regel von `WidgetInstance` VORBEI (dokumentiertes PostgreSQL-Verhalten, Aufgabe 1 Pruefung 7 hat das GELINGEN eines gebundenen `create` mit einer fremdmandantigen `widgetId` bestaetigt); ohne den Riegel waere der Unterschied zwischen "Widget existiert nicht" (FK-Verletzung) und "gehoert einem fremden Mandanten" (gelingt) ein Existenzorakel ueber Mandantengrenzen (T-GWH-05). |
| apps/api/src/groups/groups.service.ts | group | muss-mandantengebunden | gebunden | Gruppen sind je Mandant, `tenantId`-Spalte vorhanden. Alle 12 Methoden laufen seit 260909-jts (Aufgabe 2) ueber `forTenant()` bzw. `withTenantTransaction()`. | | apps/api/src/groups/groups.service.ts | group | muss-mandantengebunden | gebunden | Gruppen sind je Mandant, `tenantId`-Spalte vorhanden. Alle 12 Methoden laufen seit 260909-jts (Aufgabe 2) ueber `forTenant()` bzw. `withTenantTransaction()`. |
| apps/api/src/groups/groups.service.ts | groupMembership | muss-mandantengebunden | gebunden | Kein eigenes `tenantId`, RLS über Join auf `Group` (Migration 20260618112133-Nachfolger) — braucht trotzdem `forTenant()`, damit der Join-Kontext gesetzt ist. Seit 260909-jts gebunden, einschliesslich der drei Zugriffe innerhalb des Standardgruppen-Aufbaus (`ensureDefaultGroup`), die zuvor ueber den Transaktionsparameter liefen und fuer keine Pruefung dieses Projekts sichtbar waren (Befund B). | | apps/api/src/groups/groups.service.ts | groupMembership | muss-mandantengebunden | gebunden | Kein eigenes `tenantId`, RLS über Join auf `Group` (Migration 20260618112133-Nachfolger) — braucht trotzdem `forTenant()`, damit der Join-Kontext gesetzt ist. Seit 260909-jts gebunden, einschliesslich der drei Zugriffe innerhalb des Standardgruppen-Aufbaus (`ensureDefaultGroup`), die zuvor ueber den Transaktionsparameter liefen und fuer keine Pruefung dieses Projekts sichtbar waren (Befund B). |
@@ -693,6 +702,19 @@ werden.
an `username`/`email`. Siehe an `username`/`email`. Siehe
`docs/mandantentrennung-etappe2-fehlerrichtung.md`, "## Bereich auth", `docs/mandantentrennung-etappe2-fehlerrichtung.md`, "## Bereich auth",
(h4)(a). (h4)(a).
- **Wie die Benutzerdimension in die Regeln der zehn persönlichen Tabellen
kommt (Etappe-3-Entscheidung (2)).** **Aufgelöst (260911-nke):** Migration
`20260911120000_rls_user_dimension_personal_tables` (Etappe 3b) bringt
`app.current_user`/`current_user_id()` und die `IS NULL OR`-Form in die
Regeln aller zehn persönlichen Tabellen; `forTenant(prisma, tenantId,
userId?)` bekommt den optionalen dritten Parameter, 34 Nutzer-CRUD-
Aufrufstellen reichen ihn durch. Siehe
`docs/mandantentrennung-etappe2-fehlerrichtung.md`, Abschnitt
"Regelschluss Benutzerdimension (Etappe 3b, 260911-nke)". Was weiterhin
offen ist: ein Aufrufer, der `userId` vergisst, sieht den ganzen
Mandanten (kein Wächter gebaut, siehe `.planning/WINDOWS.md`);
Systemkontext (Etappe 3c) und Anmeldenamen pro Mandant (Etappe 3a) bleiben
offen.
- **Wie das Mailmodul künftig je Mandant versendet (260911-gwh).** Der - **Wie das Mailmodul künftig je Mandant versendet (260911-gwh).** Der
Startpfad `loadAnySmtpConfigForStartupTransport()` bleibt bewusst Startpfad `loadAnySmtpConfigForStartupTransport()` bleibt bewusst
ungebunden (sechster Fall der Hintergrunddienst-Falle, WINDOWS #30, siehe ungebunden (sechster Fall der Hintergrunddienst-Falle, WINDOWS #30, siehe