Anmeldung — Magic Link, verifizierte Rollen, Zugänge (OP-AUTH-1 A-2…A-5)
Status: gebaut (v0.165.0); Mailversand gebunden, Anmeldung dormant bis
AUTH_SECRETgesetzt ist (§6) · Bezug:ops/OP-AUTH-1.md·docs/architektur/Billing-Zugang.md§7 ·docs/architektur/Mandantenfaehigkeit.md· Zielgruppe: IT-Dev/Architektur · IT-Ops · DevOps · CISO/Security · Anwender (§2) · True North: indirekt — die Anmeldung beantwortet keine der drei Leitfragen selbst, entscheidet aber, wer sie stellen darf und mit welcher Rolle die Antworten zugeschnitten sind.
Kernaussage. Jeder Mitarbeiter meldet sich mit seiner E-Mail und einem Magic Link an — kein Passwort. Konten legt die Verwaltung an (Zugänge-Seite) und vergibt dabei die Rolle; die Rolle ist damit verifiziert statt selbst gewählt und schließt RISK-12. Technisch: Better Auth self-host im Worker, Identität in der EU-D1, Mailversand über Cloudflare Email Sending. Die Taktano-Sitzung ist ab jetzt das Zugangs-Gate — eine bewusste, begründete Abweichung von der Golden Rule „Zero Trust vor Public" (§5).
1. Die vier Entscheidungen (PO, 2026-09-22)
| ID | Entscheidung | Warum |
|---|---|---|
| AU-2 | Die Taktano-Sitzung ist das Zugangs-Gate; Cloudflare Access weicht | Access kennt die in der Verwaltung angelegten Mitarbeiter-Adressen nicht. Zwei Anmeldungen hintereinander wären für eine Werkstatt-Schicht eine Zumutung. |
| MV-CF | Mailversand über Cloudflare Email Sending | Gleiche Plattform wie D1/R2/DO, kein Drittanbieter, kein weiteres Secret. Public Beta seit 2026-04-16. |
| UB-2 | Better Auth self-host (statt Eigenbau) | Setzt die dokumentierte Entscheidung 06-23 um; organization-Plugin steht für OP-TENANT bereit. |
| AC-1 | Konto + Rolle in der Verwaltung, Mitarbeiter-Verknüpfung optional | Buchhaltung/Leitung brauchen keinen Mitarbeiter-Stammsatz; ein Werker in der Regel schon. |
2. Wie es sich anfühlt (Anwender)
Für den Mitarbeiter sind das zwei Schritte: E-Mail eintippen, Link klicken. Kein Passwort, keins zu vergessen, keins zurückzusetzen. Die Sitzung hält 30 Tage und verlängert sich bei Nutzung — Werkstatt- Tablets sollen nicht wöchentlich neu angemeldet werden.
Sperren wirkt an drei Enden: Ein gesperrtes Konto bekommt gar keinen Link mehr (der Server prüft
aktiv, bevor er die Mail auslöst); eine bestehende Sitzung endet beim nächsten Request — die Prüfung
läuft je Anfrage, nicht nur beim Login; und die offene WebSocket-Verbindung wird aktiv geschlossen.
Ohne das Erste bekäme ein ausgeschiedener Mitarbeiter weiter Anmelde-Mails (Better Auth kennt aktiv nicht,
das ist ein Taktano-Zusatzfeld). Ohne das Dritte bliebe er am offenen Socket weiterarbeitsfähig: dessen
Fähigkeiten stehen seit OP-HIBERNATE-1 im Verbindungs-State und werden beim Verbinden einmal gesetzt —
darum meldet der Worker jede rechte-entziehende Änderung dem Durable Object (entzieheZugang, per
DO-RPC → nicht von außen erreichbar). Dasselbe gilt für eine Degradierung (z. B. Chef → Werker): auch
sie schließt die Verbindung, der Client verbindet sich neu und bekommt die neue Rolle.
Die Antwort ist bewusst unspezifisch („wenn ein Konto besteht …"): Der Server verrät nicht, ob es die Adresse gibt — sonst ließe sich von außen abfragen, wer im Betrieb arbeitet. Das gilt für Text, Laufzeit und Fehlerbild zugleich: der Versand läuft neben der Antwort (sonst wäre ein bestehendes Konto an der längeren Antwortzeit erkennbar), und ein fehlgeschlagener Versand geht ins Log statt in die Antwort — er kann per Definition nur eine existierende Adresse treffen und wäre damit selbst die Auskunft. Unterschieden wird nur, was adressunabhängig ist: ungültige Eingabe (400) und „Anmeldung bzw. Mailversand gar nicht eingerichtet" (503) — Letzteres abgelesen an der Konfiguration, nicht an einem Versand-Versuch.
3. Rollen — vergeben, nicht gewählt
| Rolle | Fähigkeiten | Sieht/darf |
|---|---|---|
| Werker | — | Terminal & Ansicht |
| Buchhaltung | kosten | Kosten-Sicht, Infra-Kosten |
| Werkstattleitung | verwaltung | Stammdaten · Konfiguration · Chronik · Zugänge |
| Inhaber/Chef | kosten · verwaltung · chef | alles, inkl. Vier-Augen-Abweichung (G-4/OP-QS-6) |
Bis v0.164 konnte sich jeder im ⚙-Menü selbst zum „Inhaber/Chef" erklären (Interim A-1.5). Das ist
vorbei: die Rolle steht am Konto, der Worker liest sie aus der Sitzung und reicht sie als
X-Taktano-Rolle an das Durable Object — client-seitig nicht fälschbar, weil der Worker jede
mitgeschickte Kopie des Headers zuerst entfernt. Damit ist RISK-12 geschlossen.
Mit derselben Änderung sind die 15 zuvor ungegateten Stammdaten-Handler nachgezogen (der
„A-1-Gap": employee.create/update, Skill-Zuweisungen, Schichten, Ausbildungsziele, Abwesenheiten,
skill.create/update, teilschritt.update/setSkillAnforderung, leistung.update/setProzess) — sie
verlangen jetzt verwaltung. Die beiden letzten kamen aus der Review zu PR #806: sie gehören zur
selben Klasse (Prozess-Katalog), standen aber nicht auf der ursprünglichen Gap-Liste.
Eine Ausnahme, bewusst:
employee.setVerfuegbarkeit(Frei melden · Pause · Urlaub) bleibt ungegatet. Die Liste inops/OP-AUTH-1.mdführte es als Stammdaten-Handler; fachlich ist es aber die operative Selbstmeldung am Terminal. Einverwaltung-Gate hätte dem Werker die Pause-Taste genommen — Funktionsverlust ohne Sicherheitsgewinn.
4. Aufbau
| Datei | Rolle |
|---|---|
server/src/auth/rollen.ts | Reiner Kern: Rolle→Fähigkeit, Normalisierung. Von Worker und DO genutzt (eine Quelle, keine Kopie). |
server/src/auth/auth.ts | Better-Auth-Instanz (Magic Link, kein Passwort, keine Selbst-Registrierung) + sitzungLesen inkl. aktiv-Prüfung. |
server/src/auth/mail.ts | Mailinhalt (rein, testbar) + Versand über das EMAIL-Binding. |
server/src/auth/konten.ts | Konten-Pflege + Bootstrap + Aussperr-Schutz (§7). |
server/src/auth/router.ts | Die vier HTTP-Routen; Enumerations-Schutz; Konten-Gate auf verwaltung. |
server/src/db/schema.ts | Drizzle als Single Source (OP-DATA-1) — vier Tabellen: user, session, account, verification; wrangler-Zwilling: migrations/0002_auth.sql. |
client/src/app/services/anmeldung.service.ts | Sitzung laden, Link anfordern, abmelden. Kein Token im Browser (httpOnly-Cookie). |
client/src/app/leitstand/pages/anmeldung.component.ts | Die Anmelde-Seite. |
client/src/app/leitstand/pages/konten.component.ts | Verwaltung → Zugänge. |
Nur drei Better-Auth-Pfade stehen offen (magic-link/verify, get-session, sign-out), alles andere
unter /api/auth/ antwortet 404. Better Auth bringt mehr Endpunkte mit, als Taktano nutzt, und jede offene
Route ist Angriffsfläche (OP-SEC-1). Insbesondere ist sign-in/magic-link nicht offen — der Weg führt
über /api/anmelden, sonst ließe sich der Enumerations-Schutz umgehen.
5. Golden-Rule-Abweichung (CISO/Security)
Die Root-CLAUDE.md verlangt: jedes Online-Deployment wird zunächst hinter Cloudflare Access von der
Öffentlichkeit abgeschottet. Mit AU-2 gilt das für Taktano nicht mehr uneingeschränkt — wie die
Rot-als-Primärfarbe-Ausnahme (OP-REDESIGN-1) ist das eine bewusste, begründete und hier dokumentierte
Abweichung, kein Versehen.
Was öffentlich wird: die SPA-Hülle (HTML/JS/CSS), /api/anmelden und /api/auth/magic-link/verify —
Letzteres zwingend, denn der Klick auf den Link aus der Mail kommt per Definition ohne Sitzung an; genau
dort entsteht sie. Geschützt ist der Pfad durch den einmal verwendbaren, 15 Minuten gültigen Token, nicht
durch das Gate. Was nicht öffentlich ist: jeder Datenpfad —
/parties/* (WebSocket), /api/* und /models/* antworten ohne gültige Sitzung mit 401 (gesperrtes
Konto: 403). Ein 401 statt 302, weil ein WebSocket einem Redirect nicht folgen kann — genau der
Fehler, an dem „Weg 1" gescheitert ist (RISK-21).
Was bleibt dadurch als Risiko: der Anmelde-Endpunkt ist von außen erreichbar (Rate-Limiting: offen, s. §8), und die Client-Bundles sind lesbar. Was besser wird: Zugriff endet an einer Liste, die der Betrieb selbst pflegt, statt an einer Access-Policy, die nur ein Cloudflare-Admin ändern kann — und jede Aktion hat einen namentlichen, verifizierten Akteur im Audit-Log (OP-AUDIT-1).
6. Aktivierung (Runbook, IT-Ops/DevOps)
Bis alle Schritte erledigt sind, ist die Anmeldung dormant: die App läuft wie bisher, die Anmelde-Seite erscheint nicht. Die Reihenfolge ist zwingend — erst anmeldefähig machen, dann Access lösen. Umgekehrt stünde der Betrieb ohne jedes Gate da.
Der Schalter ist AUTH_SECRET: solange es fehlt, bleibt alles beim Alten. Deshalb kommt es als
letztes — erst wenn Mailversand und Tabellen stehen. Ein scharfes Gate ohne funktionierenden
Mailversand sperrt alle aus (niemand bekommt einen Link).
| # | Schritt | Wer | Wo |
|---|---|---|---|
| 1 | Workers Paid Plan aktiv (Email Sending ist dort verfügbar, Abrechnung je Nachricht) | Betreiber | Cloudflare Dashboard |
| 2 | Absender-Domain onboarden (taktano.com, DKIM/DNS) | Betreiber | Cloudflare → Email Service |
| 3 | ✅ erledigt (v0.165.0) — [[send_email]]-Block und MAIL_ABSENDER (noreply@taktano.com) sind aktiv | Dev | server/wrangler.toml |
| 4 | Probe-Mail an eine echte Adresse — erst wenn sie ankommt, weiter | Betreiber | Cloudflare Dashboard |
| 5 | GitHub-Secrets AUTH_SECRET (z. B. openssl rand -base64 32) und BOOTSTRAP_EMAIL (Betreiber-Adresse) setzen | Betreiber | GitHub → Settings → Secrets → Actions |
| 6 | Deploy auslösen. Steht ohnehin ein Merge auf main an, genügt der — sonst Actions → CI → Run workflow auf main (workflow_dispatch). Der Job spiegelt die Secrets nach Cloudflare, wendet die D1-Migrationen an und deployt | Betreiber/CI | GitHub → Actions |
| 7 | Erste Anmeldung: Betreiber-Adresse eintippen → Mail → Link → angemeldet als Inhaber/Chef. Unter Verwaltung → Zugänge die Mitarbeiter anlegen | Betreiber | App |
| 8 | Rate-Limiting-Regel anlegen (RL-1, Werte s. §6a) — der Worker-seitige Deckel (RL-2) ist schon aktiv | Betreiber | Cloudflare → Security → WAF → Rate limiting rules |
| 9 | Erst jetzt: Cloudflare-Access-App für app.taktano.com lösen (Bypass oder deaktivieren) | Betreiber | Cloudflare Zero Trust |
Die Secrets gehören in die GitHub-Actions-Secrets, nicht per wrangler secret put von Hand: der
deploy-Job spiegelt sie bei jedem main-Push nach Cloudflare (Everything-as-Code, gleiches Muster wie
ASSISTENT_LLM_KEY). Ein von Hand gesetztes Secret überlebt zwar auch, steht aber nirgends geschrieben.
Ein Secret allein deployt nichts. Es wirkt erst, wenn ein Lauf es nach Cloudflare spiegelt — darum
Schritt 6. Dafür gibt es seit 09-23 workflow_dispatch: Actions → CI → Run workflow → Branch
main. Der manuelle Lauf durchläuft dieselben Gates wie ein main-Push, überspringt also nichts.
⚠️ Nicht stattdessen einen alten Lauf „Re-run" — das ist die Falle, in die wir am 2026-09-23 selbst getappt sind (RISK-26): Ein Re-Run ist an den Commit seines Laufs gebunden, ein Dispatch an einen Ref. Der Re-Run eines Laufs von vor zwei Merges deployt den Stand von damals und nimmt jeden neueren Fix wieder aus der Produktion. Re-Run nur, um denselben Stand zu wiederholen (Flake).
D1-Migrationen laufen seit v0.165.0 automatisch (wrangler d1 migrations apply taktano-audit --remote,
vor dem Deploy, bewusst blockierend). Davor wurden sie von Hand angewandt — ein Schritt, den man beim
nächsten Mal vergisst und der die App dann ohne ihre Tabellen dastehen ließe.
6a. Zwei Schichten gegen Missbrauch von /api/anmelden (RISK-23)
Der Endpunkt ist seit AU-2 öffentlich erreichbar. Jede Anfrage zu einem bestehenden, aktiven Konto löst eine Mail aus — und jede Mail kostet Geld. Zwei Schichten, die verschiedene Dinge tun:
| RL-1 — Cloudflare-Regel | RL-2 — Deckel im Worker | |
|---|---|---|
| zählt je | Client-IP | Konto |
| wirkt | am Edge, bevor der Worker läuft | im Worker, vor dem Versand |
| begrenzt | Anfrage-Volumen | Mail-Kosten und Postfach-Fluten |
| Stand | manuell anzulegen (s. u.) | gebaut |
Warum beides. Die Cloudflare-Regel sieht die Adresse gar nicht — Request-Body-Felder gibt es im Rate Limiting erst ab Enterprise. Ein verteilter Angriff mit vielen IPs geht deshalb an ihr vorbei, trifft aber weiterhin dieselben Postfächer. Umgekehrt spart nur die Edge-Regel Worker-Aufrufe. Keine der beiden ersetzt die andere.
RL-1 — die Regel (Dashboard → taktano.com → Security → WAF → Rate limiting rules → Create rule):
| Feld | Wert |
|---|---|
| Rule name | anmelden-deckel |
| Expression | (http.request.uri.path eq "/api/anmelden") |
| Characteristics | IP |
| Period | 10 s · Requests per period |
| Action | Block, Duration 10 s |
Die Werte sind vom Zonen-Plan diktiert, nicht gewählt: taktano.com läuft auf Free, dort gibt es
genau eine Regel, Zählfenster und Sperrdauer sind fest 10 s, und im Ausdruck steht nur der
Pfad zur Verfügung (keine Methode, keine Source-IP). Auf Pro wären 1-Minuten-Fenster und bis zu
1 Stunde Sperre möglich — die spürbar bessere Variante, falls die Zone später hochgestuft wird.
Warum 20 und nicht 5: Cloudflare zählt je Client-IP, und die Werkstatt-Tablets teilen sich hinter dem NAT eine öffentliche IP. Bei einer scharfen Schwelle sperrt ein Schichtbeginn die halbe Belegschaft aus. 20 in 10 s ≙ 2 Anfragen/s dauerhaft — das übersteht jeder Schichtbeginn und stoppt trotzdem Skript-Missbrauch, der in anderen Größenordnungen läuft.
RL-2 — der Deckel je Konto (server/src/auth/deckel.ts): höchstens 3 Magic-Link-Mails je Konto
und Stunde. Drei, weil die Sitzung 30 Tage hält — einer ist der Normalfall, ein zweiter falls die Mail
hängt, der dritte ist Reserve. Prüfen und Zählen passieren in einer D1-Anweisung (sonst kämen zwei
gleichzeitige Anfragen beide durch). Der Zähler hängt an der Konto-Id, nicht an der Adresse: kein
zweiter Ort mit PII, und ON DELETE CASCADE räumt ihn beim Löschen des Kontos mit ab.
Der Deckel ist still. Ein gedeckelter Versuch bekommt dieselbe Antwort wie ein gesendeter. Anders ginge es nicht: nur existierende Konten erreichen den Deckel, eine Sonderantwort wäre also selbst die Auskunft „dieses Konto gibt es" — genau der Enumerations-Kanal aus §2. Der Betreiber sieht den Fall im Log (
auth.deckel.erreicht), der Anfragende nicht.
Was offen bleibt (ehrlich). Mit beiden Schichten ist der schlimmste Fall 3 Mails je Konto und Zählfenster — bei 30 Mitarbeitern also 90 Mails je Fenster, unabhängig davon, wie viele IPs ein Angreifer hat.
Wichtig, weil leicht zu überlesen: Das gilt je Zählfenster, nicht für jedes beliebige gleitende 60-Minuten-Intervall. Das Fenster ist ein Fixed Window — wer kurz vor dem Umschlagen sein Kontingent ausschöpft und direkt danach erneut, bekommt in kurzer Folge bis zu 6 Mails auf ein Konto. Das ist die bekannte Schwäche dieser Zählweise, bewusst in Kauf genommen (eine Zeile je Konto statt eine je Versuch, also konstante Größe ohne Aufräum-Job) — aber es gehört genannt statt weggerundet.
Ein bezifferbarer, kleiner Rahmen also statt eines offenen; null ist ohne Enterprise-Features oder ein CAPTCHA vor dem Formular nicht zu haben.
Rollback: Access-App wieder scharf schalten und AUTH_SECRET löschen — GitHub-Secret entfernen und
einen Lauf auslösen (Dispatch auf main), damit der deploy-Job das gespiegelte Worker-Secret entfernt;
sonst lebt der Wert im Worker weiter. Danach ist die Anmeldung wieder dormant und die App verhält sich wie
vor v0.165.0. Die Konten in der D1 bleiben erhalten.
AUTH_SECRET rotieren (nach Verdacht auf Kompromittierung, oder turnusmäßig): neuen Wert als
GitHub-Secret setzen, Dispatch auf main — fertig. Alle bestehenden Sitzungen werden dadurch ungültig,
jeder meldet sich einmal neu an. Das ist der gewollte Effekt, aber kein Vorgang für den Schichtbeginn.
6.1 „Keine Verbindung zum Server" — Ursache ablesen statt raten
Das rote Banner nennt seit v0.169.3 die gemessene Ursache des letzten Verbindungsversuchs; der Link
„Diagnose öffnen" führt auf /api/diagnose. Derselbe Link steht inzwischen in jeder Server-/Verbindungs-
Fehleranzeige (Offline-Banner, Anmelde-Seite, Abmelden, Zugänge, Feedback — Baustein app-diagnose-link). Dort steht für Angemeldete liveVerbindung:
| Befund | Bedeutung | Nächster Schritt |
|---|---|---|
| Banner: „Anmeldung nicht gültig — HTTP 401" | Sitzung fehlt/abgelaufen, Token nicht erhalten | neu anmelden |
Banner: „Server antwortet nicht rechtzeitig" + liveVerbindung.schnellGenugFuerWs: false | der Live-Server (Durable Object) braucht länger als die 4 s, die der Browser dem Aufbau gibt | Server-Seite: dauerMs/stateKb/verbindungen prüfen, Worker-Logs |
liveVerbindung.ok: false | DO antwortet gar nicht (Fehler oder > 15 s) | Cloudflare-Dashboard → Workers & Pages → taktano → Logs (Workers Logs, seit v0.169.4): Aufrufe mit Ergebnis ≠ ok zeigen den Abbruch-Grund |
Banner: „Server weist die Verbindung ab" bei schnellem liveVerbindung | Gate lehnt den Aufbau ab | Header X-Taktano-Auth-Grund in den Entwicklertools |
dauerMs misst der Worker (Wanduhr) um einen RPC, der den DO aufweckt und die Rohgrößen des gespeicherten
Stands liest (auftraege, teilschritte, historieTage, leistungenAktiv, stateKb …) — seit v0.169.5 ohne
den teuren Stand-Aufbau, damit die Diagnose auch bei CPU-Überlast antwortet. ok:false heißt dann: der DO ist
blockiert (Workers Logs: „exceeded its CPU time limit“ → RISK-28). Ausgegeben werden nur Zahlen, keine Inhalte.
7. Zwei Sicherheitsnetze, die leicht zu übersehen sind
- Aussperr-Schutz: Das letzte aktive Konto mit der Rolle Inhaber/Chef lässt sich weder degradieren noch sperren noch löschen. Ohne diese Regel kann ein Betrieb sich mit einem Klick aus der eigenen Verwaltung aussperren — genau das Szenario, wegen dem der A-1-Gap seinerzeit offen blieb.
- Bootstrap: Konten anlegen darf nur, wer
verwaltunghat; angemeldet sein kann nur, wer ein Konto hat. Aufgelöst wird das Henne-Ei überBOOTSTRAP_EMAIL— wirksam genau dann, wenn die Tabelle komplett leer ist. Existiert auch nur ein Konto, passiert nichts mehr; die Variable kann also kein bestehendes Konto hochstufen und keinen zweiten Chef einschleusen.
8. Offen
| Punkt | Warum offen |
|---|---|
| Datenresidenz von Cloudflare Email Sending | Für Email Service ist keine EU-Residenz-Zusage belegt. E-Mail-Adressen sind PII → vor produktivem Kundenbetrieb prüfen (Compliance C-20, RISK-22). Die Identitätsdaten selbst liegen in der EU-D1 (weur). |
Cloudflare-Regel RL-1 auf /api/anmelden | Der Worker-seitige Deckel RL-2 ist gebaut (s. §6a) und mindert RISK-23; offen ist nur noch die Edge-Regel, die der Betreiber im Dashboard anlegt (Werte in §6a). Sie ersetzt RL-2 nicht — sie begrenzt das Anfrage-Volumen je IP und spart Worker-Aufrufe, sieht die Adresse aber gar nicht. |
| Beta-Status Email Sending | Public Beta, kein GA-SLA (RISK-24) — seit der Aktivierung der einzige Weg herein. Rückfall bleibt die Access-App (Rollback §6), solange sie nicht gelöst ist. |
| Audit-Einträge für Konten-Änderungen | Anlegen/Rolle/Sperren wird geloggt, aber noch nicht als Audit-Event in D1 geschrieben (OP-AUDIT-1). Naheliegender Folge-Slice. |
| Mandanten (OP-TENANT) | Das organization-Plugin ist bewusst nicht aktiviert — ein Mandant, keine Seats (YAGNI). Es ist die vorgesehene Naht, wenn OP-TENANT kommt. |
| JWT/JWKS | Nicht gebaut. Die Sitzung wird je Request gegen D1 geprüft. Ein JWT/JWKS-Pfad (Verifikation ohne D1-Roundtrip) ist die vorgesehene Naht für A-3/OP-TENANT — das jwt-Plugin und seine jwks-Tabelle kommen mit dem Feature, das sie braucht, nicht auf Vorrat (Einfachheits-Prinzip). |
↩ Zurück zur Doku-Landkarte · Lesepfade · Register (alle OPs)