# 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. - **Die `users`-Zeile wird danach gelöscht.** Wer in `operators` steht, hat in `users` nichts mehr verloren — das ist der Sinn der Trennung, und eine zurückgelassene Zeile wäre genau die Vermischung in klein. Entschieden am 2026-07-27. - **Eine Ausnahme, und sie löscht nichts:** hängt an der Zeile ein Kunde, ein Platz oder eine Bestellung, **bricht die Migration ab** und nennt die betroffene Adresse. Dieser Zustand hieße, dass dieselbe Person Betreiber *und* zahlender Kunde ist — dann ist nicht die Zeile das Problem, sondern die Annahme, und ein stilles `delete` nähme Abrechnungsdaten mit. Geprüft: **keines der beiden Bestandskonten ist in diesem Zustand**, der Abbruch tritt heute also nicht ein. - Rückweg: `down()` legt die `users`-Zeilen aus `operators` wieder an — Hash und 2FA sind ja übernommen, nicht übersetzt. Verloren geht dabei nur, was seit dem Umzug am Operator entstanden ist (`last_login_at`). Steht als Kommentar in der Migration. ### 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**.