Design the console's own identity, separate from customer accounts

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 <noreply@anthropic.com>
feat/mailboxes
nexxo 2026-07-27 20:40:22 +02:00
parent 96dac236e8
commit ce970a5fac
1 changed files with 283 additions and 0 deletions

View File

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