docs(quick-260909-eor): Etappe 1 der Mandantentrennung geplant
Drei Punkte werden in dieser Etappe abschliessend geklaert: der gemessene forTenant()-Defekt, die schmale Ausnahme fuer den Anmeldeweg und die maschinell abgesicherte Klassifikation aller 232 Datenbankzugriffe. Der Befund zu forTenant() wurde vor der Planung empirisch belegt: set_config laeuft auf Backend 254999, die eigentliche Abfrage auf 255000, der Mandantenkontext ist dort NULL. Alle heutigen forTenant()-Aufrufe sind damit stillschweigend ungebunden. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01FYZcd3SSmo14QTqWx2KKzU
This commit is contained in:
+507
@@ -0,0 +1,507 @@
|
||||
---
|
||||
phase: quick-260909-eor
|
||||
plan: 01
|
||||
type: execute
|
||||
wave: 1
|
||||
depends_on: []
|
||||
files_modified:
|
||||
- apps/api/src/prisma/prisma-tenant.extension.ts
|
||||
- apps/api/src/prisma/prisma-tenant.extension.spec.ts
|
||||
- apps/api/scripts/rls-scratch-check.mjs
|
||||
- apps/api/prisma/migrations/20260909160000_auth_lookup_functions/migration.sql
|
||||
- apps/api/src/prisma/auth-lookup-functions.spec.ts
|
||||
- apps/api/src/auth/auth.service.ts
|
||||
- apps/api/src/auth/auth.service.spec.ts
|
||||
- apps/api/src/prisma/rls-access-inventory.spec.ts
|
||||
- docs/mandantentrennung-zugriffsklassifikation.md
|
||||
- docs/mandantentrennung-datenbankrolle.md
|
||||
- .planning/WINDOWS.md
|
||||
autonomous: false
|
||||
requirements: [WINDOWS-18, WINDOWS-19]
|
||||
|
||||
estimate:
|
||||
tokens: 95000
|
||||
raw_tokens: 95000
|
||||
tasks: 4
|
||||
confidence: low # keine Kalibrierungsstichproben fuer dieses Repository vorhanden; Faktor 1,0 angesetzt
|
||||
|
||||
must_haves:
|
||||
truths:
|
||||
- "forTenant() setzt den Mandantenkontext und fuehrt die Abfrage auf DERSELBEN Datenbankverbindung aus — gemessen ueber pg_backend_pid(), nicht behauptet."
|
||||
- "Unter einer Rolle ohne BYPASSRLS sieht ein forTenant(A)-Lesezugriff ausschliesslich Zeilen von Mandant A und niemals Zeilen von Mandant B."
|
||||
- "Unter derselben Rolle findet die Benutzersuche des Anmeldewegs den passenden Benutzer weiterhin — die Anmeldung bleibt moeglich."
|
||||
- "Unter derselben Rolle liefert ein gewoehnlicher, ungebundener SELECT auf \"User\" null Zeilen. Die Ausnahme ist die Funktion, nicht die Tabelle."
|
||||
- "Jede this.prisma.*-Fundstelle in apps/api/src traegt eine schriftliche Einstufung mit Begruendung, und eine Maschine prueft die Vollstaendigkeit."
|
||||
- "DATABASE_URL zeigt am Ende dieser Etappe unveraendert auf die bisherige Rolle — es wird nichts scharf geschaltet."
|
||||
artifacts:
|
||||
- apps/api/src/prisma/prisma-tenant.extension.ts
|
||||
- apps/api/src/prisma/prisma-tenant.extension.spec.ts
|
||||
- apps/api/scripts/rls-scratch-check.mjs
|
||||
- apps/api/prisma/migrations/20260909160000_auth_lookup_functions/migration.sql
|
||||
- apps/api/src/prisma/auth-lookup-functions.spec.ts
|
||||
- apps/api/src/prisma/rls-access-inventory.spec.ts
|
||||
- docs/mandantentrennung-zugriffsklassifikation.md
|
||||
key_links:
|
||||
- "prisma-tenant.extension.ts: die Array-Form von $transaction bindet set_config und die Abfrage an eine Verbindung — die interaktive Callback-Form ist genau das, was heute bricht."
|
||||
- "auth.service.ts validateUser -> auth_lookup_user_by_username: der einzige verbleibende Lesezugriff auf \"User\" vor bekanntem Mandanten."
|
||||
- "rls-access-inventory.spec.ts -> docs/mandantentrennung-zugriffsklassifikation.md: die Spec haelt das Dokument waehrend Etappe 2/3 wahr, waehrend Fundstellen umgebaut werden."
|
||||
- "auth_lookup_*-Funktionen -> GRANT EXECUTE ausschliesslich an tessera_app: die Rolle, die spaeter tatsaechlich verbindet, ist die einzige, die die Ausnahme nutzen darf."
|
||||
---
|
||||
|
||||
<objective>
|
||||
Etappe 1 der Mandantentrennung: das Fundament belastbar machen, bevor irgendein Zugriff umgebaut wird.
|
||||
|
||||
Drei Dinge werden hier abschliessend geklaert. Erstens: `forTenant()` ist defekt — gemessen, nicht
|
||||
vermutet — und wird repariert. Zweitens: der Anmeldeweg bekommt eine bewusst schmale, begruendete
|
||||
Ausnahme, damit die Umstellung spaeter nicht in einer Anwendung endet, in die sich niemand mehr
|
||||
einloggen kann. Drittens: alle Datenbankzugriffe werden klassifiziert und die Klassifikation
|
||||
maschinell gegen den Quelltext abgesichert, damit die folgenden Etappen eine Landkarte mit
|
||||
Mengenangaben haben.
|
||||
|
||||
Purpose: Ohne ein funktionierendes `forTenant()` waere jeder Umbau in Etappe 2 wertlos — er wuerde
|
||||
Aufrufe auf einen Mechanismus umstellen, der nichts bewirkt. Ohne die Anmelde-Ausnahme waere das
|
||||
Scharfschalten in Etappe 4 ein garantierter Totalausfall.
|
||||
|
||||
Output: repariertes `forTenant()` mit Live-Nachweis, drei eng geschnittene Anmelde-Funktionen in der
|
||||
Datenbank samt Verdrahtung, ein maschinell geprueftes Klassifikationsdokument ueber alle 232
|
||||
Fundstellen.
|
||||
|
||||
**Ausdruecklich NICHT in dieser Etappe:** `DATABASE_URL` wird nicht auf `tessera_app` umgestellt.
|
||||
Der Server 192.168.13.12 wird nicht angefasst. Migrationen laufen ausschliesslich gegen eine lokale
|
||||
Wegwerf-Datenbank.
|
||||
</objective>
|
||||
|
||||
<execution_context>
|
||||
@~/.claude/gsd-core/workflows/execute-plan.md
|
||||
@~/.claude/gsd-core/templates/summary.md
|
||||
</execution_context>
|
||||
|
||||
<context>
|
||||
@.planning/STATE.md
|
||||
@.planning/WINDOWS.md
|
||||
@docs/mandantentrennung-datenbankrolle.md
|
||||
@apps/api/src/prisma/prisma-tenant.extension.ts
|
||||
@apps/api/src/prisma/prisma.service.ts
|
||||
@apps/api/src/auth/auth.service.ts
|
||||
@apps/api/src/prisma/rls-app-role.spec.ts
|
||||
@apps/api/prisma/migrations/20260909130000_rls_app_role/migration.sql
|
||||
@apps/api/prisma/migrations/20260618112133_rls_policies/migration.sql
|
||||
</context>
|
||||
|
||||
<measured_baseline>
|
||||
Alle Zahlen des Auftrags wurden am 2026-09-09 nachgemessen. Zwei weichen ab und gelten in dieser
|
||||
korrigierten Fassung:
|
||||
|
||||
| Groesse | Auftrag | Nachgemessen | Befehl |
|
||||
|---|---|---|---|
|
||||
| `this.prisma.*`-Fundstellen (ohne Specs) | 228 | **232**, verteilt auf **32 Dateien** | `grep -ro "this\.prisma\.[a-zA-Z]*" apps/api/src --include=*.ts \| grep -v "\.spec\.ts" \| wc -l` |
|
||||
| tenders | 61 | **62** | dito, je Verzeichnis |
|
||||
| groups | 34 | **37** | dito |
|
||||
| ldap / dkv | 21 / 21 | 21 / 21 | dito |
|
||||
| user / module-registry | 17 / 17 | 17 / 17 | dito |
|
||||
| dashboard / auth | 13 / 13 | 13 / 13 | dito |
|
||||
| calendar / tenant | 12 / 8 | 12 / 8 | dito |
|
||||
| favorites / settings | 7 / 4 | 7 / 4 | dito |
|
||||
|
||||
Die **36** Treffer auf `forTenant`/`tenantPrisma` sind irrefuehrend: die Mehrzahl sind Kommentare,
|
||||
die begruenden, warum an dieser Stelle *kein* `forTenant()` steht. Tatsaechliche `forTenant(`-Aufrufe:
|
||||
**6** (`ldap.service.ts:762,905,1179,1342`, `tenant.middleware.ts:44`, `tenant.guard.ts:41`).
|
||||
Tatsaechliche Abfragen ueber einen mandantengebundenen Client: **9**, alle in `ldap.service.ts`.
|
||||
`req.tenantPrisma` wird von `tenant.middleware.ts` und `tenant.guard.ts` gesetzt, im gesamten
|
||||
`apps/api/src` aber von keinem Controller gelesen — die Verdrahtung laeuft ins Leere. Das ist als
|
||||
Befund in Aufgabe 3 festzuhalten.
|
||||
|
||||
Weiter nachgemessen:
|
||||
- 28 Prisma-Modelle. **8 ohne `tenantId`**: `Tenant`, `PasswordResetToken`, `LdapFieldMapping`,
|
||||
`Module`, `GroupMembership`, `Tender`, `TenderSource`, `TenderSourcePollConfig`. Achtung:
|
||||
`PasswordResetToken` und `GroupMembership` tragen dennoch RLS — ueber einen Unterabfrage-Join auf
|
||||
`User` bzw. `Group` (`20260618112133_rls_policies/migration.sql:18-21`). "Ohne `tenantId`" ist
|
||||
also nicht deckungsgleich mit "ohne Regel".
|
||||
- 2 Modelle mit nullbarem `tenantId`: `SearchProvider` (`schema.prisma:218`) und
|
||||
`TenderRssFeedSource` (`schema.prisma:579`) — das ist WINDOWS #19.
|
||||
- 16 `CREATE POLICY` in `20260909140000_rls_remaining_tenant_tables`; zusammen mit den frueheren
|
||||
Migrationen 20 abgedeckte Tabellen.
|
||||
- Lokale Datenbank erreichbar unter `172.19.0.2:5432`, Rolle `tessera` / `tessera_dev`
|
||||
(Container `tessera-ctl-db-1`, `postgres:16-alpine`, kein Host-Port).
|
||||
- Installiertes Prisma: **6.19.3** (nicht 7.x wie in CLAUDE.md behauptet). Die Extension-API dieser
|
||||
Fassung ist massgeblich.
|
||||
|
||||
## Der entscheidende Befund: forTenant() ist defekt
|
||||
|
||||
Nicht gelesen, sondern gemessen. Ein Nachbau des exakten Musters aus
|
||||
`prisma-tenant.extension.ts` gegen die lokale Datenbank ergab:
|
||||
|
||||
```
|
||||
inside tx : {"pid":254999,"t":"TENANT-A"}
|
||||
actual qry : {"pid":255000,"t":null}
|
||||
SAME CONNECTION? false
|
||||
TENANT VISIBLE TO ACTUAL QUERY? null
|
||||
```
|
||||
|
||||
`set_config('app.current_tenant', ..., true)` laeuft auf Backend 254999. Die eigentliche Abfrage
|
||||
laeuft auf Backend 255000 und sieht den Mandantenkontext als NULL. Ursache: `query(args)` in
|
||||
`$allOperations` fuehrt die Operation auf dem **aeusseren** Client aus, nicht auf `tx`; die
|
||||
interaktive Transaktion haelt eine eigene Verbindung, die Einstellung ist transaktionslokal.
|
||||
|
||||
Konsequenz: Alle heutigen `forTenant()`-Aufrufe sind stillschweigend ungebunden. Unter einer Rolle
|
||||
ohne BYPASSRLS wuerden sie nicht etwa zu viel liefern, sondern **null Zeilen** — die Policy
|
||||
`"tenantId" = current_tenant_id()` vergleicht gegen NULL. Der AD-Abgleich in `ldap.service.ts`
|
||||
wuerde beim Scharfschalten wortlos leerlaufen und, im Loeschzweig ab Zeile 1559, potenziell
|
||||
Gruppen als "im Verzeichnis verschwunden" behandeln.
|
||||
</measured_baseline>
|
||||
|
||||
<tasks>
|
||||
|
||||
<task type="tracer">
|
||||
<name>Aufgabe 1: forTenant() auf eine Verbindung zwingen und die Wirkung live nachweisen</name>
|
||||
<files>apps/api/src/prisma/prisma-tenant.extension.ts, apps/api/src/prisma/prisma-tenant.extension.spec.ts, apps/api/scripts/rls-scratch-check.mjs</files>
|
||||
<reversibility rating="reversible">Reine Codeaenderung an einer Datei plus zwei neue Pruefdateien; kein Schemawechsel, kein Betriebsschalter.</reversibility>
|
||||
<behavior>
|
||||
- Der Mandantenkontext und die eigentliche Abfrage teilen sich eine Datenbankverbindung: die von `pg_backend_pid()` gemeldete Kennung ist bei beiden gleich.
|
||||
- `current_setting('app.current_tenant', true)` ist waehrend der eigentlichen Abfrage auf den uebergebenen Mandanten gesetzt, nicht NULL.
|
||||
- Unter einer Rolle ohne BYPASSRLS liefert ein `forTenant(A)`-Lesezugriff auf eine Tabelle mit Policy genau die Zeilen von A.
|
||||
- Derselbe Lesezugriff liefert null Zeilen von Mandant B.
|
||||
- Ein ungebundener Lesezugriff derselben Rolle auf dieselbe Tabelle liefert null Zeilen.
|
||||
</behavior>
|
||||
<action>
|
||||
Ersetze in `prisma-tenant.extension.ts` die interaktive Callback-Form durch die Array-Form von
|
||||
`$transaction`. Der Kern: `$allOperations` gibt das Ergebnis eines
|
||||
`prisma.$transaction([ setConfigPromise, query(args) ])` zurueck und entnimmt den zweiten
|
||||
Eintrag. Prisma fuehrt die Array-Form als eine Transaktion auf einer Verbindung aus, weshalb
|
||||
die transaktionslokale Einstellung fuer die Abfrage sichtbar wird. Das ist genau das Muster,
|
||||
das Prisma selbst fuer RLS ueber Client-Extensions vorsieht.
|
||||
|
||||
Der Mandantenwert MUSS parametrisiert bleiben — nutze ein getaggtes `$executeRaw`-Template mit
|
||||
interpoliertem Wert, nicht `$executeRawUnsafe` mit zusammengebautem Text. Die
|
||||
Injektionsfestigkeit aus T-02-05 ist eine bestehende Zusage und darf bei diesem Umbau nicht
|
||||
verloren gehen.
|
||||
|
||||
Halte im Kopfkommentar der Datei fest, warum die Array-Form Pflicht ist und die
|
||||
Callback-Form nicht funktioniert, mit den gemessenen Backend-Kennungen als Beleg. Formuliere
|
||||
die Begruendung so, dass sie ohne diesen Plan verstaendlich bleibt.
|
||||
|
||||
Pruefe beim Umbau ausdruecklich die Grenzfaelle und dokumentiere das Ergebnis im Kommentar:
|
||||
Was passiert, wenn der aufrufende Code auf dem mandantengebundenen Client selbst
|
||||
`$transaction` aufruft, und was passiert bei `$queryRaw`. Falls eine dieser Nutzungen mit der
|
||||
Array-Form nicht mehr traegt, ist das ein benannter Vorbehalt fuer Etappe 2 und gehoert in die
|
||||
SUMMARY — nicht stillschweigend uebergangen.
|
||||
|
||||
Lege `apps/api/scripts/rls-scratch-check.mjs` an. Das Werkzeug richtet sich eine eigene
|
||||
Wegwerf-Datenbank ein (Vorschlag: `tessera_rls_scratch`), legt darin eine kleine Tabelle mit
|
||||
Mandantenspalte samt Policy und aktiviertem `FORCE ROW LEVEL SECURITY` an, legt eine Rolle
|
||||
ohne BYPASSRLS an, befuellt zwei Mandanten mit unterscheidbaren Zeilen und misst dann die fuenf
|
||||
oben genannten Verhaltensweisen. Am Ende raeumt es die Wegwerf-Datenbank wieder ab. Es darf die
|
||||
Datenbank `tessera` weder lesen noch veraendern — der Datenbankname gehoert fest ins Werkzeug,
|
||||
nicht in eine Umgebungsvariable, damit ein Tippfehler nicht in der echten Datenbank landet.
|
||||
Verbindungsangaben kommen ueber `TESSERA_SCRATCH_ADMIN_URL`; ohne diese Variable bricht das
|
||||
Werkzeug mit einer Anleitung ab, statt eine Vorgabe zu raten.
|
||||
|
||||
Das Werkzeug meldet je Pruefung eine Zeile und beendet sich mit Rueckgabewert 1, sobald eine
|
||||
Pruefung scheitert. Es gibt kein Kennwort und keine vollstaendige Verbindungszeichenkette aus.
|
||||
|
||||
Lege `prisma-tenant.extension.spec.ts` an. Die Spec prueft ohne laufende Datenbank die **Form**
|
||||
des Aufrufs, nicht seinen mit Produktionscode erzeugten Inhalt — die Lehre aus dem
|
||||
tautologischen Test in STATE.md: ein vorgetaeuschter Client zeichnet auf, dass `$transaction`
|
||||
mit einem Feld aus zwei Eintraegen aufgerufen wird, dass der Rueckgabewert der zweite Eintrag
|
||||
ist, und dass `query` waehrend des Aufbaus dieses Feldes genau einmal beruehrt wird. Ergaenze
|
||||
eine Spec-Zusicherung, dass der Quelltext der Extension `$executeRawUnsafe` nicht mehr
|
||||
verwendet.
|
||||
</action>
|
||||
<verify>
|
||||
<automated>npm --prefix apps/api run test -- src/prisma/prisma-tenant.extension.spec.ts && TESSERA_SCRATCH_ADMIN_URL="postgresql://tessera:tessera_dev@172.19.0.2:5432/postgres" node apps/api/scripts/rls-scratch-check.mjs</automated>
|
||||
</verify>
|
||||
<done>Die Spec laeuft gruen und `rls-scratch-check.mjs` meldet alle fuenf Pruefungen bestanden, darunter ausdruecklich gleiche Backend-Kennung, gesetzter Mandantenkontext, null Fremdzeilen und null Zeilen ohne Kontext.</done>
|
||||
</task>
|
||||
|
||||
<task type="auto" tdd="true">
|
||||
<name>Aufgabe 2: Den Anmeldeweg ueber drei eng geschnittene Datenbankfunktionen mandantenfaehig machen</name>
|
||||
<files>apps/api/prisma/migrations/20260909160000_auth_lookup_functions/migration.sql, apps/api/src/prisma/auth-lookup-functions.spec.ts, apps/api/src/auth/auth.service.ts, apps/api/src/auth/auth.service.spec.ts, apps/api/scripts/rls-scratch-check.mjs</files>
|
||||
<precondition>Aufgabe 1 ist abgeschlossen — die Nachweise dieser Aufgabe verlassen sich darauf, dass `forTenant()` tatsaechlich bindet.</precondition>
|
||||
<reversibility rating="costly">Eine Migration, die dauerhafte SECURITY-DEFINER-Funktionen anlegt. Ruecknehmbar nur ueber eine Folgemigration mit DROP FUNCTION, und die Funktionen sind eine Sicherheitsflaeche — die Schnittbreite ist nachtraeglich teuer zu korrigieren.</reversibility>
|
||||
<behavior>
|
||||
- Die Benutzersuche nach Benutzername liefert unter einer Rolle ohne BYPASSRLS weiterhin genau den passenden Benutzer.
|
||||
- Ein gewoehnlicher SELECT ueber "User" liefert unter derselben Rolle null Zeilen.
|
||||
- Die Suche nach einem nicht vorhandenen Benutzernamen liefert nichts und wirft nicht.
|
||||
- Ein Benutzername in abweichender Gross-/Kleinschreibung findet denselben Benutzer wie bisher.
|
||||
- Die Token-Suche liefert unter derselben Rolle den passenden Rueckstell-Datensatz samt zugehoerigem Benutzer.
|
||||
- Nach gefundenem Benutzer laufen alle Schreibzugriffe des Anmeldewegs mandantengebunden.
|
||||
</behavior>
|
||||
<action>
|
||||
**Die Entscheidung und ihre Begruendung.** Von den drei erwogenen Wegen faellt die Wahl auf
|
||||
SECURITY-DEFINER-Funktionen. Eine zusaetzliche Policy auf `User` scheidet aus, weil eine Policy
|
||||
ein Zeilenpraedikat ist und nicht die Form der Abfrage einschraenken kann: eine Regel, die eine
|
||||
Suche nach Benutzername erlaubt, erlaubt zwangslaeufig auch das Auslesen aller Zeilen. Eine
|
||||
zweite Datenbankrolle nur fuer die Anmeldung scheidet aus, weil sie einen zweiten
|
||||
Verbindungspool und einen zweiten Prisma-Client verlangt und auf `User` ohnehin dieselbe Breite
|
||||
haette. Eine Funktion dagegen bindet die Ausnahme an eine feste Abfrage mit festem Spaltensatz,
|
||||
fester Gleichheitsbedingung und `LIMIT 1` — eine kompromittierte Abfrage kann damit einen
|
||||
einzelnen Benutzernamen erraten, aber die Tabelle nicht ausleeren. Halte diese Begruendung im
|
||||
Kopf der Migration fest.
|
||||
|
||||
**Die Migration.** Lege drei Funktionen an, jede `SECURITY DEFINER`, jede `STABLE`, jede mit
|
||||
fest angeheftetem Suchpfad auf `public, pg_temp`, jede mit `LIMIT 1`:
|
||||
|
||||
- `auth_lookup_user_by_username(text)` — Gleichheitsvergleich gegen den kleingeschriebenen
|
||||
Benutzernamen, wie es `auth.service.ts:39-41` heute tut. Liefert nur die Felder, die der
|
||||
Anmeldeweg wirklich braucht: Kennung, Benutzername, Mandant, Kennwort-Hash, LDAP-DN,
|
||||
Aktiv-Merkmal, Rolle, Anzeigename, Kennwortwechsel-Merkmal.
|
||||
- `auth_lookup_user_by_email(text)` — dieselbe Bauart fuer `requestPasswordReset`
|
||||
(`auth.service.ts:144-146`). Liefert Kennung, Mandant, E-Mail und Aktiv-Merkmal; keinen
|
||||
Kennwort-Hash, denn dieser Pfad prueft kein Kennwort.
|
||||
- `auth_lookup_reset_token(text)` — Gleichheitsvergleich gegen den Token, liefert den
|
||||
Token-Datensatz zusammen mit Benutzerkennung und Mandant des Benutzers. Das Token ist eine
|
||||
`randomUUID` und nicht erratbar.
|
||||
|
||||
Keine dieser Funktionen schreibt. Nach der Anlage jeweils saemtliche Rechte von PUBLIC
|
||||
entziehen und danach ausschliesslich `tessera_app` das Ausfuehrungsrecht erteilen. Die
|
||||
Migration muss wiederholbar sein — halte dich an das Muster aus
|
||||
`20260909130000_rls_app_role/migration.sql`, das die Existenz der Rolle prueft, bevor es
|
||||
Rechte vergibt, und mit einer verstaendlichen Anleitung scheitert statt still zu ueberspringen.
|
||||
|
||||
Der feste Suchpfad ist bei SECURITY DEFINER kein Schoenheitsfehler, sondern die eigentliche
|
||||
Absicherung: ohne ihn koennte eine untergeschobene Schema-Definition den Tabellenbezug in der
|
||||
Funktion umlenken und der Aufrufer erbte die Rechte des Eigentuemers.
|
||||
|
||||
**Die Verdrahtung.** Ersetze in `auth.service.ts` genau die drei Lesezugriffe, die vor
|
||||
bekanntem Mandanten stattfinden, durch Aufrufe dieser Funktionen. Das sind
|
||||
`validateUser` (Zeile 39), `requestPasswordReset` (Zeile 144) und `resetPassword` (Zeile 178).
|
||||
Alle uebrigen Prisma-Aufrufe der Datei arbeiten mit einer bereits bekannten Benutzerkennung
|
||||
und damit bekanntem Mandanten: stelle die Schreibzugriffe in `validateUser` (Zeilen 71 und 84),
|
||||
`requestPasswordReset` (Zeile 161) sowie `resetPassword` (Zeilen 199 und 208) auf den
|
||||
mandantengebundenen Client aus Aufgabe 1 um, gebunden an den Mandanten des soeben gefundenen
|
||||
Benutzers. Damit ist `auth.service.ts` am Ende dieser Aufgabe vollstaendig umstellungsfaehig.
|
||||
|
||||
`getMe`, `changePassword` und `adminResetPassword` suchen ueber die Benutzerkennung aus dem
|
||||
bereits ausgestellten Sitzungsnachweis — dort ist der Mandant bekannt. Sie sind damit
|
||||
gewoehnliche mandantengebundene Zugriffe und gehoeren in den Umbau der Etappe 2, nicht in diese
|
||||
Ausnahme. Fasse sie hier nicht an, sondern trage sie in Aufgabe 3 entsprechend ein.
|
||||
|
||||
**Die Nachweise.** Erweitere `rls-scratch-check.mjs` um einen zweiten Abschnitt, der die
|
||||
Migration in die Wegwerf-Datenbank einspielt, zwei Benutzer in zwei Mandanten anlegt und unter
|
||||
der Rolle ohne BYPASSRLS misst: Funktionsaufruf findet den Benutzer, gewoehnlicher SELECT auf
|
||||
`User` liefert null Zeilen, Suche nach unbekanntem Namen liefert nichts.
|
||||
|
||||
Lege `auth-lookup-functions.spec.ts` nach dem Vorbild von `rls-app-role.spec.ts` an: sie liest
|
||||
den Migrationstext und sichert die Eigenschaften, die die Schnittbreite ausmachen — angehefteter
|
||||
Suchpfad, `STABLE`, `LIMIT 1` bei allen drei Funktionen, Rechteentzug von PUBLIC vor der
|
||||
Rechtevergabe, Ausfuehrungsrecht ausschliesslich fuer `tessera_app`, kein Kennwort im Text.
|
||||
Zaehlbedingungen ueber den Migrationstext muessen Kommentarzeilen vorher herausfiltern, sonst
|
||||
zaehlt die Begruendung im Kopf der Datei als Treffer mit.
|
||||
|
||||
Erweitere `auth.service.spec.ts` um die Verhaltensfaelle oben — mit vorgetaeuschtem Client,
|
||||
ohne laufende Datenbank.
|
||||
</action>
|
||||
<verify>
|
||||
<automated>npm --prefix apps/api run test -- src/prisma/auth-lookup-functions.spec.ts src/auth/auth.service.spec.ts && TESSERA_SCRATCH_ADMIN_URL="postgresql://tessera:tessera_dev@172.19.0.2:5432/postgres" node apps/api/scripts/rls-scratch-check.mjs && npm --prefix apps/api run type-check</automated>
|
||||
</verify>
|
||||
<done>Beide Specs gruen, `type-check` sauber, und das Wegwerf-Werkzeug belegt unter der Rolle ohne BYPASSRLS: Anmeldesuche findet den Benutzer, gewoehnlicher SELECT auf "User" liefert null Zeilen.</done>
|
||||
</task>
|
||||
|
||||
<task type="auto">
|
||||
<name>Aufgabe 3: Alle 232 Datenbankzugriffe klassifizieren und die Klassifikation maschinell absichern</name>
|
||||
<files>docs/mandantentrennung-zugriffsklassifikation.md, apps/api/src/prisma/rls-access-inventory.spec.ts, docs/mandantentrennung-datenbankrolle.md, .planning/WINDOWS.md</files>
|
||||
<behavior>
|
||||
- Die Bestandsaufnahme aus dem Quelltext und die Eintraege im Dokument decken sich vollstaendig.
|
||||
- Eine neu hinzugefuegte, nicht eingetragene Fundstelle laesst die Pruefung scheitern.
|
||||
- Eine im Dokument gefuehrte, im Quelltext verschwundene Datei laesst die Pruefung scheitern.
|
||||
</behavior>
|
||||
<action>
|
||||
Lege `docs/mandantentrennung-zugriffsklassifikation.md` an — deutschsprachig, im Ton der
|
||||
bestehenden `docs/`-Anleitungen, gerichtet an dieselben Leser wie
|
||||
`mandantentrennung-datenbankrolle.md`.
|
||||
|
||||
Ermittle die Bestandsaufnahme zuerst maschinell aus dem Quelltext, damit sie nicht von Hand
|
||||
zusammengeschrieben und dabei unvollstaendig wird. Ordne dann jede Fundstelle genau einer der
|
||||
drei Klassen zu:
|
||||
|
||||
- **muss mandantengebunden werden** — beruehrt auf Rechnung genau eines Mandanten eine Tabelle
|
||||
mit `tenantId`.
|
||||
- **bewusst uebergreifend** — muss ueber Mandanten hinweg sehen. Jede solche Einstufung braucht
|
||||
einen ausgeschriebenen Grund, nicht nur das Etikett.
|
||||
- **betrifft keine mandantengebundene Tabelle** — die acht Modelle ohne `tenantId`. Achtung, hier
|
||||
ist eine Falle: `PasswordResetToken` und `GroupMembership` haben kein eigenes `tenantId`,
|
||||
tragen aber ueber einen Join auf `User` bzw. `Group` sehr wohl eine Regel. Sie gehoeren
|
||||
deshalb nicht pauschal in diese dritte Klasse — pruefe je Fundstelle und begruende die
|
||||
Einordnung.
|
||||
|
||||
Bekannte Kandidaten fuer "bewusst uebergreifend", jeweils zu pruefen und nicht ungeprueft zu
|
||||
uebernehmen: der Anmeldeweg aus Aufgabe 2, die Mandantenverwaltung selbst, der Modulkatalog,
|
||||
die plattformweit gehaltenen Ausschreibungsdaten nach D-03, die Erstanlage des Administrators
|
||||
beim ersten Start sowie die Hintergrunddienste.
|
||||
|
||||
Der Hintergrunddienst ist die klassische Falle und verdient im Dokument einen eigenen Absatz:
|
||||
ein Planer, der ueber alle Mandanten iteriert, liest voellig zu Recht uebergreifend — muss aber
|
||||
*innerhalb* der Schleife je Mandant binden. Solche Stellen sind damit **beides** und gehoeren als
|
||||
solche gekennzeichnet, sonst faellt in Etappe 3 die eine Haelfte unter den Tisch. Betroffen sind
|
||||
mindestens der AD-Abgleich, der Ausschreibungs-Digest und die Ausschreibungs-Sofortmeldung.
|
||||
|
||||
Nimm zwei belegte Befunde ausdruecklich mit auf:
|
||||
|
||||
Erstens: `req.tenantPrisma` wird von `tenant.middleware.ts:44` und `tenant.guard.ts:41` gesetzt,
|
||||
im gesamten `apps/api/src` aber von keiner Stelle gelesen. Die Verdrahtung besteht, wird aber
|
||||
nicht genutzt. Fuer Etappe 2 ist damit zu entscheiden, ob die Controller kuenftig darueber
|
||||
gehen oder ob der Weg entfaellt — halte den Befund fest, entscheide ihn hier nicht.
|
||||
|
||||
Zweitens: WINDOWS #19. `SearchProvider` und `TenderRssFeedSource` haben ein nullbares
|
||||
`tenantId`; die vorhandene Regel `tenantId = current_tenant_id()` blendet Zeilen mit leerem
|
||||
Mandanten fuer *jeden* Mandanten aus. Das sind genau die von der Administration gepflegten
|
||||
plattformweiten Eintraege. Vermerke es als benannten Blocker fuer die spaetere Etappe.
|
||||
|
||||
Gib am Ende eine Tabelle mit den Mengen je Bereich und Klasse aus, damit die folgenden Etappen
|
||||
eine Groessenordnung haben. Der aktuelle Stand je Bereich ist im Abschnitt `measured_baseline`
|
||||
dieses Plans nachgemessen hinterlegt — die Summen im Dokument muessen dazu passen.
|
||||
|
||||
Lege `rls-access-inventory.spec.ts` an. Die Spec ermittelt die Fundstellen erneut aus dem
|
||||
Quelltext und vergleicht sie gegen die im Dokument gefuehrten Eintraege. Sie scheitert, sobald
|
||||
eine Fundstelle ohne Eintrag existiert oder ein Eintrag ohne Fundstelle. Waehle die
|
||||
Vergleichsschluessel so, dass sie das Verschieben einer Zeile ueberleben — Datei und Modellname
|
||||
tragen, eine Zeilennummer nicht. Die Spec ist der Grund, warum dieses Dokument die naechsten
|
||||
beiden Etappen ueberlebt, statt nach dem ersten Umbau falsch zu werden.
|
||||
|
||||
Verlinke das neue Dokument aus `docs/mandantentrennung-datenbankrolle.md` und korrigiere dort
|
||||
die inzwischen ueberholte Zahl 182 auf den nachgemessenen Stand. Ergaenze in `WINDOWS.md`
|
||||
die Eintraege #18 und #19 um einen Hinweis auf diesen Plan und auf den in Aufgabe 1 gemessenen
|
||||
`forTenant()`-Defekt, der beim Anlegen der Eintraege noch nicht bekannt war.
|
||||
</action>
|
||||
<verify>
|
||||
<automated>npm --prefix apps/api run test -- src/prisma/rls-access-inventory.spec.ts</automated>
|
||||
</verify>
|
||||
<done>Die Bestandsaufnahme-Spec ist gruen, das Dokument fuehrt jede Fundstelle mit Klasse und — bei "bewusst uebergreifend" — mit ausgeschriebenem Grund, und die Mengentabelle je Bereich stimmt mit der nachgemessenen Verteilung ueberein.</done>
|
||||
</task>
|
||||
|
||||
<task type="checkpoint:human-verify" gate="blocking-human">
|
||||
<name>Aufgabe 4: Anmeldung lokal gegenpruefen</name>
|
||||
<action>
|
||||
Aufgabe 2 hat den Anmeldeweg umgebaut. Er laeuft weiterhin unter der bisherigen Rolle, aber er
|
||||
laeuft ueber neuen Code — deshalb ist eine echte Anmeldung im Browser noetig, bevor diese
|
||||
Etappe als fertig gilt. Ein gruener Testlauf belegt das nicht: die Anmeldung wurde in dieser
|
||||
Sitzung nie mit einem echten Browser gegen den neuen Code gesehen.
|
||||
|
||||
Beachte die Lehre aus STATE.md: ein laufender Container mit altem Abbild taeuscht Fertigstellung
|
||||
vor. Baue die Dienste lokal neu, bevor du pruefst. Und beachte den Browser-Fallstrick aus dem
|
||||
Gedaechtnis: nicht per `fetch` aus der Seite heraus messen, sondern die Anmeldung wirklich
|
||||
durchklicken.
|
||||
|
||||
Der Server 192.168.13.12 wird dabei nicht angefasst.
|
||||
</action>
|
||||
<verify>
|
||||
<human-check>
|
||||
1. Lokale Dienste mit dem neuen Stand neu bauen und starten.
|
||||
2. Auf der Anmeldeseite mit einem lokalen Benutzer anmelden — die Anmeldung gelingt und das
|
||||
Portal laedt.
|
||||
3. Abmelden und mit falschem Kennwort erneut versuchen — die Anmeldung wird abgelehnt, ohne
|
||||
zu verraten, welches Feld falsch war.
|
||||
4. Die Kennwort-vergessen-Seite aufrufen und eine Anfrage abschicken — die Seite antwortet
|
||||
wie bisher, ohne Fehler im Protokoll des API-Containers.
|
||||
</human-check>
|
||||
</verify>
|
||||
<done>Der Benutzer bestaetigt, dass Anmeldung, Fehlversuch und Kennwort-Anfrage sich unveraendert verhalten. Bei Abweichung wird diese Etappe nicht abgeschlossen, sondern Aufgabe 2 nachgebessert.</done>
|
||||
</task>
|
||||
|
||||
</tasks>
|
||||
|
||||
<threat_model>
|
||||
## Trust Boundaries
|
||||
|
||||
| Boundary | Description |
|
||||
|----------|-------------|
|
||||
| Browser -> API (Anmeldeformular) | Benutzername, E-Mail und Rueckstell-Token sind unvertraute Eingaben und erreichen die neuen Datenbankfunktionen direkt. |
|
||||
| API -> PostgreSQL (Rolle `tessera_app`) | Die kuenftige Anwendungsrolle ist der eigentliche Sicherheitsanker. Alles, was sie darf, kann ein kompromittierter Prozess auch. |
|
||||
| Mandant A -> Mandant B (innerhalb einer Datenbank) | Die Grenze ist heute rein anwendungsseitig; dieser Plan bereitet ihre Durchsetzung in der Datenbank vor. |
|
||||
| SECURITY-DEFINER-Funktion -> Tabelleneigentuemer | Innerhalb dieser Funktionen gelten die Rechte des Eigentuemers, nicht die des Aufrufers. Das ist eine bewusst geoeffnete, eng zu haltende Tuer. |
|
||||
|
||||
## STRIDE Threat Register
|
||||
|
||||
| Threat ID | Category | Component | Severity | Disposition | Mitigation Plan |
|
||||
|-----------|----------|-----------|----------|-------------|-----------------|
|
||||
| T-EOR-01 | Elevation of Privilege | `auth_lookup_*`-Funktionen (Aufgabe 2) | critical | mitigate | Suchpfad fest auf `public, pg_temp` angeheftet, damit kein untergeschobenes Schema den Tabellenbezug umlenkt; alle Rechte von PUBLIC entzogen, Ausfuehrungsrecht ausschliesslich an `tessera_app`; Funktionen sind `STABLE` und schreiben nicht. Jede Eigenschaft ist in `auth-lookup-functions.spec.ts` zugesichert. |
|
||||
| T-EOR-02 | Information Disclosure | `auth_lookup_user_by_username` / `_by_email` | high | mitigate | Fester Spaltensatz statt Sternchen, Gleichheitsbedingung statt Mustervergleich, `LIMIT 1`. Ein Aufrufer kann einen einzelnen Namen erraten, aber die Benutzertabelle nicht auslesen. Im Wegwerf-Nachweis wird ausdruecklich gemessen, dass ein gewoehnlicher SELECT auf "User" unter derselben Rolle null Zeilen liefert. |
|
||||
| T-EOR-03 | Information Disclosure | `forTenant()` fuehrt die Abfrage auf einer fremden Verbindung aus | critical | mitigate | Gemessen am 2026-09-09: `set_config` auf Backend 254999, Abfrage auf Backend 255000, Kontext dort NULL. Aufgabe 1 bindet beides an eine Verbindung; `rls-scratch-check.mjs` misst Verbindungsgleichheit und Fremdmandanten-Sichtbarkeit dauerhaft nach. |
|
||||
| T-EOR-04 | Tampering | `auth_lookup_reset_token` als Weg zur Kennwortuebernahme | high | mitigate | Nur Gleichheitsvergleich gegen ein nicht erratbares `randomUUID`-Token, eine Zeile, lesend. Alle Pruefungen auf Ablauf und Einmaligkeit sowie beide Schreibzugriffe bleiben in der Anwendung und laufen nach Aufgabe 2 mandantengebunden. |
|
||||
| T-EOR-05 | Repudiation | Klassifikationsdokument driftet vom Quelltext ab | medium | mitigate | `rls-access-inventory.spec.ts` ermittelt die Fundstellen bei jedem Testlauf neu und scheitert bei jeder Abweichung in beide Richtungen. Ohne diese Spec waere das Dokument nach dem ersten Umbau der Etappe 2 falsch, wuerde aber weiter als Landkarte gelesen. |
|
||||
| T-EOR-06 | Denial of Service | Plattformweite Zeilen mit leerem `tenantId` (WINDOWS #19) | high | accept | Ausserhalb dieser Etappe. Wird in Aufgabe 3 als benannter Blocker dokumentiert und in Etappe 3 geloest. Heute ohne Wirkung, weil der Schalter aus bleibt — die Annahme ist ausdruecklich an "Schalter bleibt aus" gebunden. |
|
||||
| T-EOR-07 | Denial of Service | Wegwerf-Werkzeug trifft die echte Datenbank | high | mitigate | Der Name der Wegwerf-Datenbank ist im Werkzeug fest verdrahtet und nicht ueber eine Umgebungsvariable steuerbar; das Werkzeug legt sie an und raeumt sie ab und beruehrt `tessera` nicht. Ohne `TESSERA_SCRATCH_ADMIN_URL` bricht es mit Anleitung ab, statt eine Vorgabe zu raten. |
|
||||
| T-EOR-SC | Tampering | Paketinstallationen | — | n/a | Dieser Plan installiert kein npm-, pip- oder cargo-Paket. Alle Bausteine stammen aus dem vorhandenen Bestand. Kein Legitimitaets-Halt noetig. |
|
||||
</threat_model>
|
||||
|
||||
<verification>
|
||||
Diese Etappe gilt als erfuellt, wenn folgendes gleichzeitig zutrifft:
|
||||
|
||||
1. `npm --prefix apps/api run test` laeuft vollstaendig gruen — die bestehende Testsuite ist durch
|
||||
den Umbau von `forTenant()` und `auth.service.ts` nicht beschaedigt worden.
|
||||
2. `npm --prefix apps/api run type-check` ist sauber.
|
||||
3. `TESSERA_SCRATCH_ADMIN_URL=... node apps/api/scripts/rls-scratch-check.mjs` meldet alle
|
||||
Pruefungen bestanden, inklusive der beiden Anmelde-Nachweise aus Aufgabe 2.
|
||||
4. `docs/mandantentrennung-zugriffsklassifikation.md` existiert und
|
||||
`rls-access-inventory.spec.ts` bestaetigt seine Vollstaendigkeit.
|
||||
5. `git diff` zeigt keine Aenderung an `DATABASE_URL` in `docker-compose.yml`,
|
||||
`docker-compose.prod.yml` oder einer `.env`. Der Schalter bleibt aus.
|
||||
6. Aufgabe 4 ist vom Benutzer bestaetigt.
|
||||
|
||||
**Wie die Rot-Vorbedingung festgestellt wurde.** Am 2026-09-09, vor jeder Aenderung:
|
||||
|
||||
- `prisma-tenant.extension.spec.ts`, `auth-lookup-functions.spec.ts`,
|
||||
`rls-access-inventory.spec.ts` und `apps/api/scripts/rls-scratch-check.mjs` existieren nicht —
|
||||
`vitest run` bricht bei einem Dateifilter ohne Treffer mit Rueckgabewert ungleich null ab.
|
||||
- Das Verhalten von Aufgabe 1 wurde gegen die laufende lokale Datenbank gemessen und ist rot:
|
||||
gleiche Verbindung `false`, Mandantenkontext der eigentlichen Abfrage `null`. Eine Zusicherung
|
||||
darauf scheitert heute.
|
||||
- `auth.service.ts:39` ruft heute `this.prisma.user.findUnique` auf; eine Zusicherung auf den Aufruf
|
||||
der Datenbankfunktion scheitert damit ebenfalls.
|
||||
- Das Verzeichnis `apps/api/prisma/migrations/20260909160000_auth_lookup_functions` existiert nicht,
|
||||
`docs/mandantentrennung-zugriffsklassifikation.md` existiert nicht.
|
||||
</verification>
|
||||
|
||||
<success_criteria>
|
||||
- `forTenant()` bindet nachweislich: gleiche Backend-Kennung, gesetzter Kontext, null Fremdzeilen.
|
||||
- Unter einer Rolle ohne BYPASSRLS ist die Anmeldung moeglich und die Benutzertabelle trotzdem nicht
|
||||
auslesbar — beides in derselben Messung belegt.
|
||||
- Alle 232 Fundstellen sind eingestuft, die "bewusst uebergreifend"-Faelle begruendet, die Mengen je
|
||||
Bereich beziffert.
|
||||
- Die Klassifikation ist maschinell gegen den Quelltext abgesichert und ueberlebt die folgenden
|
||||
Etappen.
|
||||
- `DATABASE_URL` ist unveraendert; der Server wurde nicht angefasst.
|
||||
</success_criteria>
|
||||
|
||||
<next_stages>
|
||||
## Die folgenden Etappen
|
||||
|
||||
Diese Etappe hat das Fundament gelegt und die Landkarte gezeichnet. Der eigentliche Umbau steht
|
||||
noch aus. Erwartete Zuschnitte und Groessen, zu praezisieren anhand der in Aufgabe 3 ermittelten
|
||||
Mengentabelle:
|
||||
|
||||
**Etappe 2 — Umbau der mandantengebundenen Zugriffe.** Der Hauptteil. Nach heutiger Zaehlung sind
|
||||
232 Fundstellen einzustufen; der Grossteil duerfte in diese Klasse fallen. Sinnvolle Reihenfolge ist
|
||||
nach Bereich und Groesse: `tenders` (62), `groups` (37), `ldap` (21), `dkv` (21), `user` (17),
|
||||
`module-registry` (17), `dashboard` (13), `calendar` (12), `favorites` (7), `settings` (4). Der
|
||||
Bereich `auth` (13) ist durch diese Etappe bereits erledigt, `tenant` (8) faellt weitgehend unter
|
||||
Etappe 3. Erwartet: fuenf bis acht Plaene, je Bereich einer, jeweils mit einem Nachweis, dass ein
|
||||
Fremdmandant nichts mehr sieht. Dabei ist zu entscheiden, ob die Controller kuenftig ueber das
|
||||
heute gesetzte, aber nirgends gelesene `req.tenantPrisma` gehen.
|
||||
|
||||
**Etappe 3 — Benannter Systemkontext fuer die uebergreifenden Zugriffe.** Die Stellen, die
|
||||
rechtmaessig ueber Mandanten hinweg lesen, brauchen keinen Mandantenkontext, aber eine sichtbare
|
||||
Kennzeichnung — heute sind sie von einem vergessenen Filter nicht zu unterscheiden. Betrifft die
|
||||
Hintergrunddienste mit ihrem Fan-out ueber alle Mandanten, die Mandantenverwaltung, den
|
||||
Modulkatalog, die plattformweiten Ausschreibungsdaten nach D-03 und die Erstanlage des
|
||||
Administrators. Hier gehoert auch WINDOWS #19 hinein: die beiden Regeln fuer `SearchProvider` und
|
||||
`TenderRssFeedSource` muessen plattformweite Zeilen beim Lesen ausdruecklich einschliessen,
|
||||
waehrend Schreibzugriffe weiterhin einen Mandanten verlangen. Erwartet: ein bis zwei Plaene.
|
||||
|
||||
**Etappe 4 — Scharfschalten.** Kennwort fuer `tessera_app` vergeben, `TESSERA_MIGRATE_DATABASE_URL`
|
||||
und `DATABASE_URL` umstellen, `rls-preflight.mjs` gruen, dann neu starten. Der Plan muss die
|
||||
Vorher-Pruefung und einen dokumentierten Rueckweg enthalten — beides steht bereits in
|
||||
`docs/mandantentrennung-datenbankrolle.md`, Abschnitte 4 bis 6, und ist dort nur noch um den in
|
||||
Etappe 1 gemessenen `forTenant()`-Befund zu ergaenzen. Erwartet: ein Plan mit einem blockierenden
|
||||
Halt vor der Umstellung und einem zweiten nach der ersten erfolgreichen Anmeldung unter der neuen
|
||||
Rolle.
|
||||
</next_stages>
|
||||
|
||||
<output>
|
||||
Erstelle `.planning/quick/260909-eor-anmeldeweg-mandantenfaehig-machen-und-al/260909-eor-SUMMARY.md`,
|
||||
wenn alle vier Aufgaben abgeschlossen sind. Nimm darin ausdruecklich auf:
|
||||
- die tatsaechlich gemessenen Ergebnisse von `rls-scratch-check.mjs` (nicht "bestanden", sondern die
|
||||
Zahlen),
|
||||
- die Mengentabelle je Bereich und Klasse aus Aufgabe 3 als Arbeitsvorrat fuer Etappe 2,
|
||||
- jeden in Aufgabe 1 gefundenen Vorbehalt zur Array-Form von `$transaction`.
|
||||
</output>
|
||||
Reference in New Issue
Block a user