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
35 KiB
phase, plan, type, wave, depends_on, files_modified, autonomous, requirements, estimate, must_haves
| phase | plan | type | wave | depends_on | files_modified | autonomous | requirements | estimate | must_haves | ||||||||||||||||||||||||||||||||||||||||||||
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
| quick-260909-eor | 01 | execute | 1 |
|
false |
|
|
|
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.
<execution_context>
@/.claude/gsd-core/workflows/execute-plan.md
@/.claude/gsd-core/templates/summary.md
</execution_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:PasswordResetTokenundGroupMembershiptragen dennoch RLS — ueber einen Unterabfrage-Join aufUserbzw.Group(20260618112133_rls_policies/migration.sql:18-21). "OhnetenantId" ist also nicht deckungsgleich mit "ohne Regel". - 2 Modelle mit nullbarem
tenantId:SearchProvider(schema.prisma:218) undTenderRssFeedSource(schema.prisma:579) — das ist WINDOWS #19. - 16
CREATE POLICYin20260909140000_rls_remaining_tenant_tables; zusammen mit den frueheren Migrationen 20 abgedeckte Tabellen. - Lokale Datenbank erreichbar unter
172.19.0.2:5432, Rolletessera/tessera_dev(Containertessera-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>
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.
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
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.
Aufgabe 2: Den Anmeldeweg ueber drei eng geschnittene Datenbankfunktionen mandantenfaehig machen
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
Aufgabe 1 ist abgeschlossen — die Nachweise dieser Aufgabe verlassen sich darauf, dass `forTenant()` tatsaechlich bindet.
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.
- 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.
**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.
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
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.
Aufgabe 3: Alle 232 Datenbankzugriffe klassifizieren und die Klassifikation maschinell absichern
docs/mandantentrennung-zugriffsklassifikation.md, apps/api/src/prisma/rls-access-inventory.spec.ts, docs/mandantentrennung-datenbankrolle.md, .planning/WINDOWS.md
- 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.
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.
npm --prefix apps/api run test -- src/prisma/rls-access-inventory.spec.ts
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.
Aufgabe 4: Anmeldung lokal gegenpruefen
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.
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.
Der Benutzer bestaetigt, dass Anmeldung, Fehlversuch und Kennwort-Anfrage sich unveraendert verhalten. Bei Abweichung wird diese Etappe nicht abgeschlossen, sondern Aufgabe 2 nachgebessert.
<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> |
npm --prefix apps/api run testlaeuft vollstaendig gruen — die bestehende Testsuite ist durch den Umbau vonforTenant()undauth.service.tsnicht beschaedigt worden.npm --prefix apps/api run type-checkist sauber.TESSERA_SCRATCH_ADMIN_URL=... node apps/api/scripts/rls-scratch-check.mjsmeldet alle Pruefungen bestanden, inklusive der beiden Anmelde-Nachweise aus Aufgabe 2.docs/mandantentrennung-zugriffsklassifikation.mdexistiert undrls-access-inventory.spec.tsbestaetigt seine Vollstaendigkeit.git diffzeigt keine Aenderung anDATABASE_URLindocker-compose.yml,docker-compose.prod.ymloder einer.env. Der Schalter bleibt aus.- 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.tsundapps/api/scripts/rls-scratch-check.mjsexistieren nicht —vitest runbricht 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 Abfragenull. Eine Zusicherung darauf scheitert heute. auth.service.ts:39ruft heutethis.prisma.user.findUniqueauf; eine Zusicherung auf den Aufruf der Datenbankfunktion scheitert damit ebenfalls.- Das Verzeichnis
apps/api/prisma/migrations/20260909160000_auth_lookup_functionsexistiert nicht,docs/mandantentrennung-zugriffsklassifikation.mdexistiert nicht.
<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_URList 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>