Zum Hauptinhalt springen

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_SECRET gesetzt 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)​

IDEntscheidungWarum
AU-2Die Taktano-Sitzung ist das Zugangs-Gate; Cloudflare Access weichtAccess kennt die in der Verwaltung angelegten Mitarbeiter-Adressen nicht. Zwei Anmeldungen hintereinander wären für eine Werkstatt-Schicht eine Zumutung.
MV-CFMailversand über Cloudflare Email SendingGleiche Plattform wie D1/R2/DO, kein Drittanbieter, kein weiteres Secret. Public Beta seit 2026-04-16.
UB-2Better Auth self-host (statt Eigenbau)Setzt die dokumentierte Entscheidung 06-23 um; organization-Plugin steht für OP-TENANT bereit.
AC-1Konto + Rolle in der Verwaltung, Mitarbeiter-Verknüpfung optionalBuchhaltung/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​

RolleFähigkeitenSieht/darf
Werker—Terminal & Ansicht
BuchhaltungkostenKosten-Sicht, Infra-Kosten
WerkstattleitungverwaltungStammdaten · Konfiguration · Chronik · Zugänge
Inhaber/Chefkosten · verwaltung · chefalles, 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 in ops/OP-AUTH-1.md führte es als Stammdaten-Handler; fachlich ist es aber die operative Selbstmeldung am Terminal. Ein verwaltung-Gate hätte dem Werker die Pause-Taste genommen — Funktionsverlust ohne Sicherheitsgewinn.

4. Aufbau​

DateiRolle
server/src/auth/rollen.tsReiner Kern: Rolle→Fähigkeit, Normalisierung. Von Worker und DO genutzt (eine Quelle, keine Kopie).
server/src/auth/auth.tsBetter-Auth-Instanz (Magic Link, kein Passwort, keine Selbst-Registrierung) + sitzungLesen inkl. aktiv-Prüfung.
server/src/auth/mail.tsMailinhalt (rein, testbar) + Versand über das EMAIL-Binding.
server/src/auth/konten.tsKonten-Pflege + Bootstrap + Aussperr-Schutz (§7).
server/src/auth/router.tsDie vier HTTP-Routen; Enumerations-Schutz; Konten-Gate auf verwaltung.
server/src/db/schema.tsDrizzle als Single Source (OP-DATA-1) — vier Tabellen: user, session, account, verification; wrangler-Zwilling: migrations/0002_auth.sql.
client/src/app/services/anmeldung.service.tsSitzung laden, Link anfordern, abmelden. Kein Token im Browser (httpOnly-Cookie).
client/src/app/leitstand/pages/anmeldung.component.tsDie Anmelde-Seite.
client/src/app/leitstand/pages/konten.component.tsVerwaltung → 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).

#SchrittWerWo
1Workers Paid Plan aktiv (Email Sending ist dort verfügbar, Abrechnung je Nachricht)BetreiberCloudflare Dashboard
2Absender-Domain onboarden (taktano.com, DKIM/DNS)BetreiberCloudflare → Email Service
3✅ erledigt (v0.165.0) — [[send_email]]-Block und MAIL_ABSENDER (noreply@taktano.com) sind aktivDevserver/wrangler.toml
4Probe-Mail an eine echte Adresse — erst wenn sie ankommt, weiterBetreiberCloudflare Dashboard
5GitHub-Secrets AUTH_SECRET (z. B. openssl rand -base64 32) und BOOTSTRAP_EMAIL (Betreiber-Adresse) setzenBetreiberGitHub → Settings → Secrets → Actions
6Deploy 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 deploytBetreiber/CIGitHub → Actions
7Erste Anmeldung: Betreiber-Adresse eintippen → Mail → Link → angemeldet als Inhaber/Chef. Unter Verwaltung → Zugänge die Mitarbeiter anlegenBetreiberApp
8Rate-Limiting-Regel anlegen (RL-1, Werte s. §6a) — der Worker-seitige Deckel (RL-2) ist schon aktivBetreiberCloudflare → Security → WAF → Rate limiting rules
9Erst jetzt: Cloudflare-Access-App für app.taktano.com lösen (Bypass oder deaktivieren)BetreiberCloudflare 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-RegelRL-2 — Deckel im Worker
zählt jeClient-IPKonto
wirktam Edge, bevor der Worker läuftim Worker, vor dem Versand
begrenztAnfrage-VolumenMail-Kosten und Postfach-Fluten
Standmanuell 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):

FeldWert
Rule nameanmelden-deckel
Expression(http.request.uri.path eq "/api/anmelden")
CharacteristicsIP
Period10 s · Requests per period
ActionBlock, 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:

BefundBedeutungNächster Schritt
Banner: „Anmeldung nicht gültig — HTTP 401"Sitzung fehlt/abgelaufen, Token nicht erhaltenneu anmelden
Banner: „Server antwortet nicht rechtzeitig" + liveVerbindung.schnellGenugFuerWs: falseder Live-Server (Durable Object) braucht länger als die 4 s, die der Browser dem Aufbau gibtServer-Seite: dauerMs/stateKb/verbindungen prüfen, Worker-Logs
liveVerbindung.ok: falseDO 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 liveVerbindungGate lehnt den Aufbau abHeader 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 verwaltung hat; angemeldet sein kann nur, wer ein Konto hat. Aufgelöst wird das Henne-Ei über BOOTSTRAP_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​

PunktWarum offen
Datenresidenz von Cloudflare Email SendingFü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/anmeldenDer 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 SendingPublic 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-ÄnderungenAnlegen/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/JWKSNicht 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)