CluPilotCloud/docs/superpowers/specs/2026-07-27-operator-identit...

15 KiB
Raw Blame History

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/*, upnicht 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-adminclupilot: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:37EU.
  • resources/views/landing.blade.php:466EU. 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 R18R20 (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.