Kunden-Self-Service-Portal (persistentes Kunden-Login) — Design (MED-D-279, MED-OP-PORTAL-1)
Kurz (Kernaussage zuerst): Ein persistentes, authentifiziertes Kundenportal, in dem die Kontaktperson eines Mandanten sich jederzeit einloggt, ihren eigenen Stand sieht („was ist offen / eingereicht / freigegeben?") und proaktiv Info-Aktualisierungen einreicht. Die Einreichung ist „submitted", nicht der tatsächliche Datenstand — sie wird zum Vorschlag und erst nach feldweiser Freigabe durch einen Medidentas-Berater (4-Augen-Prinzip) in die Stammdaten übernommen (versioniert, Quelle
self_service). Das ist kein neuer Datenpfad: die komplette submitted-vs-actual-/4-Augen-/Übernahme- Maschinerie existiert bereits (domain/feld-sichtung.ts,…/uebernahme-vorschlag→…/uebernehmen, VersionedField). Neu ist allein eine persistente Identitäts- und Übersichts-Schicht darüber. Auth-Default: passwortlos / Magic-Link (+ optional Passkey) — kein Passwort-Hash at rest (G-5/G-6). Golden Rule & DSGVO: eine neue extern-authentifizierte Fläche → dokumentierte, begründete Zero-Trust-Ausnahme + DSGVO/AVV-Freigabe vor Go-Live (MED-KB-13, RISK-33).
True North: beantwortet #1 „Wo steht der Kunde?" (Kunde sieht/pflegt seinen Stand selbst, entlastet das Backoffice) und #3 „vollständig & rechtssicher dokumentiert?" (jede Einreichung/Freigabe append-only auditiert, 4-Augen erzwungen). Zielgruppen dieses Docs: IT-Dev/Architektur (Reuse-Map, Datenmodell, Slices) · CISO/Security (Auth, Zero-Trust, Angriffsfläche) · Business/Management (Nutzen, Compliance, Fahrplan).
1. Warum (Nutzen) & Abgrenzung
| Frage | Heute (einladungsbasiert) | Mit Portal (persistent) |
|---|---|---|
| Kunde aktualisiert Infos | nur wenn der Berater einen Token-Link schickt (Einmal-Strecke) | jederzeit selbst, ohne Anstoß durch den Berater |
| Kunde sieht seinen Stand | nein (nur das eine Formular hinter dem Link) | Dashboard: offen / eingereicht / freigegeben |
| submitted ≠ tatsächlich | ✅ bereits so (Vorschlag → Übernahme) | ✅ unverändert (gleiche Maschine) |
| 4-Augen-Freigabe | ✅ bereits so (feld-sichtung) | ✅ unverändert (gleiche Queue) |
| Identität | anonymer Token je Strecke | persistentes Konto (E-Mail, an Mandant gebunden) |
Abgrenzung: Das Portal ersetzt nicht die vier öffentlichen Kundenstrecken (Onboarding/Formular/ Fragebogen/Lead) — diese bleiben für den Erstkontakt/nicht-eingeloggten Fall. Das Portal ist die wiederkehrende, eingeloggte Sicht für bestehende Mandanten.
2. Reuse-Map (G-1 integrate-before-build · G-2 eine Wahrheit)
Der Kern ist schon gebaut — das Portal ist eine dünne Schicht darüber. Nichts davon wird nachgebaut:
| Baustein | Vorhanden als | Portal nutzt es für |
|---|---|---|
| submitted ≠ actual | Einreichung → …/uebernahme-vorschlag (Vorschlag) ≠ Stammdaten | Kunden-Einreichung landet als Vorschlag |
| 4-Augen feldweise | domain/feld-sichtung.ts (akzeptiert/abgelehnt + Grund), …/feld-sichten, …/sichtung-abschliessen | Berater-Freigabe je Feld |
| Korrektur-Schleife | abgelehnte Felder wieder geöffnet, akzeptierte gesperrt (MED-D-89) | Kunde bessert nur Abgelehntes nach |
| Versionierte Übernahme + Herkunft | VersionedField, StammdatenHerkunft.quelle (StammdatenQuelle-Enum), append-only Audit (G-4) | Übernahme mit bestehender Quelle self_service (kein neuer Enum-Wert; das Portal ist derselbe „Kunde über den Aktualisierungs-Link"-Pfad) |
| Unguessbare Tokens + Ablauf | Ausfuellformular.token/ablaufAm, Onboarding-Einladung | Magic-Link = Token mit TTL (dieselbe Machart) |
| Bot-Schutz | auth/turnstile.ts (Cloudflare Turnstile) | Login-/Anforderungs-Formular gegen Bots |
| Scope-Guard „nur eigenes" | ladeDokScope (fremder Mandant → 404, kein Existenz-Leak) | Kunde sieht/ändert nur den eigenen Mandanten |
| Mandant-Kontakt | Mandant.email (R1-F12), Kontaktpersonen | Konto-Bindung (E-Mail = Identität) |
Bewusst NEU (die eigentliche Arbeit): persistente Kunden-Identität + Session + Dashboard + proaktiver Einreichungs-Einstieg. Alles andere = Verdrahtung an Bestehendes.
3. Datenmodell (neu, minimal — G-6 Datensparsamkeit)
portal_konto— persistente Kunden-Identität. Felder:id·mandant_id(FK, Scope) ·email(Identität; kein Passwort-Hash) ·status(eingeladen/aktiv/gesperrt) ·erstellt_am·letzter_login_am. Kein zusätzliches PII über die ohnehin vorhandene Kontakt-E-Mail hinaus (G-6).portal_anmelde_token— Magic-Link:token_hash(Digest/HMAC des Tokens, nie der Rohwert — der Klartext-Token steht nur im Link, at rest liegt nur der Hash; ein DB-Leak ist damit nicht replaybar) ·konto_id·ablauf_am(kurze TTL, z. B. 15 min) ·verbraucht_am(Einmalgebrauch). AnalogAusfuellformular.tokenin der Ausgabe, aber gehasht gespeichert (Bearer-Credential).portal_session— nach erfolgreichem Magic-Link:session_id_hash(Digest des Session-Cookies, nicht der Rohwert) ·konto_id·erstellt_am·letzte_aktivitaet_am· zwei getrennte Ablauf- Grenzen: Idle-TTL (relativ zuletzte_aktivitaet_am, z. B. 30 min) und Absolut-TTL (relativ zuerstellt_am, z. B. 12 h) — beide werden bei jeder Anfrage geprüft ·widerrufen_am(Logout/Sperre). Server-seitig, kein JWT-at-rest nötig.- Optional Passkey (WebAuthn) — späterer Slice:
portal_passkey(credential_id,public_key) für wiederkehrende Logins ohne Mail-Runde. Kein Secret at rest (nur Public Key).
Derived, nie gespeichert (G-2): „was ist offen/eingereicht/freigegeben" wird abgeleitet aus den bestehenden Dokument-/Einreichungs-/Sichtungs-Zuständen — kein neuer Statusspeicher.
4. Auth — passwortlos / Magic-Link (Empfehlung) vs. Alternativen
| Option | Portal-Erlebnis | Sicherheit / Compliance | Empfehlung |
|---|---|---|---|
| Passwortlos / Magic-Link | Konto, „jederzeit per Mail-Link rein" (+ optional Passkey) | kein Passwort-Hash at rest, kleine Angriffsfläche, DSGVO-arm, nutzt bestehende Token-Infra | ✅ Default |
| Passwort-Login | klassisch vertraut | Credential-at-rest (Hash), Reset-Flows, Breach-/Reuse-Risiko, mehr Härtung | begründungspflichtig |
| OIDC (Kunde bringt Google/MS) | kein Medidentas-Secret | externe IdP-Abhängigkeit, für KMU-Kontakte oft unpassend | Nische |
Härtung (verbindlich, alle Optionen): Rate-Limiting je IP+Konto, keine Konto-Enumeration (immer
„falls ein Konto existiert, wurde eine Mail gesendet"), Turnstile am Login, Magic-Link Einmalgebrauch +
kurze TTL, Session-Cookie HttpOnly/Secure/SameSite, absolute + Idle-Expiry, Logout=Session-Widerruf.
Mail-Versand hängt am noch offenen realen Mail-Seam OP-DM-8 (heute Fake) — bis dahin ist das Portal
dormant/flag-gated.
5. RBAC & Isolation (strikt getrennt von Staff)
- Zwei getrennte Prinzipale: Staff =
Akteurüber Cloudflare Access (unverändert, Staff-only) · Kunde =portal_kontoüber eigene Session-Cookies (getrennter Realm, andere Middleware). - Kunde sieht ausschließlich den eigenen Mandanten — jeder Portal-Endpunkt trägt den
ladeDokScope- artigen Guard (fremder Mandant → 404, kein Existenz-Leak). Ein Portal-Konto kann nie eine Staff-Route erreichen und umgekehrt. - Vorschlagend, nicht ausführend: der Kunde kann nur einreichen (Vorschlag). Die Übernahme in die Stammdaten bleibt ausschließlich beim Berater (4-Augen) — der Kunde hat keinen Schreibzugriff auf den tatsächlichen Datenstand.
6. Compliance — proaktiv geflaggt (verbindlich, DE/AT/CH)
- Golden Rule „Zero Trust vor Public" (RISK-33): Das Portal ist eine neue öffentlich-authentifizierte Fläche. Es wird als bewusste, begründete Ausnahme dokumentiert (wie die vier Kundenstrecken) — mit Rate-Limiting, No-Enumeration, Turnstile, Token-TTL, Session-Härtung. Nicht offen als Default; Go-Live erst nach Sign-off.
- DSGVO/revDSG (MED-KB-13): persistente Kundenkonten = neue Verarbeitung → Rechtsgrundlage
(Art. 6(1)(b) Vertragserfüllung), Betroffenenrechte (Auskunft/Berichtigung/Löschung/Übertragbarkeit),
AVV mit Mail-/Auth-Provider. EU-/CH-Residenz ist vor Go-Live nachzuweisen (noch keine belegte
Kontrolle):
server/wrangler.tomlträgt nur die D1-ID,--location weursteht bisher nur in Kommentar/ Runbook — Worker-Placement, D1-Region, Cloudflare-Account-Einstellungen und die realen Mail-/Auth-Provider- Bedingungen sind als Deployment-Evidenz zu belegen (S5-Gate). Medidentas = Verantwortlicher. - G-5/G-6 Datensparsamkeit: Konto = E-Mail + Mandant-Link, kein zusätzliches PII; Magic-Link vermeidet Secret-at-rest; Login-/Session-Events PII-arm auditiert (nur IDs/Enums).
- G-4 Audit: Portal-Login, Einreichung, Berater-Freigabe/Ablehnung append-only auditiert (Actor = Portal-Konto bzw. Berater).
- Aufbewahrung: Konto-/Session-Daten mit definierter Retention; Session-Widerruf bei Mandant-Archivierung.
7. Slice-Plan (MED-OP-PORTAL-1)
| Slice | Inhalt | Risiko / Gate |
|---|---|---|
| S0 — Design (dieser PR) | dieses Doc + Decision-Log (MED-D-279) + Risikoregister (RISK-33) + Kunden-Besprechungspunkt (MED-KB-13) + Lastenheft/Feature-Liste-Stub | keins (Doku) |
| S1 — Fundament | Datenmodell portal_konto/portal_anmelde_token/portal_session (Token/Session-ID nur als Hash at rest; Session mit Idle- + Absolut-TTL) + Migration + Repo + Domänen-Kern (Token-Ausgabe/-Prüfung + atomarer Claim wie MED-OP-TIME-5, Session — reine Funktionen) + Vitest. Dormant, keine public Route. | niedrig (nichts exponiert) |
S2 — Auth ✅ gebaut (0.125.0, MED-D-283) | Passwortlose Magic-Link-Routen (No-Enumeration, atomarer Einmal-Claim, HttpOnly/Secure/SameSite-Cookie mit Idle+Absolut-TTL) + Staff-Route „Portal-Login anlegen". Endpunkte: POST /oeffentlich/portal/anmelden · GET /oeffentlich/portal/login?token= · GET /oeffentlich/portal/ich · POST /oeffentlich/portal/abmelden · POST /api/mandanten/:id/portal-konto (Staff). Flag-gated: dormant, bis PORTAL_AKTIV=true; PORTAL_TEST_MODE=true gibt den Magic-Link in der Antwort zurück (Test/Demo, solange kein echter Mailversand OP-DM-8). Auth-Mechanismus per Nutzer bestätigt (MED-KB-13 (a): passwortlos, zum Testen). +9 Vitest (inkl. CodeRabbit-#320-Härtung: Login-Atomarität, Sperr-Check, istEmail). | Gate erst bei S5-Aktivierung: DSGVO/AVV + Rate-Limiting/Turnstile (RISK-33) + realer Mailversand (OP-DM-8) |
| S3 — Dashboard | Read-only „offen/eingereicht/freigegeben" je eigenem Mandanten (reuse bestehender abgeleiteter Endpunkte) | niedrig |
| S4 — Proaktive Einreichung | Kunde öffnet „Stammdaten aktualisieren" → Vorschlag über bestehende feld-sichtung/uebernahme (Quelle self_service, bestehender Enum-Wert) | mittel (schreibt Vorschlag, nicht Stammdaten) |
| S5 — Go-Live | DSGVO/AVV-Freigabe, Zero-Trust-Ausnahme dokumentiert, Rate-Limits verifiziert, Mail live → aktivieren; optional Passkey | Gate: Kundenfreigabe |
Reihenfolge-Logik: S1 ist komplett ungefährlich (nur Datenmodell + pure Funktionen, dormant) → sofort baubar. Die öffentlich exponierten Slices (S2/S5) hängen an MED-KB-13 (Auth-Mechanismus + DSGVO/AVV- Freigabe durch den Kunden) und am realen Mail-Versand (OP-DM-8).
8. Offene Entscheidungen
- MED-KB-13 (Kundenfreigabe, vor S2/S5): (a) Auth-Mechanismus final (Default passwortlos/Magic-Link; Passwort nur auf ausdrücklichen Wunsch), (b) DSGVO-Rechtsgrundlage + AVV mit Mail-/Auth-Provider, (c) mehrere Kontaktpersonen je Mandant mit eigenem Login? (d) welche Stammdatenfelder darf der Kunde proaktiv aktualisieren?
- OP-DM-8 (Mail-Versand): realer E-Mail-Seam — Voraussetzung für Magic-Link live.
- Passkey (WebAuthn): optionaler Zusatz-Faktor für wiederkehrende Logins — nach S3.
Nächster Schritt: nach S0-Merge S1 (Fundament, dormant) bauen — risikofrei, ohne öffentliche Fläche.