From ce970a5fac9238d49b74b4497a0962fe4785a8bf Mon Sep 17 00:00:00 2001 From: nexxo Date: Mon, 27 Jul 2026 20:40:22 +0200 Subject: [PATCH] Design the console's own identity, separate from customer accounts MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The operator console and the customer portal share one users table, one login page and one guard. Three reported faults follow from that single fact: the console serves the portal's sign-in page, that page offers "Registrieren", and the link 404s because RestrictAdminHost::SHARED does not list register. Measured rather than assumed: driving the host guard directly gives /login through and /register 404 on the console host, and all sixteen Spatie permissions turn out to be console permissions — so RBAC moves to the new guard rather than being duplicated across two. Co-Authored-By: Claude Opus 5 --- .../2026-07-27-operator-identity-design.md | 283 ++++++++++++++++++ 1 file changed, 283 insertions(+) create mode 100644 docs/superpowers/specs/2026-07-27-operator-identity-design.md diff --git a/docs/superpowers/specs/2026-07-27-operator-identity-design.md b/docs/superpowers/specs/2026-07-27-operator-identity-design.md new file mode 100644 index 0000000..a100897 --- /dev/null +++ b/docs/superpowers/specs/2026-07-27-operator-identity-design.md @@ -0,0 +1,283 @@ +# Spec — Eigene Betreiber-Identität für die Konsole + +**Datum:** 2026-07-27 +**Status:** entworfen, noch nicht umgesetzt +**Vorgänger-Kontext:** `docs/handoffs/2026-07-27-portal-design-handoff.md` + +--- + +## 1. Ziel + +Die Konsole bekommt eine **eigene Personengruppe** mit eigener Tabelle, eigenem +Guard und eigener Anmeldung: der Inhaber und die Mitarbeiter von CluPilot. +`users` bleibt, was es ist — Kundenkonten des Portals. + +Heute teilen sich beide dieselbe Tabelle, dieselbe Anmeldeseite und denselben +Guard. Daraus folgen drei gemeldete Fehler, die alle **dieselbe** Ursache haben. + +### Nicht Teil dieses Vorhabens + +- Mitarbeiterverwaltung als Konsolen-Ansicht (die Berechtigung `staff.manage` + existiert bereits, die Ansicht dazu nicht). Folgepunkt. +- Ob Selbstregistrierung im **Portal** bleiben soll (§9). +- Die 22 Konsolen-Blades auf das Designsystem umstellen (eigener Block). + +--- + +## 2. Ausgangsbefund — gemessen, nicht gelesen + +| Befund | Beleg | +|---|---| +| `/register` ist im Exclusive-Modus auf dem Konsolen-Host **404** | `RestrictAdminHost::SHARED` führt `login`, `logout`, `two-factor-challenge`, `livewire/*`, `up` — **nicht** `register`. Guard direkt getrieben: `/login → through`, `/register → 404`. | +| Die Konsole zeigt die **Portal**-Anmeldeseite | `routes/web.php` registriert `/login` host-agnostisch; die Ansicht enthält „Kein Konto? Registrieren" (`livewire/auth/login.blade.php:37`). | +| `/admin` ist aus dem LAN 404 | `console.network_restricted` = **an**, erlaubt `10.66.0.0/24` + `127.0.0.1`. Gemessen: aus `10.66.0.5 → 302`, aus `10.10.90.50 → 404`. **So gewollt** (404 statt 403), nur als Ursache zu kennen. | +| Serverstandort-Text | `lang/de/auth.php:37` = `EU — Österreich / Deutschland`, `lang/en/auth.php:37` = `EU — Austria / Germany`. Die freigegebene Vorlage sagt an **beiden** Stellen nur `EU` (`tpl-home.html:728`, `:996`). | +| **Alle 16** Spatie-Berechtigungen sind Konsolen-Berechtigungen | `console.view`, `hosts.manage`, `secrets.manage`, … — keine einzige ist kundenseitig. Rollen: `Owner`(16), `Admin`(13), `Support`(4), `Billing`(2), `Developer`(2), `Read-only`(1), alle unter `guard_name = web`. | +| Bestandskonten | Zwei: `admin@clupilot.local` und `boban.blaskovic@gmail.com`, beide `Owner`, **beide ohne 2FA**. | +| Umfang | 11 App-/Routen-/View-Dateien mit Betreiber-Identität, 34 Testdateien mit `actingAs` (16 davon unter `Admin/`). | + +**Der Registrieren-Link und die 404 sind ein Fehler, nicht zwei.** Beide folgen +daraus, dass der Konsolen-Host eine Anmeldeseite ausliefert, die für das Portal +gebaut wurde. + +--- + +## 3. Identität + +Neue Tabelle `operators`: + +| Spalte | Zweck | +|---|---| +| `id`, `uuid` | Schlüssel; `uuid` für URLs, wie im Projekt üblich | +| `name`, `email` (unique), `password` | Anmeldung | +| `remember_token` | „angemeldet bleiben" | +| `two_factor_secret`, `two_factor_recovery_codes`, `two_factor_confirmed_at` | 2FA, Feldnamen wie bei Fortify | +| `last_login_at` | wer ist noch aktiv — Grundlage für spätere Mitarbeiterverwaltung | +| `disabled_at` | ausgeschiedener Mitarbeiter, ohne die Zuordnungshistorie zu löschen | +| `timestamps` | | + +- Modell `App\Models\Operator` mit `HasRoles` (`guard_name = 'operator'`), + `TwoFactorAuthenticatable`, `Notifiable`. +- Guard `operator` (Session-Treiber) + Provider `operators` in `config/auth.php`, + mit **eigenem Session-Cookie-Namen**, damit Portal- und Konsolen-Sitzung + einander nicht überschreiben. +- `App\Models\User` verliert `HasRoles` und `is_admin`. Beides hat dort nichts + mehr zu tun, sobald alle Berechtigungen am Operator hängen. + +**Warum eine eigene Tabelle und nicht ein Flag:** ein Flag lässt den Zustand +„Betreiber, der auch Portal-Konto ist" jederzeit wieder entstehen. Genau dieser +Zustand ist der Grund, warum `OperatorInPortalTest` existiert — ein Operator ohne +Kunden konnte ins Portal stolpern und dort auf tote Knöpfe drücken. Eine eigene +Tabelle macht die Fehlerklasse **konstruktiv unmöglich** statt sie abzufangen. + +--- + +## 4. RBAC zieht um, statt sich zu verdoppeln + +Weil **alle** Berechtigungen Konsolen-Berechtigungen sind, ist das ein Umzug: + +1. `permissions.guard_name` und `roles.guard_name` von `web` auf `operator`. +2. `model_has_roles` / `model_has_permissions`: `model_type` von + `App\Models\User` auf `App\Models\Operator`, `model_id` auf die neue + Operator-ID der jeweiligen Person. +3. Spatie-Cache leeren (`PermissionRegistrar::forgetCachedPermissions()`). + +Es entstehen **keine zwei Rollensätze**, die auseinanderdriften können. Das ist +der Unterschied zum naheliegenden „Rollen für den zweiten Guard duplizieren". + +> **Falle:** Spatie prüft `guard_name` bei jeder Zuweisung. Eine Rolle unter +> `operator` lässt sich einem `User` nicht mehr zuweisen — das ist erwünscht und +> wird von R21 (§8) festgeschrieben. + +--- + +## 5. Zwei Anmeldungen, die einander nicht kennen + +- **Fortify bleibt beim Portal** (`guard = web`). Fortify bindet an genau einen + Guard; der Versuch, beide daran zu hängen, wäre der Kern des Problems in neu. +- **Die Konsole bekommt eigenen Code:** + `App\Livewire\Auth\OperatorLogin` und `App\Livewire\Auth\OperatorTwoFactorChallenge` + (Vollseiten-Livewire, klassenbasiert — R1/R2), eigener Rate-Limiter-Schlüssel, + eigene Session-Regeneration nach erfolgreicher Anmeldung. +- **Die 2FA-Mechanik wird geteilt, der Ablauf nicht.** `TwoFactorAuthenticatable` + und Fortifys TOTP-Provider sind guard-agnostisch und werden wiederverwendet; + nur der Challenge-Ablauf ist eigener Code. Keine zweite TOTP-Implementierung. +- **Pfad aus `AdminArea::prefix()`**, nicht hartkodiert: + `/login` auf dem Konsolen-Host (Exclusive), `/admin/login` im Fallback. + Registriert in `routes/admin.php` als **Gast-Gruppe vor** dem `auth`-Teil, + damit sie die Host- und Netzwerk-Wächter der Konsole erbt. + +### Eigene Ansicht + +Der Konsolen-Login ist eine eigene Blade-Ansicht: **kein** Registrieren-Link, +**keine** Marketing-Faktenplatte, nüchterne Betreiber-Optik nach dem +Designsystem. Sie sagt nicht, wofür CluPilot gut ist — wer hier steht, weiß das. + +--- + +## 6. Damit wird die Trennung echt + +`RestrictAdminHost::SHARED` schrumpft auf `livewire/*` und `up`. +`login`, `logout` und `two-factor-challenge` entfallen, weil beide Seiten ihre +eigenen haben. + +Folgen: + +- Der Konsolen-Host liefert **keine** Portal-Anmeldung mehr aus. +- `/register` ist auf dem Konsolen-Host **keine Route mehr** — statt einer 404 + gibt es nichts mehr zu klicken, weil der Link nicht existiert. +- **`LandsWhereSignedIn`, `ConsoleAwareLoginResponse` und + `ConsoleAwareTwoFactorLoginResponse` entfallen ersatzlos.** Ihre einzige + Aufgabe war, eine geteilte Anmeldung an das richtige Ziel zu verteilen. Unterm + Strich **weniger** Code als vorher. + +### Betroffene Stellen (vollständig) + +| Datei | Änderung | +|---|---| +| `app/Http/Middleware/EnsureAdmin.php` | prüft den `operator`-Guard | +| `app/Http/Middleware/PublicSiteGate.php` | `Auth::guard('operator')->check()` statt `user()?->isOperator()` | +| `app/Http/Middleware/EnsureCustomerActive.php` | Operator-Sonderfall entfällt; nur noch Impersonation-Flag | +| `app/Http/Middleware/RestrictAdminHost.php` | `SHARED` schrumpft | +| `app/Models/Customer.php:138` | `is_admin \|\| isOperator()` → Operator-Guard | +| `app/Policies/VpnPeerPolicy.php` | **einzige gemischte Fläche:** Kunde *und* Operator greifen auf dieselbe Policy. Wird getrennt — Kundenbesitz über `web`, `vpn.*.all` über den Operator-Guard | +| `routes/channels.php:10` | Broadcast-Kanal `admin.runs` am Operator-Guard | +| `resources/views/layouts/portal-app.blade.php:25` | Konsolen-Link entfällt (ein Kunde ist nie Operator) | +| `app/Livewire/Admin/Settings.php`, `Admin/Vpn.php` | Guard-Wechsel | +| `config/auth.php`, `config/fortify.php` | Guard/Provider/Broker | + +--- + +## 7. Impersonation — signierte Einmal-URL + +**Befund, nicht Neuerung:** im Exclusive-Modus liegen Konsole und Portal auf +verschiedenen Hosts, und `SESSION_DOMAIN=null` macht Cookies host-gebunden. Der +heutige `Auth::login($user)` auf dem Konsolen-Host erreicht den Portal-Host +nicht. **Impersonation funktioniert auf live vermutlich bereits heute nicht.** + +Gewählter Weg: + +1. Die Konsole erzeugt eine **signierte, einmal gültige** URL auf den + Portal-Host: `URL::temporarySignedRoute`, **60 Sekunden** Frist. Gegen + Wiederverwendung wird der Signatur-Hash beim ersten Einlösen im Cache + (Redis) abgelegt, TTL gleich der Linkfrist — ein zweiter Aufruf findet den + Marker und wird abgewiesen. Redis, nicht die Datenbank: der Marker ist + genauso kurzlebig wie der Link und soll nicht aufgeräumt werden müssen. +2. Der Portal-Host meldet darüber den Kundenbenutzer am `web`-Guard an und + vermerkt in der Sitzung, wer impersoniert. +3. `leave()` meldet **nur** den `web`-Guard ab. Die Betreiber-Sitzung auf dem + Konsolen-Host wurde nie angetastet — sauberer als heute. + +Vorteile gegenüber `SESSION_DOMAIN=.clupilot.com`: kein über Subdomains geteiltes +Cookie (also nicht die Vermischung, die dieses Vorhaben gerade auflöst), Ablauf +eingebaut, und **protokollierbar** — wer wann wen angesehen hat. + +--- + +## 8. 2FA: freiwillig, mit Schalter des Inhabers + +- Standard bleibt **freiwillig**, wie heute. +- Neue Einstellung `console.require_2fa`, umlegbar in der Konsole. Ist sie an, + landet jeder Operator ohne bestätigte 2FA zwingend auf der Einrichtungsseite + und kommt erst danach weiter. +- **Aussperr-Schutz nach dem Muster von `console.allowed_ips`:** der Schalter + lässt sich nur umlegen, wenn das eigene Konto bereits bestätigte 2FA hat. + Sonst wäre die erste Amtshandlung, sich selbst auszusperren — und die Seite, + auf der man es zurücknehmen könnte, liegt hinter dem Schalter. +- Erzwungen durch Test, inklusive des Falls „Schalter an, Konto ohne 2FA". + +--- + +## 9. Konten und Migration + +- `clupilot:create-admin` → `clupilot:create-operator`; der alte Name bleibt als + Alias, damit vorhandene Installationsnotizen nicht brechen. +- Die Migration **übernimmt** jedes Konto mit Operator-Rolle nach `operators`, + samt Passwort-Hash und 2FA-Feldern. Dieselben Zugangsdaten wie bisher — kein + Passwortwechsel, kein Aussperren beim Live-Update. +- Danach wird die `users`-Zeile **nur dann** gelöscht, wenn kein Kunde, keine + Plätze und keine Bestellungen daran hängen. Andernfalls bleibt sie stehen, + verliert aber ihre Rollen, und die Migration **meldet**, was sie stehen ließ. +- Rückweg: die Migration ist `down()`-fähig, solange die `users`-Zeilen noch da + sind. Nach dem Löschen verwaister Zeilen ist nur noch der Weg über + `clupilot:create-operator` offen — das steht in der Migration als Kommentar. + +### Kleine Punkte, im selben Zug + +- `lang/de/auth.php:37` und `lang/en/auth.php:37` → `EU`. +- `resources/views/landing.blade.php:466` → `EU`. Diese Zeile steht **nicht** in + der freigegebenen Vorlage; die Änderung stellt Vorlagentreue her, sie weicht + nicht ab. +- **Angemerkt, nicht angefasst:** `Features::registration()` ist in + `config/fortify.php:172` auskommentiert, während `routes/web.php` `/register` + von Hand daran vorbei verdrahtet. Ob Selbstregistrierung im Portal bleiben + soll, ist eine eigene Entscheidung. + +--- + +## 10. Tests und Regel R21 + +- 16 Admin-Testdateien auf `actingAs($operator, 'operator')`; Factory + `OperatorFactory` mit `->role('Owner')`. +- **`OperatorInPortalTest` entfällt.** Es prüfte, dass ein Operator ohne Kunden + im Portal eine Meldung statt eines toten Knopfes bekommt. Diesen Zustand kann + es nicht mehr geben. Ersetzt durch den Nachweis, dass ein Operator sich am + `web`-Guard **gar nicht anmelden kann**. +- Neue Tests: Konsolen-Login ohne Registrieren-Link; Konsolen-Host serviert keine + Portal-Auth; Portal-Konto scheitert am Konsolen-Login und umgekehrt; + 2FA-Ablauf am Operator-Guard; 2FA-Pflichtschalter inkl. Aussperr-Schutz; + Impersonation über die signierte URL inkl. Ablauf und Einmaligkeit; + Migration übernimmt Hash und 2FA und lässt belegte `users`-Zeilen stehen. + +> ### R21 — Konsole und Portal teilen keine Identität +> +> Keine gemeinsame Auth-Route, keine Rolle am `User`, kein `users`-Datensatz mit +> Konsolen-Berechtigung, keine Anmeldeansicht, die beide bedient. +> +> **Warum:** Die geteilte Anmeldeseite hat dem Betreiber einen Registrieren-Link +> gezeigt, der auf dem Konsolen-Host zwangsläufig ins Leere führte. Das war kein +> Anzeigefehler — es war die Identität zweier verschiedener Personengruppen in +> einer Tabelle. +> +> **Erzwungen durch:** `tests/Feature/IdentitySeparationTest.php` + +Aufzunehmen in `CLAUDE.md`, im Format von R18–R20 (Verbote, Warum, Wie gebaut, +Erzwungen durch). + +--- + +## 11. Reihenfolge + +| Phase | Inhalt | Grün prüfbar durch | +|---|---|---| +| 1 | Kleine Punkte: `EU` in beiden Sprachdateien + Landing | vorhandene Tests + Sichtprüfung | +| 2 | Tabelle, Modell, Guard, Factory, RBAC-Umzug, Datenmigration | Migrationstest | +| 3 | Operator-Login + 2FA-Ablauf, Routen, eigene Ansicht | Auth-Tests | +| 4 | Trennung scharf: `SHARED` schrumpfen, drei Response-Klassen entfernen, 11 Fundstellen umstellen, 16 Testdateien nachziehen | vollständige Suite | +| 5 | Impersonation über signierte URL, `console.require_2fa`, R21 + `IdentitySeparationTest`, `CLAUDE.md` | vollständige Suite | + +Die Phasen sind Bauabschnitte, kein Auslieferungsplan — vereinbart ist ein Zug. +Phase 1 hängt an nichts und steht zuerst, damit die Sprachdateien nicht mitten +im Guard-Umbau angefasst werden müssen. + +--- + +## 12. Risiken + +| Risiko | Gegenmaßnahme | +|---|---| +| **Aussperren beim Live-Update** — nach dem Umzug meldet der alte Login nicht mehr an | Migration übernimmt Passwort-Hash 1:1; zusätzlich `clupilot:create-operator` als Weg über die Kommandozeile. Vor dem Live-Update auf dev durchspielen. | +| Spatie-Rollencache hält alte `guard_name` | `forgetCachedPermissions()` in der Migration, danach `config:clear` | +| Zwei Session-Cookies auf demselben Host (Fallback-Modus/dev) | Unterschiedliche Cookie-Namen; Test deckt „Portal-Anmeldung beendet Konsolen-Sitzung nicht" ab | +| `VpnPeerPolicy` bekommt je nach Guard ein anderes Modell | Policy wird getrennt, nicht verzweigt; beide Wege einzeln getestet | +| Die 404 aus dem LAN bleibt bestehen | Sie ist gewollt. Zum Testen VPN benutzen — im Handoff festhalten. | + +--- + +## 13. Folgepunkte + +- Mitarbeiterverwaltung in der Konsole (`staff.manage` existiert bereits). +- Passwort-Zurücksetzen für Operatoren (`Features::resetPasswords()` ist + projektweit aus; heute führt kein Weg außer der Kommandozeile). +- Entscheidung zur Selbstregistrierung im Portal (§9). +- `app/Models/User.php:23` sagt „five operator roles", die Liste hat **sechs**.