15 KiB
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.manageexistiert 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\OperatormitHasRoles(guard_name = 'operator'),TwoFactorAuthenticatable,Notifiable. - Guard
operator(Session-Treiber) + Provideroperatorsinconfig/auth.php, mit eigenem Session-Cookie-Namen, damit Portal- und Konsolen-Sitzung einander nicht überschreiben. App\Models\UserverliertHasRolesundis_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:
permissions.guard_nameundroles.guard_namevonwebaufoperator.model_has_roles/model_has_permissions:model_typevonApp\Models\UseraufApp\Models\Operator,model_idauf die neue Operator-ID der jeweiligen Person.- 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_namebei jeder Zuweisung. Eine Rolle unteroperatorlässt sich einemUsernicht 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\OperatorLoginundApp\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.
TwoFactorAuthenticatableund 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:/loginauf dem Konsolen-Host (Exclusive),/admin/loginim Fallback. Registriert inroutes/admin.phpals Gast-Gruppe vor demauth-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.
/registerist auf dem Konsolen-Host keine Route mehr — statt einer 404 gibt es nichts mehr zu klicken, weil der Link nicht existiert.LandsWhereSignedIn,ConsoleAwareLoginResponseundConsoleAwareTwoFactorLoginResponseentfallen 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:
- 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. - Der Portal-Host meldet darüber den Kundenbenutzer am
web-Guard an und vermerkt in der Sitzung, wer impersoniert. leave()meldet nur denweb-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 inoperatorssteht, hat inusersnichts 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
deletenähme Abrechnungsdaten mit. Geprüft: keines der beiden Bestandskonten ist in diesem Zustand, der Abbruch tritt heute also nicht ein. - Rückweg:
down()legt dieusers-Zeilen ausoperatorswieder 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:37undlang/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 inconfig/fortify.php:172auskommentiert, währendroutes/web.php/registervon 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'); FactoryOperatorFactorymit->role('Owner'). OperatorInPortalTestentfä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 amweb-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, keinusers-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.manageexistiert 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:23sagt „five operator roles", die Liste hat sechs.