8cb2d43f88
WINDOWS #18: RLS ist heute wirkungslos, weil die Anwendungsrolle Superuser ist und BYPASSRLS traegt. Der Plan folgt der vorgegebenen Reihenfolge — erst die Rolle ohne Umgehungsrecht, dann der Nachweis, dann die Ausweitung auf die 16 fehlenden Tabellen. Gemessen und im Plan festgehalten: 182 Zugriffe im API-Quelltext laufen ueber den unskalierten Prisma-Client (nur 19 ueber tenantPrisma), darunter der Anmeldeweg selbst. Ein Umlegen des Schalters wuerde die Anwendung aussperren; der Plan bereitet die Umstellung deshalb vor und misst sie, vollzieht sie aber nicht. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01FYZcd3SSmo14QTqWx2KKzU
639 lines
46 KiB
Markdown
639 lines
46 KiB
Markdown
---
|
|
quick_id: 260909-dgj
|
|
slug: mandantentrennung-auf-alle-tabellen-mit-
|
|
date: 2026-09-09
|
|
status: planned
|
|
relates_to: 02-authentication-multi-tenancy, 15-modul-berechtigungen-gruppen-user-grants
|
|
windows_ref: 18
|
|
severity: high
|
|
|
|
phase: quick-260909-dgj
|
|
plan: 01
|
|
type: execute
|
|
wave: 1
|
|
depends_on: []
|
|
files_modified:
|
|
- apps/api/prisma/migrations/20260909130000_rls_app_role/migration.sql
|
|
- apps/api/prisma/migrations/20260909140000_rls_remaining_tenant_tables/migration.sql
|
|
- apps/api/src/prisma/rls-app-role.spec.ts
|
|
- apps/api/src/prisma/rls-coverage.spec.ts
|
|
- apps/api/src/prisma/start-script.spec.ts
|
|
- apps/api/src/prisma/rls-preflight.spec.ts
|
|
- apps/api/scripts/migrate-and-start.sh
|
|
- apps/api/scripts/rls-preflight.mjs
|
|
- apps/api/Dockerfile
|
|
- docker-compose.yml
|
|
- docker-compose.prod.yml
|
|
- .env.example
|
|
- docs/mandantentrennung-datenbankrolle.md
|
|
- docs/README.md
|
|
autonomous: true
|
|
requirements: [WINDOWS-18]
|
|
|
|
estimate:
|
|
tokens: 85000
|
|
raw_tokens: 85000
|
|
tasks: 4
|
|
confidence: low
|
|
|
|
must_haves:
|
|
truths:
|
|
- "Eine Datenbankrolle `tessera_app` wird durch eine Migration angelegt und traegt nachweislich weder das Superuser- noch das RLS-Umgehungsrecht; die Migration konvergiert eine bereits vorhandene Rolle auf genau diese Eigenschaften, statt zu scheitern."
|
|
- "Dieselbe Migration laesst sich beliebig oft anwenden, ohne zu scheitern — sie laeuft bei jedem Containerstart erneut ueber `prisma migrate deploy` nur einmal, muss aber gegen eine Datenbank, in der Rolle und Rechte schon existieren, fehlerfrei durchlaufen."
|
|
- "Im SQL der Migration steht kein Kennwort. Das Setzen des Kennworts ist als ausdruecklicher Handgriff des Betreibers dokumentiert, samt der auszufuehrenden Anweisung."
|
|
- "Die Anwendung kann als eine andere Rolle laufen als die, mit der Migrationen angewendet werden: `prisma migrate deploy` nutzt `TESSERA_MIGRATE_DATABASE_URL`, wenn gesetzt, sonst `DATABASE_URL`; der Node-Prozess nutzt in beiden Faellen unveraendert `DATABASE_URL`."
|
|
- "Ohne gesetztes `TESSERA_MIGRATE_DATABASE_URL` verhaelt sich der Containerstart exakt wie vorher — lokal, in der CI und auf dem Server bleibt der bisherige Ablauf gueltig, ohne dass jemand etwas anpassen muss."
|
|
- "Ein Pruefwerkzeug misst gegen eine beliebige Datenbank, ob die Trennung unter einer angegebenen Rolle tatsaechlich greift: Rollenrechte, Setzbarkeit von `app.current_tenant`, null sichtbare Zeilen ohne Kontext, sichtbare Zeilen mit Kontext, vorhandene Schreib-/Leserechte. Es veraendert dabei nichts."
|
|
- "Das Pruefwerkzeug laesst sich ohne Datenbank aufrufen und gibt dann seinen Pruefplan aus — dadurch ist es in der CI testbar, die keine Datenbank hat."
|
|
- "Alle 20 Modelle mit `tenantId`-Spalte tragen nach dieser Aenderung eine Policy; die 16 bisher fehlenden sind in einer zweiten Migration ergaenzt."
|
|
- "Jede Tabelle ohne Policy ist namentlich mit Begruendung aufgefuehrt — im Kopf der neuen Migration und als Ausnahmeliste in einem Test, der bei jedem neuen Modell eine bewusste Entscheidung erzwingt."
|
|
- "Keine bereits angewendete Migrationsdatei wurde veraendert; Korrekturen an frueheren Aussagen stehen ausschliesslich im Kopf der neuen Migration."
|
|
- "Die Betriebsanleitung nennt die Umstellung als noch NICHT vollzogen, nennt die gemessene Zahl der unskalierten Zugriffe als Sperrgrund, beschreibt den Weg zurueck und sagt, was ein Betreiber tut, wenn die API nach einer Umstellung nicht mehr verbindet."
|
|
artifacts:
|
|
- apps/api/prisma/migrations/20260909130000_rls_app_role/migration.sql
|
|
- apps/api/prisma/migrations/20260909140000_rls_remaining_tenant_tables/migration.sql
|
|
- apps/api/scripts/migrate-and-start.sh
|
|
- apps/api/scripts/rls-preflight.mjs
|
|
- apps/api/src/prisma/rls-app-role.spec.ts
|
|
- apps/api/src/prisma/rls-coverage.spec.ts
|
|
- apps/api/src/prisma/start-script.spec.ts
|
|
- apps/api/src/prisma/rls-preflight.spec.ts
|
|
- docs/mandantentrennung-datenbankrolle.md
|
|
key_links:
|
|
- "`docker-compose.yml:33` (`DATABASE_URL` der API zeigt auf Rolle `tessera`) <-> `docker-compose.yml:76` (`POSTGRES_USER: tessera`). Die zweite Zeile ist die Ursache: die von `POSTGRES_USER` angelegte Rolle ist Superuser des Clusters. Deshalb ist RLS heute wirkungslos, und deshalb reicht es nicht, Policies zu ergaenzen."
|
|
- "`apps/api/Dockerfile:38` (CMD fuehrt `prisma migrate deploy` und `node main.js` mit derselben `DATABASE_URL` aus) <-> jede Rollentrennung. Solange beide Schritte dieselbe Verbindung nutzen, muesste die Anwendungsrolle DDL-Rechte und Tabelleneigentum haben — womit die Trennung wieder verloren waere. Die Trennung der beiden URLs ist die Voraussetzung fuer alles Weitere."
|
|
- "`apps/api/src/auth/auth.service.ts:37-41` (`validateUser` liest `User` bewusst ohne Mandantenkontext — der Kommentar sagt es woertlich) <-> jede wirksame Policy auf `User`. Unter der neuen Rolle liefert genau diese Abfrage null Zeilen, und niemand kann sich mehr anmelden. Das ist der Grund, warum dieser Plan die Umstellung vorbereitet und misst, aber nicht vollzieht."
|
|
- "`apps/api/src/prisma/prisma-tenant.extension.ts:16` (`set_config('app.current_tenant', $1, true)` innerhalb einer Transaktion) <-> Rechte der neuen Rolle. Transaktionslokales Setzen einer benutzerdefinierten Einstellung braucht kein besonderes Recht; das Pruefwerkzeug misst es trotzdem, statt es anzunehmen."
|
|
- "`apps/api/prisma/migrations/20260804130918_groups_rls_policies/migration.sql` (Kopf: RLS als 'zweites Sicherheitsnetz', Tender-Tabellen 'bewusst ohne RLS, D-03') <-> gemessener Bestand. Die Pauschalaussage trifft nur auf die drei Tender-Tabellen ohne `tenantId` zu. Die Korrektur gehoert in den Kopf der NEUEN Migration — die alte Datei darf nicht angefasst werden, weil Prisma ihre Pruefsumme fuehrt."
|
|
- "`apps/api/vitest.config.ts:8` (`passWithNoTests: true`) <-> jede Abnahmepruefung dieses Plans. Ohne `--passWithNoTests=false` liefert ein Aufruf auf eine noch nicht existierende Testdatei den Erfolgscode 0 — die Pruefung waere von vornherein gruen und damit wertlos. Gemessen am 2026-09-09."
|
|
---
|
|
|
|
<objective>
|
|
Die Mandantentrennung auf Datenbankebene wirksam machen — in der Reihenfolge, die WINDOWS #18
|
|
vorgibt: erst die Rolle, dann der Nachweis, dann die Ausweitung.
|
|
|
|
**Der gemessene Ausgangsstand (am 2026-09-09 im Arbeitsbaum nachgeprueft, nicht uebernommen):**
|
|
|
|
Die API verbindet laut `docker-compose.yml:33` als Rolle `tessera`. Diese Rolle entsteht aus
|
|
`POSTGRES_USER: tessera` (`docker-compose.yml:76`) und ist damit Superuser des Clusters; auf dem
|
|
Testserver wurde `rolsuper = t` und `rolbypassrls = t` gemessen. PostgreSQL wendet Row-Level
|
|
Security auf solche Rollen grundsaetzlich nicht an. `FORCE ROW LEVEL SECURITY` aendert daran
|
|
nichts — es erzwingt Policies nur auf den Tabelleneigentuemer, nicht auf Rollen mit
|
|
Umgehungsrecht. Die sieben vorhandenen Policies (User, Group, GroupMembership, LdapConfig,
|
|
LdapFieldMapping, ModuleGrant, PasswordResetToken) sind daher heute wirkungslos.
|
|
|
|
Nachgezaehlt im Schema: 28 Modelle, davon 20 mit `tenantId`-Spalte, davon 7 mit Policy — also 16
|
|
Tabellen ohne. Alle 16 haben eine direkte `tenantId`-Spalte; keine braucht ein Unterabfrage-Muster.
|
|
|
|
**Der zweite, ebenso wichtige Befund — er bestimmt, was dieser Plan NICHT tut:**
|
|
|
|
Die Anwendung ist ueberwiegend gegen den unskalierten Prisma-Client geschrieben. Gezaehlt am
|
|
2026-09-09 in `apps/api/src`, ohne Testdateien: 84 Zugriffe auf die sieben bereits mit Policies
|
|
versehenen Tabellen und 98 Zugriffe auf die 16 noch offenen — zusammen 182 — gegenueber lediglich
|
|
19 Verwendungen von `tenantPrisma` im gesamten API-Quelltext. Darunter ist der Anmeldeweg selbst:
|
|
`apps/api/src/auth/auth.service.ts:37-41` liest `User` ohne Mandantenkontext, und der Kommentar
|
|
darueber sagt ausdruecklich, dass das so sein muss, weil die Anmeldung mandantenuebergreifend
|
|
funktionieren muss — der Mandant wird ja erst aus dem gefundenen Benutzer bestimmt. Dazu kommen
|
|
Hintergrunddienste, die von Natur aus ohne Kontext laufen: `ldap-sync.scheduler.ts`,
|
|
`tender-digest.scheduler.ts`, `admin-seed.service.ts`, `module-access.service.ts` und
|
|
`tender-matching.service.ts`.
|
|
|
|
Wuerde man heute nur die Verbindung auf eine Rolle ohne Umgehungsrecht umstellen, lieferten all
|
|
diese Abfragen null Zeilen. Niemand koennte sich mehr anmelden, und die Hintergrunddienste
|
|
liefen leer. Das ist kein Restrisiko, das ist die sichere Folge.
|
|
|
|
**Was dieser Plan deshalb liefert:** die Rolle, den sauberen Weg, die Anwendung ueberhaupt unter
|
|
einer anderen Rolle laufen zu lassen als der, die migriert, ein Messwerkzeug, das den Nachweis
|
|
fuehrt statt ihn zu behaupten, und die vollstaendige Policy-Abdeckung samt benannter Ausnahmen.
|
|
|
|
**Was dieser Plan ausdruecklich NICHT liefert:** das Umlegen des Schalters. Die Umstellung bleibt
|
|
standardmaessig aus. Sie wird erst moeglich, wenn die 182 unskalierten Zugriffe behandelt sind —
|
|
das ist eigene Arbeit fuer einen eigenen Vorgang, und die Betriebsanleitung sagt das offen.
|
|
WINDOWS #18 bleibt danach offen.
|
|
|
|
Output: zwei Migrationen, ein Startskript, ein Pruefwerkzeug, vier Testdateien, eine
|
|
Betriebsanleitung.
|
|
</objective>
|
|
|
|
<execution_context>
|
|
@~/.claude/gsd-core/workflows/execute-plan.md
|
|
@~/.claude/gsd-core/templates/summary.md
|
|
</execution_context>
|
|
|
|
<context>
|
|
@.planning/STATE.md
|
|
@CLAUDE.md
|
|
@apps/api/prisma/migrations/20260618112133_rls_policies/migration.sql
|
|
@apps/api/prisma/migrations/20260804130918_groups_rls_policies/migration.sql
|
|
@apps/api/src/groups/migration-sql.spec.ts
|
|
@apps/api/src/prisma/prisma-tenant.extension.ts
|
|
@apps/api/Dockerfile
|
|
@docker-compose.yml
|
|
</context>
|
|
|
|
<constraints_global>
|
|
Fuer alle vier Aufgaben gilt:
|
|
|
|
1. **Keine bereits angewendete Migrationsdatei veraendern.** Prisma fuehrt zu jeder Migration eine
|
|
Pruefsumme; eine nachtraegliche Aenderung laesst `prisma migrate deploy` mit einem
|
|
Aenderungsfehler abbrechen — und damit startet der API-Container nicht mehr. Korrekturen an
|
|
frueheren Aussagen stehen im Kopf der neuen Migration.
|
|
2. **Nichts anwenden, nichts starten.** Keine Migration ausfuehren, kein `docker compose up`,
|
|
`pull`, `restart` oder `build`, kein Zugriff auf 192.168.13.12. Es werden ausschliesslich
|
|
Dateien geschrieben.
|
|
3. **Kein Kennwort in eine Datei schreiben**, die im Repository landet — weder im SQL noch in
|
|
`.env.example` noch in der Anleitung. Dort stehen Platzhalter.
|
|
4. **Jede Abnahmepruefung braucht `--passWithNoTests=false`.** `apps/api/vitest.config.ts:8` setzt
|
|
`passWithNoTests: true`; ein Aufruf auf eine fehlende Testdatei liefert sonst den Erfolgscode 0.
|
|
Am 2026-09-09 gemessen: ohne die Option Ende-Code 0, mit der Option Ende-Code 1.
|
|
</constraints_global>
|
|
|
|
<!-- planner-discipline-allow: PASSWORD -->
|
|
|
|
<tasks>
|
|
|
|
<task type="tracer" tdd="true">
|
|
<name>Task 1: Anwendungsrolle ohne RLS-Umgehungsrecht anlegen (Migration)</name>
|
|
<files>apps/api/prisma/migrations/20260909130000_rls_app_role/migration.sql, apps/api/src/prisma/rls-app-role.spec.ts</files>
|
|
|
|
<precondition>Der Arbeitsbaum enthaelt `apps/api/prisma/migrations/` mit `20260909120000_user_email_optional` als juengstem Eintrag; der neue Ordner muss zeitlich danach sortieren. Es wird keine Datenbank benoetigt und keine Migration angewendet.</precondition>
|
|
|
|
<reversibility rating="reversible">Die Rolle wird angelegt, aber von niemandem benutzt. Ein Rueckbau ist ein `DROP ROLE` von Hand; solange die Rolle keine Verbindung aufbaut, hat ihre Existenz keine Wirkung auf den Betrieb.</reversibility>
|
|
|
|
<behavior>
|
|
Die Testdatei entsteht zuerst und ist rot, bevor die Migration geschrieben wird. Sie liest die
|
|
neue `migration.sql` als Text — genau wie `apps/api/src/groups/migration-sql.spec.ts` es fuer
|
|
die Phase-15-Migrationen tut — und braucht dafuer keine Datenbank.
|
|
|
|
- Test 1: Die Migration nennt die Rolle `tessera_app`.
|
|
- Test 2: Sie entzieht ausdruecklich beide Umgehungswege — `NOSUPERUSER` und `NOBYPASSRLS`
|
|
kommen beide vor, und `BYPASSRLS` kommt nirgends ohne vorangestelltes `NO` vor.
|
|
- Test 3: Sie ist wiederholbar. Vor dem Anlegen steht eine Existenzpruefung ueber `pg_roles`;
|
|
der Text enthaelt `DO $$` und `IF NOT EXISTS`.
|
|
- Test 4: Der Datenbankname ist nicht fest verdrahtet — der Verbindungsanspruch wird ueber
|
|
`current_database()` erteilt.
|
|
- Test 5: Der Eigentuemer der kuenftigen Tabellen ist nicht fest verdrahtet — die
|
|
Vorgaberechte werden fuer `current_user` gesetzt.
|
|
- Test 6: Es steht kein Kennwort im SQL. Die Datei enthaelt keine Stelle, an der auf das
|
|
Schluesselwort PASSWORD ein Hochkomma folgt.
|
|
- Test 7: Die Migration erteilt alle vier Datenzugriffsarten (SELECT, INSERT, UPDATE, DELETE).
|
|
</behavior>
|
|
|
|
<action>
|
|
Zuerst `apps/api/src/prisma/rls-app-role.spec.ts` schreiben (rot), dann die Migration.
|
|
|
|
Die Testdatei uebernimmt das Muster aus `apps/api/src/groups/migration-sql.spec.ts`: eine
|
|
Hilfsfunktion, die das Verzeichnis `apps/api/prisma/migrations` liest, genau einen Ordner mit
|
|
der Endung `_rls_app_role` erwartet und dessen `migration.sql` als Zeichenkette zurueckgibt.
|
|
Bewusst dieselbe Hilfsfunktion nachbauen statt sie zu importieren — die vorhandene ist in
|
|
ihrer Datei privat, und eine Kopie von zwoelf Zeilen ist billiger als eine neue
|
|
Abhaengigkeit zwischen zwei Testdateien.
|
|
|
|
Dann `apps/api/prisma/migrations/20260909130000_rls_app_role/migration.sql` anlegen. Der
|
|
Ordnername muss exakt so lauten; nur dann sortiert er hinter `20260909120000_user_email_optional`
|
|
und wird von der Testdatei gefunden.
|
|
|
|
Kopfkommentar der Migration, in deutschen Saetzen und ausfuehrlich genug, dass ein spaeterer
|
|
Leser die Entscheidung nachvollziehen kann. Er muss diese Punkte tragen: dass die bisher
|
|
verwendete Rolle `tessera` aus `POSTGRES_USER` entsteht und deshalb Superuser ist; dass
|
|
PostgreSQL RLS auf Superuser- und BYPASSRLS-Rollen nicht anwendet und `FORCE ROW LEVEL
|
|
SECURITY` daran nichts aendert, weil es nur den Tabelleneigentuemer erfasst; dass die sieben
|
|
vorhandenen Policies deshalb heute ohne Wirkung sind (WINDOWS #18, gemessen am 2026-09-09);
|
|
dass diese Migration allein noch nichts umstellt, weil niemand die neue Rolle benutzt; und
|
|
dass das Kennwort bewusst nicht hier gesetzt wird, sondern vom Betreiber von Hand — mit
|
|
Verweis auf `docs/mandantentrennung-datenbankrolle.md`.
|
|
|
|
Der SQL-Koerper besteht aus einem `DO $$`-Block und anschliessenden Rechtevergaben:
|
|
|
|
(a) Existenz: Wenn in `pg_roles` keine Zeile mit `rolname = 'tessera_app'` steht, die Rolle
|
|
anlegen. Vorher pruefen, ob `current_user` das ueberhaupt darf (`rolsuper` oder `rolcreaterole`
|
|
aus `pg_roles`). Darf er es nicht, mit `RAISE EXCEPTION` abbrechen und im Meldungstext die
|
|
genau auszufuehrende Anweisung nennen sowie darauf hinweisen, dass sie einmalig als
|
|
Datenbank-Superuser laufen muss. Lautes Scheitern mit Anleitung ist hier richtig; ein
|
|
stilles Ueberspringen wuerde einen Betreiber im Glauben lassen, die Rolle existiere.
|
|
Die Rolle wird mit `LOGIN NOSUPERUSER NOBYPASSRLS NOCREATEDB NOCREATEROLE` angelegt und ohne
|
|
jede Kennwortangabe.
|
|
|
|
(b) Konvergenz: Wenn die Rolle bereits existiert und `current_user` Superuser ist, ihre
|
|
Eigenschaften unbedingt auf denselben Stand ziehen (`ALTER ROLE tessera_app WITH LOGIN
|
|
NOSUPERUSER NOBYPASSRLS NOCREATEDB NOCREATEROLE`). Nur ein Superuser darf die Merkmale
|
|
SUPERUSER und BYPASSRLS setzen oder entziehen — ist `current_user` keiner, statt des `ALTER`
|
|
pruefen, ob `rolsuper` und `rolbypassrls` bereits beide falsch sind, und andernfalls wieder
|
|
mit `RAISE EXCEPTION` samt auszufuehrender Anweisung abbrechen. Damit ist die Migration auf
|
|
einer Datenbank, in der schon alles stimmt, ein reiner Durchlauf.
|
|
|
|
(c) Rechte, alle idempotent (ein wiederholtes `GRANT` ist in PostgreSQL folgenlos):
|
|
Verbindungsrecht auf die aktuelle Datenbank — den Namen dynamisch ueber `current_database()`
|
|
und `format(..., %I)` einsetzen, damit die Migration auch gegen eine anders benannte Datenbank
|
|
laeuft; `USAGE` auf das Schema `public`; `SELECT, INSERT, UPDATE, DELETE` auf alle Tabellen in
|
|
`public`; `USAGE, SELECT` auf alle Sequenzen in `public` (das Schema nutzt derzeit keine
|
|
Sequenzen — nachgezaehlt, kein einziges `autoincrement` —, die Vergabe kostet nichts und
|
|
verhindert einen spaeteren Stolperstein).
|
|
|
|
(d) Vorgaberechte, damit kuenftige Migrationen nicht jedes Mal nachziehen muessen:
|
|
`ALTER DEFAULT PRIVILEGES FOR ROLE <current_user> IN SCHEMA public GRANT SELECT, INSERT,
|
|
UPDATE, DELETE ON TABLES TO tessera_app` und dasselbe fuer Sequenzen. Die Rolle im
|
|
`FOR ROLE`-Teil dynamisch aus `current_user` bilden, nicht `tessera` hineinschreiben — der
|
|
Eigentuemer ist die Rolle, die migriert, und die kann auf einer anderen Installation anders
|
|
heissen.
|
|
|
|
Keine DDL an Tabellen, keine Policy, kein `ALTER TABLE`. Diese Migration beruehrt keine Daten.
|
|
</action>
|
|
|
|
<verify>
|
|
<automated>pnpm --filter=@tessera/api exec vitest run --passWithNoTests=false src/prisma/rls-app-role.spec.ts</automated>
|
|
</verify>
|
|
|
|
<done>Der Ordner `apps/api/prisma/migrations/20260909130000_rls_app_role/` enthaelt eine `migration.sql`, die die Rolle `tessera_app` wiederholbar anlegt und auf `NOSUPERUSER`/`NOBYPASSRLS` konvergiert, Datenbank- und Eigentuemernamen dynamisch bildet, alle vier Datenzugriffsarten samt Vorgaberechten erteilt und kein Kennwort enthaelt. Die sieben Tests in `src/prisma/rls-app-role.spec.ts` laufen gruen; vor dem Schreiben der Migration waren sie rot.</done>
|
|
</task>
|
|
|
|
<task type="auto" tdd="true">
|
|
<name>Task 2: Migrationsverbindung von der Laufzeitverbindung trennen — abgeschaltet als Vorgabe</name>
|
|
<files>apps/api/scripts/migrate-and-start.sh, apps/api/Dockerfile, docker-compose.yml, docker-compose.prod.yml, .env.example, apps/api/src/prisma/start-script.spec.ts</files>
|
|
|
|
<precondition>`sh` ist aufrufbar (im Alpine-Abbild, in der Gitea-CI und lokal jeweils vorhanden). Der Test startet ausschliesslich das neue Skript in einer Ausgabebetriebsart; er ruft weder Prisma noch Node an und baut keine Verbindung auf.</precondition>
|
|
|
|
<reversibility rating="reversible">Der Zustand ohne gesetztes `TESSERA_MIGRATE_DATABASE_URL` ist Zeile fuer Zeile derselbe Ablauf wie die bisherige CMD-Zeile. Ein Rueckbau ist das Zuruecksetzen von vier Dateien.</reversibility>
|
|
|
|
<behavior>
|
|
`apps/api/src/prisma/start-script.spec.ts` entsteht zuerst und ist rot. Er ruft das Skript mit
|
|
`execFileSync('sh', [pfad, '--print-plan'], { env, encoding: 'utf-8' })` auf und wertet die
|
|
Ausgabe aus. Der Pfad wird ueber `join(__dirname, '../../scripts/migrate-and-start.sh')`
|
|
gebildet, damit der Test unabhaengig vom Arbeitsverzeichnis laeuft.
|
|
|
|
- Test 1 (Rueckwaertsvertraeglichkeit): Mit gesetztem `DATABASE_URL` und ohne
|
|
`TESSERA_MIGRATE_DATABASE_URL` meldet die Ausgabe fuer beide Schritte `DATABASE_URL` als
|
|
Quelle.
|
|
- Test 2 (Trennung): Sind beide gesetzt, meldet der Migrationsschritt
|
|
`TESSERA_MIGRATE_DATABASE_URL` und der Laufzeitschritt weiterhin `DATABASE_URL`.
|
|
- Test 3 (leer zaehlt als nicht gesetzt): Ist `TESSERA_MIGRATE_DATABASE_URL` die leere
|
|
Zeichenkette, verhaelt sich das Skript wie in Test 1 — Compose reicht nicht gesetzte
|
|
Variablen als leere Zeichenketten weiter.
|
|
- Test 4 (kein Geheimnisabfluss): Die Ausgabe enthaelt keinen der beiden uebergebenen
|
|
Verbindungswerte, sondern nur die Namen der Variablen. Als Wert im Test eine erkennbare
|
|
Zeichenkette verwenden und pruefen, dass sie in der Ausgabe nicht vorkommt.
|
|
- Test 5 (fehlende Angabe): Ohne `DATABASE_URL` bricht das Skript mit einem Ende-Code
|
|
ungleich 0 ab und nennt den fehlenden Variablennamen.
|
|
</behavior>
|
|
|
|
<action>
|
|
Zuerst den Test schreiben (rot), dann das Skript, dann die drei Aufrufer.
|
|
|
|
`apps/api/scripts/migrate-and-start.sh` neu anlegen, POSIX-`sh`, mit `set -e`. Kopfkommentar:
|
|
warum es das Skript gibt — `prisma migrate deploy` braucht die Rechte des Tabelleneigentuemers,
|
|
die Anwendung soll sie gerade nicht haben; ohne getrennte Verbindungen ist eine Rollentrennung
|
|
nicht moeglich. Ablauf:
|
|
|
|
Fehlt `DATABASE_URL`, mit einer Meldung auf die Standardfehlerausgabe und Ende-Code 1
|
|
abbrechen. Andernfalls die Migrationsverbindung bestimmen: `TESSERA_MIGRATE_DATABASE_URL`,
|
|
wenn nicht leer, sonst `DATABASE_URL` — in `sh` ist das die Ersetzung mit Doppelpunkt, die
|
|
eine leere Zeichenkette wie eine nicht gesetzte behandelt. Merken, welche der beiden Quellen
|
|
gewaehlt wurde.
|
|
|
|
Ist das erste Argument `--print-plan`, zwei Zeilen ausgeben — die gewaehlte Quelle fuer den
|
|
Migrationsschritt und die Quelle fuer den Laufzeitschritt, jeweils als Name der Variablen,
|
|
niemals als Wert — und mit Ende-Code 0 zurueckkehren, ohne irgendetwas auszufuehren. Diese
|
|
Betriebsart existiert allein, damit die CI das Verhalten pruefen kann, ohne eine Datenbank zu
|
|
haben.
|
|
|
|
Sonst: `prisma migrate deploy --schema apps/api/prisma/schema.prisma` aus
|
|
`apps/api/node_modules/.bin/` ausfuehren, wobei `DATABASE_URL` nur fuer diesen einen Aufruf
|
|
auf die Migrationsverbindung gesetzt wird (vorangestellte Zuweisung, kein `export`), und
|
|
danach mit `exec node apps/api/dist/main.js` in den Anwendungsprozess wechseln, der die
|
|
unveraenderte `DATABASE_URL` aus der Umgebung erbt. Das `exec` ist wichtig, damit Signale den
|
|
Node-Prozess erreichen — die bisherige CMD-Zeile hatte dasselbe Problem und loest es nicht;
|
|
hier wird es nebenbei besser.
|
|
|
|
Anschliessend `apps/api/Dockerfile`: im Runner-Abschnitt eine Kopieranweisung fuer
|
|
`apps/api/scripts` ergaenzen — sinnvolle Stelle ist direkt nach Zeile 34, wo bereits
|
|
`apps/api/prisma` kopiert wird — und die CMD-Zeile 38 durch den Aufruf des Skripts ersetzen.
|
|
Ohne die Kopieranweisung liegt das Skript nicht im Abbild und der Container startet nicht;
|
|
beides gehoert in denselben Commit.
|
|
|
|
Dann `docker-compose.yml`: beim Dienst `api` im Umgebungsblock direkt unter Zeile 33
|
|
(`DATABASE_URL`) einen Eintrag `TESSERA_MIGRATE_DATABASE_URL` ergaenzen, der aus der
|
|
gleichnamigen Variablen mit leerer Vorgabe gefuellt wird. Dasselbe in
|
|
`docker-compose.prod.yml` unter Zeile 34. `docker-compose.dev.yml` und `docker-compose.ci.yml`
|
|
bleiben unberuehrt — nachgesehen: dev setzt kein `DATABASE_URL`, ci hat gar keinen
|
|
API-Dienst.
|
|
|
|
Zuletzt `.env.example`: unter Zeile 2 einen kommentierten Block ergaenzen. Er nennt beide
|
|
Variablen, sagt in deutschen Saetzen, dass `DATABASE_URL` die Verbindung der laufenden
|
|
Anwendung ist und `TESSERA_MIGRATE_DATABASE_URL` die des Migrationsschritts, dass die zweite
|
|
leer bleiben darf und dann alles wie bisher laeuft, und dass die getrennte Belegung erst
|
|
sinnvoll ist, wenn die Anleitung `docs/mandantentrennung-datenbankrolle.md` abgearbeitet
|
|
wurde. Beide Beispielwerte tragen Platzhalter, keine echten Zugangsdaten. Die auskommentierte
|
|
Beispielzeile fuer die getrennte Belegung ausdruecklich auskommentiert lassen — sie darf
|
|
nicht versehentlich in Kraft treten.
|
|
</action>
|
|
|
|
<verify>
|
|
<automated>pnpm --filter=@tessera/api exec vitest run --passWithNoTests=false src/prisma/start-script.spec.ts</automated>
|
|
</verify>
|
|
|
|
<done>`apps/api/scripts/migrate-and-start.sh` existiert, wird vom Dockerfile kopiert und als CMD aufgerufen; beide Compose-Dateien reichen `TESSERA_MIGRATE_DATABASE_URL` durch; `.env.example` erklaert beide Variablen mit Platzhaltern. Die fuenf Tests in `src/prisma/start-script.spec.ts` sind gruen und belegen insbesondere, dass ohne gesetzte Migrationsvariable beide Schritte weiterhin `DATABASE_URL` verwenden.</done>
|
|
</task>
|
|
|
|
<task type="auto" tdd="true">
|
|
<name>Task 3: Pruefwerkzeug fuer den Nachweis plus Betriebsanleitung mit Rueckweg</name>
|
|
<files>apps/api/scripts/rls-preflight.mjs, apps/api/src/prisma/rls-preflight.spec.ts, docs/mandantentrennung-datenbankrolle.md, docs/README.md</files>
|
|
|
|
<precondition>Node ist aufrufbar und `@prisma/client` ist in `apps/api/node_modules` aufgeloest (durch `pnpm install`, bereits vorhanden). Der Test ruft das Werkzeug ausschliesslich in der Pruefplan-Betriebsart auf; es wird keine Datenbankverbindung aufgebaut.</precondition>
|
|
|
|
<reversibility rating="reversible">Ein Werkzeug und ein Dokument, die nichts veraendern. Loeschen genuegt.</reversibility>
|
|
|
|
<behavior>
|
|
`apps/api/src/prisma/rls-preflight.spec.ts` entsteht zuerst und ist rot. Er ruft
|
|
`execFileSync(process.execPath, [pfad, '--print-plan'], ...)` auf, wobei der Pfad ueber
|
|
`join(__dirname, '../../scripts/rls-preflight.mjs')` gebildet wird.
|
|
|
|
- Test 1: Der Aufruf endet mit Ende-Code 0, obwohl keine Verbindungsangabe in der Umgebung
|
|
steht — die Pruefplan-Betriebsart verbindet nicht.
|
|
- Test 2: Die Ausgabe nennt alle fuenf Pruefkennungen: `rollenrechte`, `kontext-setzbar`,
|
|
`ohne-kontext-leer`, `mit-kontext-sichtbar`, `schreibrechte`.
|
|
- Test 3: Die Ausgabe nennt `TESSERA_PREFLIGHT_DATABASE_URL` als die Variable, aus der die zu
|
|
pruefende Verbindung stammt.
|
|
- Test 4: Der Quelltext des Werkzeugs fuehrt jede Pruefung innerhalb einer Transaktion aus —
|
|
die Datei enthaelt `$transaction`. Begruendung im Test als Kommentar: Prisma haelt einen
|
|
Verbindungspool; ein `set_config` ausserhalb einer Transaktion kann auf einer anderen
|
|
Verbindung landen als die darauffolgende Abfrage, und die Messung waere wertlos.
|
|
- Test 5: Das Werkzeug schreibt nicht. Der Quelltext enthaelt keines der Schluesselwoerter
|
|
INSERT, UPDATE, DELETE, DROP oder ALTER in einer SQL-Zeichenkette.
|
|
</behavior>
|
|
|
|
<action>
|
|
Zuerst den Test schreiben (rot), dann das Werkzeug, dann die Anleitung.
|
|
|
|
`apps/api/scripts/rls-preflight.mjs` als ES-Modul anlegen. Es importiert `PrismaClient` aus
|
|
`@prisma/client` und erzeugt ihn mit einer uebergebenen Verbindung
|
|
(`new PrismaClient({ datasourceUrl: url })`), damit es gegen eine andere Rolle messen kann als
|
|
die, mit der die Anwendung laeuft. Kein neues Paket installieren — Prisma ist bereits
|
|
Abhaengigkeit der API.
|
|
|
|
Zwei Betriebsarten. Mit `--print-plan`: die Liste der Pruefungen mit Kennung und
|
|
Kurzbeschreibung ausgeben, den Namen der Umgebungsvariablen nennen und mit Ende-Code 0 enden,
|
|
ohne zu verbinden. Ohne Argument: die Verbindung aus `TESSERA_PREFLIGHT_DATABASE_URL` lesen,
|
|
bei fehlender Angabe mit einer verstaendlichen Meldung und Ende-Code 1 abbrechen, sonst alle
|
|
Pruefungen ausfuehren und einen deutschen Bericht ausgeben — je Zeile Kennung, Ergebnis,
|
|
gemessener Wert. Ende-Code 0 nur, wenn alle Pruefungen bestanden sind, sonst 1.
|
|
|
|
Jede einzelne Pruefung laeuft in einer eigenen interaktiven Transaktion
|
|
(`prisma.$transaction(async (tx) => { ... })`), und jedes Setzen des Mandantenkontexts
|
|
innerhalb dieser Transaktion geschieht transaktionslokal — also mit `true` als drittem
|
|
Argument von `set_config`, genau wie `apps/api/src/prisma/prisma-tenant.extension.ts:16` es
|
|
tut. Das ist keine Stilfrage: ausserhalb einer Transaktion kann der Verbindungspool die
|
|
Folgeabfrage auf eine andere Verbindung legen, auf der die Einstellung nie gesetzt wurde.
|
|
|
|
Die fuenf Pruefungen:
|
|
|
|
`rollenrechte` — aus `pg_roles` fuer `current_user` die Merkmale `rolsuper` und `rolbypassrls`
|
|
lesen. Bestanden, wenn beide falsch sind. Der Bericht nennt zusaetzlich den Rollennamen, damit
|
|
ein Betreiber sofort sieht, ob er versehentlich die alte Verbindung geprueft hat. Diese
|
|
Pruefung ist der eigentliche Kern: sie misst genau die Aussage aus WINDOWS #18.
|
|
|
|
`kontext-setzbar` — `set_config('app.current_tenant', 'probe', true)` ausfuehren und danach
|
|
`current_tenant_id()` lesen. Bestanden, wenn der gelesene Wert `probe` ist. Damit ist belegt,
|
|
dass eine Rolle ohne besondere Rechte den Mandantenkontext ueberhaupt setzen kann — eine
|
|
Annahme, die dieser Plan bewusst nicht ungeprueft laesst.
|
|
|
|
`ohne-kontext-leer` — ohne gesetzten Kontext fuer jede Tabelle mit Policy die Zeilenzahl
|
|
zaehlen. Die Tabellenliste nicht fest eintippen, sondern zur Laufzeit aus `pg_policies` fuer
|
|
das Schema `public` lesen — dann waechst die Pruefung automatisch mit Task 4 und mit jeder
|
|
spaeteren Policy mit. Bestanden, wenn jede Zahl 0 ist. Der Bericht nennt jede Tabelle, die
|
|
ungleich 0 liefert, denn genau diese Zeile ist der Beweis fuer eine wirkungslose Trennung.
|
|
|
|
`mit-kontext-sichtbar` — eine vorhandene Mandantenkennung aus der Tabelle `Tenant` lesen
|
|
(`Tenant` traegt selbst keine Policy und bleibt daher lesbar), den Kontext darauf setzen und
|
|
dieselben Zaehlungen wiederholen. Bestanden, wenn mindestens eine Tabelle mehr als 0 Zeilen
|
|
liefert — sonst waere nicht die Trennung bewiesen, sondern nur eine unbrauchbare Verbindung.
|
|
Findet sich kein Mandant, die Pruefung als "nicht durchfuehrbar" berichten statt sie
|
|
faelschlich zu bestehen.
|
|
|
|
`schreibrechte` — ueber `has_table_privilege` fuer jede Tabelle im Schema `public` alle vier
|
|
Zugriffsarten pruefen. Bestanden, wenn keine Tabelle ein Recht vermissen laesst. Der Bericht
|
|
nennt jede Luecke einzeln; genau hier zeigt sich, ob Task 1 etwas vergessen hat, bevor
|
|
jemand die Verbindung umstellt.
|
|
|
|
Danach `docs/mandantentrennung-datenbankrolle.md` schreiben — deutsch, an den Betreiber
|
|
gerichtet, im Ton der vorhandenen Anleitungen unter `docs/`. Inhalt:
|
|
|
|
(1) Der Befund in einfachen Worten: warum die Trennung heute nichts tut, mit der gemessenen
|
|
Beobachtung (ohne gesetzten Mandanten liefert eine Zaehlung auf `Group` zwei Zeilen statt
|
|
null) und dem Hinweis auf WINDOWS #18.
|
|
|
|
(2) Was diese Aenderung bereits mitbringt: die Rolle, die getrennten Verbindungen, das
|
|
Pruefwerkzeug, die vollstaendigen Policies.
|
|
|
|
(3) **Der Sperrgrund, unmissverstaendlich.** Die Umstellung ist noch nicht vollzogen und darf
|
|
noch nicht vollzogen werden. Gezaehlt am 2026-09-09: 182 Zugriffe im API-Quelltext laufen
|
|
ueber den unskalierten Prisma-Client (84 auf die bisher geschuetzten, 98 auf die neu
|
|
geschuetzten Tabellen), demgegenueber nur 19 Verwendungen von `tenantPrisma`. Der Anmeldeweg
|
|
gehoert dazu und kann gar nicht anders: `apps/api/src/auth/auth.service.ts:37-41` sucht den
|
|
Benutzer, bevor der Mandant bekannt ist. Unter der neuen Rolle liefert diese Suche null
|
|
Zeilen — niemand koennte sich mehr anmelden. Ebenso betroffen: die Hintergrunddienste fuer
|
|
AD-Abgleich, Ausschreibungs-Digest, Modulzugriff, Treffersuche und die Erstanlage des
|
|
Administrators. Diese Wege brauchen einen ausdruecklichen, benannten Systemkontext, bevor der
|
|
Schalter umgelegt werden darf. Das ist eigene Arbeit und nicht Teil dieser Aenderung.
|
|
|
|
(4) Die Handgriffe, die spaeter noetig sind, in der richtigen Reihenfolge und mit dem klaren
|
|
Vermerk, welche der Betreiber selbst ausfuehren muss, weil sie in keiner Migration stehen
|
|
koennen: das Kennwort der Rolle einmalig setzen (die Anweisung `ALTER ROLE tessera_app WITH
|
|
PASSWORD` mit Platzhalter statt eines echten Werts nennen und dazusagen, dass sie als
|
|
Datenbank-Superuser laufen muss und nicht in eine Datei gehoert); anschliessend in der
|
|
`.env` des Servers `TESSERA_MIGRATE_DATABASE_URL` auf die bisherige Verbindung setzen und
|
|
`DATABASE_URL` auf die neue Rolle umbiegen — in dieser Reihenfolge, denn die Migration muss
|
|
weiterhin als Eigentuemer laufen.
|
|
|
|
(5) Die Vorher-Pruefung: Aufruf des Werkzeugs mit gesetztem `TESSERA_PREFLIGHT_DATABASE_URL`
|
|
auf die neue Rolle. Sie ist die Freigabebedingung. Ausdruecklich dazusagen, dass ein gruener
|
|
Bericht nur bedeutet, dass die Datenbankseite stimmt — die 182 Zugriffe aus Punkt (3) misst
|
|
das Werkzeug nicht.
|
|
|
|
(6) Der Rueckweg und die Not-Antwort. Wenn die API nach einer Umstellung nicht mehr
|
|
hochkommt: das Sichtbare beschreiben (der Container bleibt ungesund, das Protokoll zeigt
|
|
einen Authentifizierungs- oder Rechtefehler von PostgreSQL) und den Weg zurueck in zwei
|
|
Schritten — `DATABASE_URL` in der `.env` wieder auf die bisherige Verbindung setzen und den
|
|
API-Dienst neu erstellen. Dazu der Hinweis, dass die neue Rolle dabei bestehen bleiben darf;
|
|
sie schadet nicht, solange niemand sie benutzt. Und der Hinweis, dass die Datei
|
|
`/opt/tessera/docker-compose.yml` auf dem Server keine Arbeitskopie des Repositorys ist und
|
|
von einem Deploy nicht angefasst wird — wer die neuen Variablen dort haben will, traegt sie
|
|
selbst ein.
|
|
|
|
Zuletzt `docs/README.md`: die neue Anleitung in der Tabelle der Leserkreise oder im Absatz
|
|
ueber das CI/CD-Runbook verlinken, mit einem Satz, der sagt, dass sie sich an dieselben Leute
|
|
wie die Betriebsanleitung richtet und nur die Datenbankrolle behandelt.
|
|
</action>
|
|
|
|
<verify>
|
|
<automated>pnpm --filter=@tessera/api exec vitest run --passWithNoTests=false src/prisma/rls-preflight.spec.ts</automated>
|
|
</verify>
|
|
|
|
<done>`apps/api/scripts/rls-preflight.mjs` fuehrt fuenf benannte Pruefungen jeweils in einer Transaktion aus, verbindet in der Pruefplan-Betriebsart nicht und schreibt in keiner Betriebsart. `docs/mandantentrennung-datenbankrolle.md` nennt den Sperrgrund mit der gemessenen Zahl 182, die Handgriffe des Betreibers samt Kennwortsetzung, die Freigabebedingung und den Rueckweg; `docs/README.md` verweist darauf. Die fuenf Tests in `src/prisma/rls-preflight.spec.ts` sind gruen.</done>
|
|
</task>
|
|
|
|
<task type="auto" tdd="true">
|
|
<name>Task 4: Policies fuer die 16 fehlenden Tabellen, mit benannten Ausnahmen und dauerhafter Abdeckungspruefung</name>
|
|
<files>apps/api/prisma/migrations/20260909140000_rls_remaining_tenant_tables/migration.sql, apps/api/src/prisma/rls-coverage.spec.ts</files>
|
|
|
|
<precondition>Keine. Der Test liest ausschliesslich `apps/api/prisma/schema.prisma` und die Dateien unter `apps/api/prisma/migrations/`; es wird keine Datenbank benoetigt und keine Migration angewendet.</precondition>
|
|
|
|
<reversibility rating="costly">Policies lassen sich zurueckbauen, aber nur ueber eine weitere Migration — eine angewendete Migration darf nicht nachtraeglich veraendert werden. Solange die Anwendung als Rolle mit Umgehungsrecht verbindet, sind die neuen Policies ohne Wirkung auf den Betrieb; das begrenzt den Schaden einer falschen Entscheidung erheblich.</reversibility>
|
|
|
|
<behavior>
|
|
`apps/api/src/prisma/rls-coverage.spec.ts` entsteht zuerst und ist rot — er faellt heute mit
|
|
einer Liste von 16 nicht abgedeckten Tabellen. Er misst die Abdeckung, statt Text zu
|
|
vergleichen, und bleibt dadurch auch fuer kuenftige Modelle gueltig.
|
|
|
|
Aufbau: aus `apps/api/prisma/schema.prisma` alle `model`-Bloecke lesen und in zwei Mengen
|
|
teilen — Modelle mit einem Feld `tenantId` und Modelle ohne. Aus allen `migration.sql`-Dateien
|
|
unter `apps/api/prisma/migrations/` die Tabellennamen sammeln, fuer die
|
|
`ENABLE ROW LEVEL SECURITY` vorkommt, und getrennt davon die, fuer die `CREATE POLICY`
|
|
vorkommt.
|
|
|
|
- Test 1: Jedes Modell mit `tenantId` hat RLS eingeschaltet. Die Fehlermeldung nennt die
|
|
fehlenden Namen sortiert.
|
|
- Test 2: Jede Tabelle mit eingeschaltetem RLS hat auch mindestens eine Policy. Eingeschaltetes
|
|
RLS ohne Policy sperrt jede Zeile aus — das waere schlimmer als gar keine Regel.
|
|
- Test 3: Die Modelle ohne `tenantId` zerfallen genau in zwei im Test fest hinterlegte Listen:
|
|
die drei ueber eine Verknuepfung geschuetzten (PasswordResetToken, LdapFieldMapping,
|
|
GroupMembership) und die fuenf bewusst ausgenommenen (Tenant, Module, Tender, TenderSource,
|
|
TenderSourcePollConfig). Kommt ein neues Modell ohne `tenantId` hinzu, faellt der Test und
|
|
erzwingt eine Entscheidung, statt es stillschweigend durchzulassen. Jeder Eintrag der
|
|
Ausnahmeliste traegt im Test seine Begruendung als Zeichenkette.
|
|
- Test 4: Die neue Migration nennt jede der fuenf Ausnahmen namentlich in ihrem Kopf.
|
|
- Test 5: Die neue Migration schaltet fuer alle 16 Tabellen sowohl `ENABLE` als auch `FORCE`
|
|
ein und legt fuer jede genau eine Policy an — 16 Vorkommen von `CREATE POLICY`.
|
|
</behavior>
|
|
|
|
<action>
|
|
Zuerst den Test schreiben (rot, mit 16 gemeldeten Luecken), dann die Migration.
|
|
|
|
Die Einteilung ist am 2026-09-09 im Schema nachgezaehlt: 28 Modelle, 20 davon mit
|
|
`tenantId`-Spalte, 7 Tabellen mit Policy. Alle 16 fehlenden tragen eine direkte
|
|
`tenantId`-Spalte; keine braucht das Unterabfrage-Muster von `PasswordResetToken`.
|
|
|
|
**Bekommen eine Policy (16):** CalendarSource, DashboardLayout, DkvInvoiceHistory,
|
|
DkvModuleConfig, DkvVehicleMaster, FavoriteLink, SearchProvider, SmtpConfig,
|
|
TenantModuleActivation, TenderEmailConfig, TenderMatch, TenderNotificationPref,
|
|
TenderRssFeedSource, TenderSavedSearch, TenderTriage, WidgetInstance.
|
|
|
|
**Bleiben bewusst ohne Policy (5), jeweils weil sie keine `tenantId`-Spalte tragen und auch
|
|
keine tragen sollen:** `Tenant` — die Mandantentabelle selbst; eine Regel darauf wuerde die
|
|
Aufloesung des Mandanten verhindern, auf der jede andere Regel beruht. `Module` — der
|
|
Modulkatalog ist plattformweit; die mandantenbezogene Zuordnung liegt in
|
|
`TenantModuleActivation`, und die bekommt eine Policy. `Tender`, `TenderSource` und
|
|
`TenderSourcePollConfig` — der Ausschreibungskatalog ist plattformweite Bezugsdaten
|
|
(Entscheidung D-03 aus Phase 10, wortwoertlich in
|
|
`.planning/phases/10-ausschreibungs-radar-foundation-d-e-ingestion/10-01-PLAN.md:93`: kein
|
|
`tenantId`, weil global). Eine Policy darauf wuerde einem zweiten Mandanten den gemeinsamen
|
|
Katalog verbergen.
|
|
|
|
**Eine Aussage aus dem Bestand ist zu korrigieren, und die Korrektur gehoert in den Kopf der
|
|
neuen Datei — nicht in die alte.** Der Kopf von
|
|
`apps/api/prisma/migrations/20260804130918_groups_rls_policies/migration.sql` sagt pauschal,
|
|
"die Tender*-Tabellen bleiben bewusst ohne RLS (D-03)". Nachgemessen trifft das nur auf die
|
|
drei Tabellen ohne `tenantId` zu. Die sechs Tender-Tabellen mit `tenantId`
|
|
(TenderEmailConfig, TenderMatch, TenderNotificationPref, TenderRssFeedSource,
|
|
TenderSavedSearch, TenderTriage) enthalten keine Katalogdaten, sondern Zeilen einzelner
|
|
Nutzer und Mandanten — Suchprofile, Treffer, Benachrichtigungseinstellungen,
|
|
Postfachanbindungen. D-03 betrifft sie nicht. Die alte Datei bleibt unveraendert, weil Prisma
|
|
ihre Pruefsumme fuehrt und eine Aenderung `prisma migrate deploy` zum Abbruch bringen wuerde —
|
|
womit der API-Container nicht mehr startet.
|
|
|
|
Nun `apps/api/prisma/migrations/20260909140000_rls_remaining_tenant_tables/migration.sql`
|
|
anlegen. Der Kopfkommentar traegt in deutschen Saetzen: die Zaehlung (28/20/7/16); die
|
|
Einteilung oben mit je einer Begruendung; die soeben beschriebene Korrektur samt Grund, warum
|
|
sie hier und nicht dort steht; und den unmissverstaendlichen Hinweis, dass diese Policies
|
|
erst wirken, wenn die Anwendung als Rolle ohne Umgehungsrecht verbindet — mit Verweis auf die
|
|
Migration `20260909130000_rls_app_role` und auf
|
|
`docs/mandantentrennung-datenbankrolle.md`. Ohne diesen Satz waere die Datei genau das, wovor
|
|
WINDOWS #18 warnt: eine Regel, die Sicherheit vortaeuscht.
|
|
|
|
Der SQL-Koerper folgt fuer jede der 16 Tabellen exakt dem Muster aus
|
|
`20260804130918_groups_rls_policies` — RLS einschalten, erzwingen, und eine Policy
|
|
`tenant_isolation_policy` mit dem Vergleich der `tenantId`-Spalte gegen `current_tenant_id()`.
|
|
Der Policy-Name bleibt in allen Tabellen derselbe; Policy-Namen sind je Tabelle eindeutig, das
|
|
ist kein Konflikt und haelt die Suche einfach. Keine getrennte Pruefklausel angeben: laesst
|
|
man sie weg, verwendet PostgreSQL denselben Ausdruck auch fuer neu geschriebene Zeilen —
|
|
genau das ist gewollt, denn damit kann unter der neuen Rolle niemand eine Zeile mit fremder
|
|
Mandantenkennung einfuegen.
|
|
|
|
Reihenfolge im Dokument: alphabetisch nach Tabellenname, damit ein Leser eine Tabelle findet,
|
|
ohne die Datei zu durchsuchen. Jede Tabelle bekommt eine Kommentarzeile mit ihrer Rolle im
|
|
System (etwa: Dashboard-Anordnung eines Nutzers; Fahrzeugstammdaten des DKV-Moduls;
|
|
Suchprofile im Ausschreibungsmodul). Keine Datenaenderung, kein `ALTER TABLE` ausser dem
|
|
Ein- und Erzwingen von RLS.
|
|
</action>
|
|
|
|
<verify>
|
|
<automated>pnpm --filter=@tessera/api exec vitest run --passWithNoTests=false src/prisma/rls-coverage.spec.ts</automated>
|
|
</verify>
|
|
|
|
<done>Alle 20 Modelle mit `tenantId` haben RLS eingeschaltet und eine Policy; keine Tabelle hat RLS ohne Policy; die fuenf Ausnahmen ohne `tenantId` sind im Test mit Begruendung und im Kopf der neuen Migration namentlich aufgefuehrt. Der Abdeckungstest ist gruen und faellt kuenftig bei jedem neuen Modell ohne bewusste Entscheidung. Keine bestehende Migrationsdatei wurde veraendert.</done>
|
|
</task>
|
|
|
|
</tasks>
|
|
|
|
<threat_model>
|
|
## Vertrauensgrenzen
|
|
|
|
| Grenze | Beschreibung |
|
|
|--------|--------------|
|
|
| Anwendungsprozess -> Datenbank | Hier soll die Mandantentrennung durchgesetzt werden. Heute ist die Grenze offen: die Anwendungsrolle umgeht jede Regel. |
|
|
| Betreiber -> Serverkonfiguration | Kennwort und Verbindungsangaben werden von Hand gesetzt; sie duerfen nirgends im Repository landen. |
|
|
| Migrationsschritt -> Laufzeitschritt | Der eine braucht Eigentuemerrechte, der andere darf sie gerade nicht haben. Bis heute nutzen beide dieselbe Verbindung. |
|
|
|
|
## STRIDE-Register
|
|
|
|
| Kennung | Kategorie | Betroffenes Teil | Schwere | Umgang | Massnahme |
|
|
|---------|-----------|------------------|---------|--------|-----------|
|
|
| T-DGJ-01 | Information Disclosure | Rolle `tessera` (`docker-compose.yml:33`/`:76`), rolsuper und rolbypassrls gesetzt | critical | mitigate | Task 1 legt `tessera_app` mit `NOSUPERUSER NOBYPASSRLS` an und konvergiert eine vorhandene Rolle darauf; Task 3 misst die beiden Merkmale, statt sie anzunehmen. Die Grenze bleibt bis zur Umstellung offen — Task 3 sagt das in der Anleitung ausdruecklich. |
|
|
| T-DGJ-02 | Information Disclosure | 16 Tabellen mit `tenantId` ohne Policy — u. a. SmtpConfig, DkvVehicleMaster, CalendarSource, TenderEmailConfig | high | mitigate | Task 4 ergaenzt fuer alle 16 eine Policy und sichert die Abdeckung dauerhaft ueber einen Test, der aus Schema und Migrationen misst statt Text zu vergleichen. |
|
|
| T-DGJ-03 | Denial of Service | Umstellung der Verbindung sperrt die Anwendung aus ihren eigenen Daten aus — gemessen: 182 unskalierte Zugriffe, darunter der Anmeldeweg | high | mitigate | Der Schalter bleibt als Vorgabe aus (Task 2: ohne gesetzte Migrationsvariable verhaelt sich alles wie bisher). Task 3 liefert die Vorher-Pruefung, den benannten Sperrgrund und einen Rueckweg in zwei Schritten. |
|
|
| T-DGJ-04 | Elevation of Privilege | Rollenanlage in einer Migration verlangt erhoehte Rechte zur Anwendungszeit | medium | mitigate | Task 1 prueft die Berechtigung vorher und bricht mit einer Meldung ab, die die von Hand auszufuehrende Anweisung nennt. Ein stilles Ueberspringen ist ausgeschlossen — es wuerde eine nicht vorhandene Rolle als vorhanden erscheinen lassen. |
|
|
| T-DGJ-05 | Information Disclosure | Zugangsdaten in Repository, Abbild oder Protokoll | medium | mitigate | Kein Kennwort im SQL (Task 1, per Test abgesichert); `.env.example` traegt nur Platzhalter; das Startskript gibt Variablennamen statt Werte aus (Task 2, per Test abgesichert). |
|
|
| T-DGJ-06 | Tampering | Einfuegen einer Zeile mit fremder Mandantenkennung | medium | mitigate | Die Policies geben keine getrennte Pruefklausel an; PostgreSQL verwendet dann denselben Ausdruck fuer neue Zeilen. Unter der neuen Rolle scheitert ein Einfuegen mit fremder Kennung. |
|
|
| T-DGJ-07 | Tampering | Nachtraegliche Aenderung einer bereits angewendeten Migration bricht `migrate deploy` und damit den Containerstart | medium | mitigate | Als globale Vorgabe festgeschrieben; die Korrektur der Aussage aus `20260804130918` steht ausschliesslich im Kopf der neuen Datei. |
|
|
| T-DGJ-08 | Spoofing | Falsches Sicherheitsgefuehl — Policies vorhanden, Wirkung nicht | high | mitigate | Der Kopf der neuen Migration sagt ausdruecklich, dass die Regeln erst mit der neuen Rolle wirken; die Anleitung nennt die Umstellung als nicht vollzogen; WINDOWS #18 bleibt offen. |
|
|
| T-DGJ-SC | Tampering | Lieferkette ueber Paketinstallationen | low | accept | Dieser Plan installiert kein Paket. Das Pruefwerkzeug nutzt `@prisma/client`, der bereits Abhaengigkeit der API ist; `package.json` und `pnpm-lock.yaml` bleiben unveraendert. |
|
|
</threat_model>
|
|
|
|
<verification>
|
|
Alle vier Abnahmepruefungen sind ohne Datenbank lauffaehig — die Gitea-CI hat keine
|
|
(`.gitea/workflows/ci.yml`: nur Lint, Typpruefung und `pnpm test`).
|
|
|
|
Vorab gemessen am 2026-09-09, damit die Pruefungen nicht von vornherein gruen sind:
|
|
`pnpm --filter=@tessera/api exec vitest run --passWithNoTests=false src/prisma/rls-app-role.spec.ts`
|
|
liefert heute Ende-Code 1 ("No test files found"); derselbe Aufruf ohne die Option liefert
|
|
Ende-Code 0. Die vorhandene Testdatei `src/groups/migration-sql.spec.ts` laeuft mit derselben
|
|
Befehlsform gruen (14 Tests) — die Befehlsform ist damit belegt und nicht geraten.
|
|
|
|
Nach allen vier Aufgaben zusaetzlich die vollstaendige Reihe:
|
|
`pnpm --filter=@tessera/api exec vitest run` — muss gruen bleiben. Und `pnpm lint` sowie
|
|
`pnpm type-check`, weil zwei neue Testdateien und ein neues ES-Modul hinzukommen.
|
|
|
|
Nicht Teil der Abnahme, weil ausserhalb dieses Vorgangs: das tatsaechliche Anwenden der
|
|
Migrationen, das Umstellen der Verbindung und jede Handlung auf 192.168.13.12.
|
|
</verification>
|
|
|
|
<success_criteria>
|
|
- Zwei neue Migrationsordner, keine bestehende Migrationsdatei veraendert.
|
|
- `tessera_app` wird wiederholbar angelegt, traegt weder Superuser- noch Umgehungsrecht, und die
|
|
Migration bricht mit einer verwendbaren Anleitung ab, wenn ihr die Rechte dafuer fehlen.
|
|
- Kein Kennwort und kein Verbindungswert in einer versionierten Datei.
|
|
- Ohne gesetztes `TESSERA_MIGRATE_DATABASE_URL` ist der Containerstart Schritt fuer Schritt der
|
|
bisherige — lokal, in der CI und auf dem Server.
|
|
- Das Pruefwerkzeug misst die fuenf benannten Eigenschaften in Transaktionen, veraendert nichts
|
|
und laesst sich ohne Datenbank in der Pruefplan-Betriebsart testen.
|
|
- Alle 20 Modelle mit `tenantId` sind abgedeckt; die fuenf Ausnahmen sind namentlich mit
|
|
Begruendung festgehalten und werden von einem Test bewacht.
|
|
- Die Anleitung nennt die Umstellung als nicht vollzogen, den Sperrgrund mit der gemessenen Zahl,
|
|
die Handgriffe des Betreibers und den Rueckweg bei einer nicht mehr verbindenden API.
|
|
- WINDOWS #18 bleibt offen; die Beschreibung des Eintrags kann um den Verweis auf
|
|
`docs/mandantentrennung-datenbankrolle.md` und den Sperrgrund ergaenzt werden.
|
|
</success_criteria>
|
|
|
|
<output>
|
|
Bei Abschluss `.planning/quick/260909-dgj-mandantentrennung-auf-alle-tabellen-mit-/260909-dgj-SUMMARY.md` schreiben.
|
|
</output>
|