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

293 lines
15 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters!

This file contains ambiguous Unicode characters that may be confused with others in your current locale. If your use case is intentional and legitimate, you can safely ignore this warning. Use the Escape button to highlight these characters.

# 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 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**.