293 lines
15 KiB
Markdown
293 lines
15 KiB
Markdown
# 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**.
|