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