CluPilotCloud/docs/superpowers/plans/2026-07-28-operator-identit...

2008 lines
74 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.

# Betreiber-Identität — Implementation Plan
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
**Goal:** Die Konsole bekommt eine eigene Personengruppe — Inhaber und Mitarbeiter von CluPilot — in eigener Tabelle, mit eigenem Guard und eigener Anmeldung, getrennt von den Kundenkonten in `users`.
**Architecture:** Neue Tabelle `operators` mit dem Guard `operator`; das gesamte Spatie-RBAC zieht von `web` nach `operator` um, statt sich zu verdoppeln. Fortify bleibt beim Portal; die Konsole bekommt eigene Anmelde- und 2FA-Bauteile, die Fortifys TOTP-Mechanik wiederverwenden, aber nicht seinen Ablauf. Damit fällt die geteilte Anmeldeseite weg — und mit ihr der Registrieren-Link und die 404.
**Tech Stack:** Laravel 13.8, Livewire 3 (klassenbasiert, kein Volt), Fortify, spatie/laravel-permission 8.3, Pest, MariaDB 11.4, Redis, wire-elements/modal, Tailwind v4. Docker-first.
## Global Constraints
- **Zweigbasis: `feat/mailboxes`, nicht `main`.** Die Postfach-Arbeit ist gepusht, aber noch nicht gemergt, und dieser Plan ändert `app/Livewire/Admin/Mail.php`, `app/Livewire/EditMailbox.php` und `app/Livewire/Concerns/ConfirmsPassword.php` — alles Dateien von dort. Von `main` abzweigen erzeugt Konflikte in genau den Dateien, die dieser Plan anfasst. Ist `feat/mailboxes` beim Start bereits gemergt, dann von `main`.
- **Alle Befehle laufen im Container:** `docker compose exec -T app <cmd>`. Nie auf dem Host.
- **R1/R2:** Seiten sind klassenbasierte Livewire-Vollseiten-Komponenten. Kein Volt.
- **R13:** Pfade und Route-Namen englisch, Oberflächentexte deutsch, ausschließlich über `lang/de/*`. `TranslationParityTest` erzwingt Parität beider Sprachdateien.
- **R18:** Icon neben dem Text, `size-4` in Buttons und Tabellen, `size-5` in der Navigation. Icons in `<x-ui.nav-item>` gehören in `<x-slot:icon>`.
- **R19:** Jede angezeigte Zeit geht durch `->local()`. Nie `->timezone(config('app.timezone'))`.
- **R20:** Bearbeiten mit Eingabefeldern passiert im Modal, nie in der Tabellenzeile.
- **`<x-ui.button>` hat KEINEN Icon-Slot** — er rendert nur `{{ $slot }}`. Icons gehören in den Standard-Slot mit `class="size-4"`. Nur `<x-ui.nav-item>` hat `<x-slot:icon>`.
- **Komponentensatz ist fest:** `alert, badge, button, card, chart, checkbox, icon, input, logo, metric, nav-item, otp-input, progress-stepper, ring, spark, stat-tile`. Es gibt **kein** `x-ui.select` und keine Page-Header-Komponente — beides von Hand schreiben, Klassen aus `admin/host-create.blade.php` oder `admin/edit-datacenter.blade.php` übernehmen.
- **Seitenüberschriften sind `text-2xl`**, nicht `text-3xl`.
- **Die `// pfad/zur/datei.php`-Zeile in den Codeblöcken dieses Plans ist eine Annotation, kein Code.** Repo-Konvention ist `<?php`, Leerzeile, `namespace`.
- **Ein Livewire-`#[Validate]` an einer Eigenschaft gilt klassenweit** — Regeln gehören an die Aktion.
- **Pest-Falle:** `toThrow(SomeInterface::class)` degradiert still zu einem String-Vergleich auf der Fehlermeldung, weil `class_exists()` für ein Interface `false` ist. Nie gegen einen Interface-Namen prüfen; konkrete Klasse oder `try`/`catch`.
- **Commit-Konvention:** kurzer Aussagesatz im Imperativ, kein `feat:`/`chore:`-Präfix.
- **Nach jeder Aufgabe:** `docker compose exec -T app php artisan test` vollständig grün. Ausgangsstand: **815 Tests**.
---
## File Structure
| Datei | Verantwortung |
|---|---|
| `database/migrations/..._create_operators_table.php` | **neu** — die Tabelle |
| `app/Models/Operator.php` | **neu** — Betreiberkonto: `HasRoles` (Guard `operator`), `TwoFactorAuthenticatable`, `HasUuid` |
| `database/factories/OperatorFactory.php` | **neu** — Testdaten, mit `->role('Owner')` |
| `database/migrations/..._move_rbac_to_operator_guard.php` | **neu** — 17 Berechtigungen und 6 Rollen von `web` nach `operator`, Kontenumzug, `users`-Zeilen entfernen |
| `config/auth.php` | Guard `operator` + Provider `operators` + Passwort-Broker |
| `app/Models/User.php` | verliert `HasRoles`, `is_admin`, `OPERATOR_ROLES`, `isOperator()` |
| `app/Livewire/Concerns/ConfirmsPassword.php` | wird guard-bewusst über `confirmationGuard()` |
| `app/Livewire/Auth/OperatorLogin.php` + View | **neu** — Konsolen-Anmeldung, ohne Registrieren |
| `app/Livewire/Auth/OperatorTwoFactorChallenge.php` + View | **neu** — 2FA am Operator-Guard |
| `app/Http/Middleware/EnsureAdmin.php` | prüft den Operator-Guard |
| `app/Http/Middleware/RestrictAdminHost.php` | `SHARED` schrumpft auf `livewire/*` und `up` |
| `app/Http/Responses/*` | **drei Klassen entfallen ersatzlos** |
| `app/Http/Controllers/ImpersonationController.php` | signierte Einmal-URL über zwei Guards |
| `app/Livewire/Admin/Settings.php` | Schalter `console.require_2fa` mit Aussperr-Schutz |
| `app/Http/Middleware/RequireOperatorTwoFactor.php` | **neu** — erzwingt die Einrichtung, wenn der Schalter an ist |
| `tests/Feature/IdentitySeparationTest.php` | **neu** — erzwingt R21 |
| `CLAUDE.md` | R21 |
| `app/Console/Commands/CreateAdmin.php` | wird `clupilot:create-operator`, alter Name als Alias |
---
## Task 1: „EU" statt „EU — Österreich / Deutschland"
Der kleinste Punkt, unabhängig von allem anderen, und der einzige aus der ursprünglichen Meldung, der ohne den Guard-Umbau auskommt.
**Files:**
- Modify: `lang/de/auth.php:37`, `lang/en/auth.php:37`
- Modify: `resources/views/landing.blade.php:466`
- Test: `tests/Feature/Auth/BrandFactsTest.php` (neu)
**Interfaces:**
- Produces: nichts, was spätere Aufgaben brauchen.
- [ ] **Step 1: Write the failing test**
```php
<?php
use App\Models\Mailbox;
it('names only the EU as the server location, on the sign-in page', function () {
$this->get('/login')->assertOk()->assertSee('EU', false)
->assertDontSee('Österreich / Deutschland', false);
});
it('names only the EU in the specification plate on the public site', function () {
// The approved template (docs/design/tpl-home.html:728, :996) says only "EU"
// in both places. The row that spelled out the countries was never in it.
$this->get('/')->assertOk()->assertDontSee('EU — Österreich / Deutschland', false);
});
it('keeps the countries out of the translation files entirely', function () {
expect(__('auth.fact_location'))->toBe('EU');
app()->setLocale('en');
expect(__('auth.fact_location'))->toBe('EU');
});
```
- [ ] **Step 2: Run test to verify it fails**
Run: `docker compose exec -T app php artisan test --filter=BrandFactsTest`
Expected: FAIL — `fact_location` is `EU — Österreich / Deutschland`.
Note: if `/` returns 503 rather than 200, the public site is switched off
(`site.public`); the test runs from a trusted range in the container, so this
should not happen — if it does, read `app/Http/Middleware/PublicSiteGate.php`
before changing the test.
- [ ] **Step 3: Make the change**
`lang/de/auth.php:37`:
```php
'fact_location' => 'EU',
```
`lang/en/auth.php:37`:
```php
'fact_location' => 'EU',
```
`resources/views/landing.blade.php:466` — replace the whole row:
```blade
<div class="row"><dt>Serverstandort</dt><dd>EU</dd></div>
```
- [ ] **Step 4: Run the tests**
Run: `docker compose exec -T app php artisan test --filter=BrandFactsTest`
Expected: PASS — three tests.
Then the full suite: `docker compose exec -T app php artisan test`
Expected: PASS.
- [ ] **Step 5: Commit**
```bash
git add lang/de/auth.php lang/en/auth.php resources/views/landing.blade.php tests/Feature/Auth/BrandFactsTest.php
git commit -m "Say EU and stop there, as the approved template does"
```
---
## Task 2: Tabelle, Modell, Guard
Rein additiv — nichts benutzt den Guard danach, die Suite bleibt grün.
**Files:**
- Create: `database/migrations/2026_07_29_090000_create_operators_table.php`
- Create: `app/Models/Operator.php`
- Create: `database/factories/OperatorFactory.php`
- Modify: `config/auth.php`
- Test: `tests/Feature/Admin/OperatorModelTest.php`
**Interfaces:**
- Produces: `App\Models\Operator` mit `isOperator(): bool`, `Operator::OPERATOR_ROLES`; Guard `operator`; `OperatorFactory` mit `->role(string $role = 'Owner')`.
- [ ] **Step 1: Write the failing test**
```php
<?php
use App\Models\Operator;
use Illuminate\Support\Facades\Auth;
it('authenticates on its own guard, not on web', function () {
$operator = Operator::factory()->create(['password' => 'geheim-genug-12']);
expect(Auth::guard('operator')->attempt([
'email' => $operator->email, 'password' => 'geheim-genug-12',
]))->toBeTrue();
// The web guard resolves against users, which has no such row.
expect(Auth::guard('web')->attempt([
'email' => $operator->email, 'password' => 'geheim-genug-12',
]))->toBeFalse();
});
it('hashes the password rather than storing it', function () {
$operator = Operator::factory()->create(['password' => 'geheim-genug-12']);
expect($operator->password)->not->toBe('geheim-genug-12')
->and(Hash::check('geheim-genug-12', $operator->password))->toBeTrue();
});
it('assigns a uuid on create and uses it as the route key', function () {
$operator = Operator::factory()->create();
expect($operator->uuid)->not->toBeNull()
->and($operator->getRouteKeyName())->toBe('uuid');
});
it('carries the two-factor columns Fortify expects', function () {
$operator = Operator::factory()->create();
expect($operator->two_factor_secret)->toBeNull()
->and($operator->two_factor_confirmed_at)->toBeNull()
->and(method_exists($operator, 'twoFactorQrCodeSvg'))->toBeTrue();
});
it('can be disabled without deleting the record', function () {
$operator = Operator::factory()->create(['disabled_at' => now()]);
expect($operator->fresh()->disabled_at)->not->toBeNull();
});
```
- [ ] **Step 2: Run test to verify it fails**
Run: `docker compose exec -T app php artisan test --filter=OperatorModelTest`
Expected: FAIL — `Class "App\Models\Operator" not found`.
- [ ] **Step 3: Write the migration**
```php
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
/**
* The people who run CluPilot: the owner and the staff.
*
* Their own table rather than a flag on `users`, because a flag lets the state
* "operator who is also a portal account" reappear at any time — and that state
* is why OperatorInPortalTest had to exist: an operator without a customer
* could wander into the portal and press buttons that did nothing. A separate
* table makes the class of fault impossible rather than caught.
*/
return new class extends Migration
{
public function up(): void
{
Schema::create('operators', function (Blueprint $table) {
$table->id();
$table->uuid()->unique();
$table->string('name');
$table->string('email')->unique();
$table->string('password');
$table->rememberToken();
// Same column names Fortify's TwoFactorAuthenticatable expects.
$table->text('two_factor_secret')->nullable();
$table->text('two_factor_recovery_codes')->nullable();
$table->timestamp('two_factor_confirmed_at')->nullable();
// Who is still active — the basis for the staff view that comes later.
$table->timestamp('last_login_at')->nullable();
// A member of staff who has left, without deleting the audit trail
// their id appears in.
$table->timestamp('disabled_at')->nullable();
$table->timestamps();
});
}
public function down(): void
{
Schema::dropIfExists('operators');
}
};
```
- [ ] **Step 4: Write the model and factory**
```php
<?php
namespace App\Models;
use App\Models\Concerns\HasUuid;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Foundation\Auth\User as Authenticatable;
use Illuminate\Notifications\Notifiable;
use Laravel\Fortify\TwoFactorAuthenticatable;
use Spatie\Permission\Traits\HasRoles;
/**
* An operator: the owner of CluPilot, or a member of staff.
*
* Extends Authenticatable rather than App\Models\User — sharing the class would
* put both groups back in one table, which is the thing this exists to end.
*
* `$guard_name` is what makes Spatie resolve roles against the `operator` guard.
* Without it every role assignment would be checked against `web` and fail,
* which is the failure mode to expect if roles suddenly stop matching.
*/
class Operator extends Authenticatable
{
use HasFactory, HasRoles, HasUuid, Notifiable, TwoFactorAuthenticatable;
/** Tells spatie/laravel-permission which guard this model's roles live under. */
protected string $guard_name = 'operator';
/** The six roles that grant access to the console. */
public const OPERATOR_ROLES = ['Owner', 'Admin', 'Support', 'Billing', 'Read-only', 'Developer'];
protected $fillable = ['name', 'email', 'password', 'last_login_at', 'disabled_at'];
protected $hidden = ['password', 'remember_token', 'two_factor_secret', 'two_factor_recovery_codes'];
protected function casts(): array
{
return [
'password' => 'hashed',
'last_login_at' => 'datetime',
'disabled_at' => 'datetime',
'two_factor_confirmed_at' => 'datetime',
];
}
/** True when this operator holds a role that reaches the console. */
public function isOperator(): bool
{
return $this->hasAnyRole(self::OPERATOR_ROLES);
}
/** A disabled operator keeps their record and loses their access. */
public function isActive(): bool
{
return $this->disabled_at === null;
}
}
```
```php
<?php
namespace Database\Factories;
use App\Models\Operator;
use Illuminate\Database\Eloquent\Factories\Factory;
/** @extends Factory<Operator> */
class OperatorFactory extends Factory
{
protected $model = Operator::class;
public function definition(): array
{
return [
'name' => $this->faker->name(),
'email' => $this->faker->unique()->safeEmail(),
'password' => 'passwort-fuer-tests',
];
}
/** Give the operator a console role. Roles are seeded by migration. */
public function role(string $role = 'Owner'): static
{
return $this->afterCreating(fn (Operator $operator) => $operator->syncRoles([$role]));
}
}
```
- [ ] **Step 5: Register the guard**
In `config/auth.php`, add to `guards`:
```php
// The console. Its own session cookie name is set in config/session.php
// is NOT enough — Laravel keeps one session per request, and both guards
// read from it under different keys, so a portal login and a console
// login coexist in one browser without overwriting each other.
'operator' => [
'driver' => 'session',
'provider' => 'operators',
],
```
and to `providers`:
```php
'operators' => [
'driver' => 'eloquent',
'model' => App\Models\Operator::class,
],
```
and to `passwords` (needed later for operator password resets; harmless now):
```php
'operators' => [
'provider' => 'operators',
'table' => 'password_reset_tokens',
'expire' => 60,
'throttle' => 60,
],
```
Add `use App\Models\Operator;` is **not** needed — the file already uses the
fully-qualified `App\Models\User`; match that style.
- [ ] **Step 6: Run the tests**
Run: `docker compose exec -T app php artisan test --filter=OperatorModelTest`
Expected: PASS — five tests. The role-related ones do not run yet; roles still
live under `web` until Task 3.
Then: `docker compose exec -T app php artisan test`
Expected: PASS, 815 + 5.
- [ ] **Step 7: Commit**
```bash
git add database/migrations/2026_07_29_090000_create_operators_table.php app/Models/Operator.php database/factories/OperatorFactory.php config/auth.php tests/Feature/Admin/OperatorModelTest.php
git commit -m "Give the people who run CluPilot a table of their own"
```
---
## Task 3: Der Guard-Wechsel
**Diese Aufgabe ist groß und lässt sich nicht sinnvoll teilen.** Ein Guard-Wechsel ist atomar: in dem Moment, in dem die Rollen unter `operator` liegen, kann kein `User` mehr `can('console.view')`, und jeder Konsolentest fällt um. Halb umgestellt ist die Suite rot. Deshalb: RBAC-Umzug, Kontenumzug, alle elf Fundstellen und alle siebzehn Testdateien in **einem** Commit.
**Files:**
- Create: `database/migrations/2026_07_29_100000_move_rbac_to_operator_guard.php`
- Modify: `app/Models/User.php`, `app/Http/Middleware/EnsureAdmin.php`, `app/Http/Middleware/PublicSiteGate.php`, `app/Http/Middleware/EnsureCustomerActive.php`, `app/Models/Customer.php:138`, `app/Policies/VpnPeerPolicy.php`, `routes/channels.php:10`, `resources/views/layouts/portal-app.blade.php:25`, `app/Livewire/Admin/Settings.php`, `app/Livewire/Admin/Vpn.php`
- Modify: 17 Testdateien unter `tests/Feature/Admin/` und `tests/Feature/Mail/`
- Delete: `tests/Feature/OperatorInPortalTest.php`
- Test: `tests/Feature/Admin/RbacMoveTest.php` (neu)
**Interfaces:**
- Consumes: `App\Models\Operator`, Guard `operator` (Task 2)
- Produces: alle 17 Berechtigungen und 6 Rollen unter `guard_name = 'operator'`; `User` ohne `HasRoles`/`is_admin`/`isOperator()`
- [ ] **Step 1: Write the failing test**
```php
<?php
use App\Models\Customer;
use App\Models\Operator;
use App\Models\User;
use Illuminate\Support\Facades\DB;
use Spatie\Permission\Models\Permission;
use Spatie\Permission\Models\Role;
it('moves every permission and role to the operator guard, leaving none behind', function () {
expect(Permission::where('guard_name', 'web')->count())->toBe(0)
->and(Role::where('guard_name', 'web')->count())->toBe(0)
->and(Permission::where('guard_name', 'operator')->count())->toBe(17)
->and(Role::where('guard_name', 'operator')->count())->toBe(6);
});
it('keeps Owner holding every permission after the move', function () {
$owner = Role::findByName('Owner', 'operator');
expect($owner->permissions()->count())->toBe(Permission::count());
});
it('lets an operator use a console capability and a user not', function () {
$operator = Operator::factory()->role('Owner')->create();
expect($operator->can('console.view'))->toBeTrue()
->and(method_exists(User::factory()->create(), 'hasRole'))->toBeFalse();
});
it('carries an existing operator user across with password and two-factor intact', function () {
// Simulates the pre-migration state on a live server.
$hash = bcrypt('altes-passwort');
DB::table('users')->insert([
'name' => 'Alt', 'email' => 'alt@clupilot.test', 'password' => $hash,
'two_factor_secret' => 'geheimnis', 'is_admin' => true,
'created_at' => now(), 'updated_at' => now(),
]);
$userId = DB::table('users')->where('email', 'alt@clupilot.test')->value('id');
DB::table('model_has_roles')->insert([
'role_id' => DB::table('roles')->where('name', 'Owner')->value('id'),
'model_type' => User::class, 'model_id' => $userId,
]);
// Re-run only the move migration against this hand-built state.
(require base_path('database/migrations/2026_07_29_100000_move_rbac_to_operator_guard.php'))->up();
$operator = Operator::where('email', 'alt@clupilot.test')->first();
expect($operator)->not->toBeNull()
->and($operator->password)->toBe($hash)
->and($operator->two_factor_secret)->toBe('geheimnis')
->and($operator->hasRole('Owner'))->toBeTrue()
// Not mixed: the users row is gone.
->and(DB::table('users')->where('email', 'alt@clupilot.test')->exists())->toBeFalse();
});
it('refuses to delete a users row that has a customer on it', function () {
$customer = Customer::factory()->create();
$user = User::factory()->create(['email' => 'beides@clupilot.test']);
DB::table('users')->where('id', $user->id)->update(['customer_id' => $customer->id]);
DB::table('model_has_roles')->insert([
'role_id' => DB::table('roles')->where('name', 'Owner')->value('id'),
'model_type' => User::class, 'model_id' => $user->id,
]);
// Aborting is the point: silently deleting would take billing data with it.
try {
(require base_path('database/migrations/2026_07_29_100000_move_rbac_to_operator_guard.php'))->up();
$this->fail('The migration should have refused.');
} catch (RuntimeException $e) {
expect($e->getMessage())->toContain('beides@clupilot.test');
}
});
```
Note on the fourth and fifth tests: `RefreshDatabase` has already run every
migration, so calling `up()` again works on an already-migrated schema. Confirm
the migration is written to tolerate that (`firstOrNew`, guard clauses) — the
mailbox work was bitten by a non-idempotent seed migration and the repo's
`2026_07_25_133900_seed_roles_and_permissions.php` is the local precedent for
doing it re-runnably.
- [ ] **Step 2: Run test to verify it fails**
Run: `docker compose exec -T app php artisan test --filter=RbacMoveTest`
Expected: FAIL — the permissions are still on guard `web`.
- [ ] **Step 3: Write the migration**
```php
<?php
use App\Models\Operator;
use App\Models\User;
use Illuminate\Database\Migrations\Migration;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Str;
use RuntimeException;
use Spatie\Permission\PermissionRegistrar;
/**
* RBAC moves to the operator guard. It is a move, not a copy.
*
* Every one of the seventeen permissions is a console permission — there is no
* customer-facing one among them — so duplicating the set under a second guard
* would create two role sets that drift apart. Moving them leaves `users` with
* no roles at all, which is exactly right.
*/
return new class extends Migration
{
public function up(): void
{
DB::transaction(function () {
app(PermissionRegistrar::class)->forgetCachedPermissions();
DB::table('permissions')->where('guard_name', 'web')->update(['guard_name' => 'operator']);
DB::table('roles')->where('guard_name', 'web')->update(['guard_name' => 'operator']);
foreach ($this->operatorUserIds() as $userId) {
$this->carryAcross($userId);
}
app(PermissionRegistrar::class)->forgetCachedPermissions();
});
}
/** Every users row that currently holds a console role. */
private function operatorUserIds(): array
{
return DB::table('model_has_roles')
->where('model_type', User::class)
->pluck('model_id')
->unique()
->all();
}
private function carryAcross(int $userId): void
{
$row = DB::table('users')->where('id', $userId)->first();
if ($row === null) {
return;
}
// Idempotent: re-running must fix a half-finished move, not fail on the
// unique email.
$operator = Operator::firstOrNew(['email' => $row->email]);
$operator->name = $row->name;
$operator->uuid ??= (string) Str::uuid();
$operator->save();
// The hash and the two-factor secret are carried, not translated — the
// operator signs in with the same password as before.
DB::table('operators')->where('id', $operator->id)->update([
'password' => $row->password,
'two_factor_secret' => $row->two_factor_secret,
'two_factor_recovery_codes' => $row->two_factor_recovery_codes,
'two_factor_confirmed_at' => $row->two_factor_confirmed_at,
'remember_token' => $row->remember_token,
]);
// Re-point the role assignment at the new model.
DB::table('model_has_roles')
->where('model_type', User::class)->where('model_id', $userId)
->update(['model_type' => Operator::class, 'model_id' => $operator->id]);
DB::table('model_has_permissions')
->where('model_type', User::class)->where('model_id', $userId)
->update(['model_type' => Operator::class, 'model_id' => $operator->id]);
$this->removeUserRow($row);
}
/**
* The users row goes — whoever is in `operators` has no business in `users`.
*
* One exception, and it deletes nothing: a row with a customer, a seat or an
* order on it means the same person is operator AND paying customer. Then it
* is not the row that is wrong but the assumption, and a silent delete would
* take billing data with it.
*/
private function removeUserRow(object $row): void
{
$hasCustomer = property_exists($row, 'customer_id') && $row->customer_id !== null;
$hasSeats = DB::table('seats')->where('user_id', $row->id)->exists();
$hasOrders = DB::table('orders')->where('user_id', $row->id)->exists();
if ($hasCustomer || $hasSeats || $hasOrders) {
throw new RuntimeException(
"Refusing to move [{$row->email}]: this account is both an operator and a customer. "
.'Resolve that by hand before migrating.'
);
}
DB::table('users')->where('id', $row->id)->delete();
}
public function down(): void
{
DB::transaction(function () {
app(PermissionRegistrar::class)->forgetCachedPermissions();
foreach (DB::table('operators')->get() as $row) {
$userId = DB::table('users')->insertGetId([
'name' => $row->name, 'email' => $row->email, 'password' => $row->password,
'two_factor_secret' => $row->two_factor_secret,
'two_factor_recovery_codes' => $row->two_factor_recovery_codes,
'two_factor_confirmed_at' => $row->two_factor_confirmed_at,
'remember_token' => $row->remember_token,
'is_admin' => true,
'created_at' => $row->created_at, 'updated_at' => $row->updated_at,
]);
DB::table('model_has_roles')
->where('model_type', Operator::class)->where('model_id', $row->id)
->update(['model_type' => User::class, 'model_id' => $userId]);
}
DB::table('permissions')->where('guard_name', 'operator')->update(['guard_name' => 'web']);
DB::table('roles')->where('guard_name', 'operator')->update(['guard_name' => 'web']);
app(PermissionRegistrar::class)->forgetCachedPermissions();
});
}
};
```
> `down()` recreates the `users` rows from `operators`. What it cannot bring
> back is anything that only ever existed on the operator — `last_login_at`.
> Say so in a comment where the migration is read, not only here.
- [ ] **Step 4: Strip the operator concepts out of `User`**
In `app/Models/User.php`: remove `HasRoles` from the `use` list and its import,
remove `OPERATOR_ROLES`, remove `isOperator()`, remove `'is_admin'` from the
`#[Fillable]` attribute and from `casts()`. Leave the `is_admin` **column** in
place — dropping it is a separate migration and not worth coupling to this one;
add a one-line comment saying it is dead.
- [ ] **Step 5: Point the eleven consumers at the operator guard**
`app/Http/Middleware/EnsureAdmin.php`:
```php
public function handle(Request $request, Closure $next): Response
{
$operator = Auth::guard('operator')->user();
abort_unless($operator !== null && $operator->isActive() && $operator->can('console.view'), 403);
return $next($request);
}
```
`app/Http/Middleware/PublicSiteGate.php` — replace `$request->user()?->isOperator()`:
```php
if ($this->fromManagementNetwork($request) || Auth::guard('operator')->check()) {
```
`app/Http/Middleware/EnsureCustomerActive.php:22` — the operator special case
disappears; only the impersonation flag remains:
```php
if ($user !== null && ! $request->session()->has('impersonator_id')) {
```
`app/Models/Customer.php:138` — replace `$user->is_admin || $user->isOperator()`:
```php
if (Auth::guard('operator')->check()) {
```
Read the surrounding method first: if `$user` is a parameter used elsewhere in
it, keep that use and change only this condition.
`routes/channels.php:10`:
```php
Broadcast::channel('admin.runs', fn () => (bool) Auth::guard('operator')->user()?->can('console.view'));
```
`resources/views/layouts/portal-app.blade.php:25` — the console link goes
entirely. A customer is never an operator now, so the condition can only ever
be false:
```blade
{{-- The console link is gone: a portal account is never an operator (R21). --}}
```
`app/Policies/VpnPeerPolicy.php` — this is the one genuinely mixed surface.
Split it: the customer half keeps working off the `web` user and `owns()`, and
the operator half moves to explicit guard checks. Read the file and write both
paths; do not branch on `instanceof` inside one method, because Laravel resolves
the policy from the model being authorised and either identity can arrive.
`app/Livewire/Admin/Settings.php` and `app/Livewire/Admin/Vpn.php` — replace
`auth()->user()` with `Auth::guard('operator')->user()` wherever the operator is
meant. Grep both files for `auth()` and check each hit.
- [ ] **Step 6: Migrate the seventeen test files**
In every file under `tests/Feature/Admin/` and `tests/Feature/Mail/` that calls
`actingAs`, replace the operator user with an operator on its guard:
```php
// before
Livewire::actingAs(User::factory()->operator('Owner')->create())->test(...)
// after
Livewire::actingAs(Operator::factory()->role('Owner')->create(), 'operator')->test(...)
```
and for HTTP tests:
```php
$this->actingAs(Operator::factory()->role('Owner')->create(), 'operator')->get(...)
```
Update the `use` statements. **Do not weaken any assertion to make a test pass**
if a test breaks for a reason other than the identity change, stop and report it.
Delete `tests/Feature/OperatorInPortalTest.php`. It proved that an operator
without a customer gets a message instead of a dead button in the portal; that
state cannot occur any more. Replace it with a single test in the new
`IdentitySeparationTest` (Task 9) proving an operator cannot authenticate on the
`web` guard at all — do not leave the deletion uncompensated.
- [ ] **Step 7: Run the tests**
Run: `docker compose exec -T app php artisan test`
Expected: PASS. Expect this step to take several iterations — that is the nature
of an atomic guard switch. Work through the failures; do not skip or weaken.
- [ ] **Step 8: Commit**
```bash
git add -A
git commit -m "Move the console's identity out of the customer table"
```
---
## Task 4: Passwortbestätigung wird guard-bewusst
`ConfirmsPassword` prüft heute `auth()->user()->password` — den Standard-Guard.
Auf einer Konsolenseite ist das ab Task 3 der falsche Benutzer, und
`auth()->id()` im Rate-Limiter-Schlüssel ebenso. Fünf Bauteile benutzen den
Trait, zwei davon im Portal.
**Files:**
- Modify: `app/Livewire/Concerns/ConfirmsPassword.php`
- Modify: `app/Livewire/Admin/Secrets.php`, `app/Livewire/Admin/Mail.php`, `app/Livewire/EditMailbox.php`
- Test: `tests/Feature/Admin/PasswordConfirmationGuardTest.php` (neu)
**Interfaces:**
- Produces: `ConfirmsPassword::confirmationGuard(): string`, überschreibbar; Vorgabe `'web'`.
- [ ] **Step 1: Write the failing test**
```php
<?php
use App\Livewire\Admin\Secrets;
use App\Models\Operator;
use App\Models\User;
use Livewire\Livewire;
beforeEach(fn () => config()->set('admin_access.secrets_key', 'base64:'.base64_encode(random_bytes(32))));
it('confirms a console password against the operator record, not a portal user', function () {
$operator = Operator::factory()->role('Owner')->create(['password' => 'operator-passwort']);
Livewire::actingAs($operator, 'operator')->test(Secrets::class)
->set('confirmablePassword', 'operator-passwort')
->call('confirmPassword')
->assertHasNoErrors();
});
it('rejects a password that belongs to a portal user with the same address', function () {
// The trap: two tables, one address. Confirming against the wrong one would
// let a customer's password unlock the console.
$operator = Operator::factory()->role('Owner')->create([
'email' => 'doppelt@clupilot.test', 'password' => 'operator-passwort',
]);
User::factory()->create(['email' => 'doppelt@clupilot.test', 'password' => 'kunden-passwort']);
Livewire::actingAs($operator, 'operator')->test(Secrets::class)
->set('confirmablePassword', 'kunden-passwort')
->call('confirmPassword')
->assertHasErrors('confirmablePassword');
});
it('still confirms a portal password on the web guard', function () {
$user = User::factory()->create(['password' => 'kunden-passwort']);
Livewire::actingAs($user)->test(App\Livewire\Settings::class)
->set('confirmablePassword', 'kunden-passwort')
->call('confirmPassword')
->assertHasNoErrors();
});
```
- [ ] **Step 2: Run test to verify it fails**
Run: `docker compose exec -T app php artisan test --filter=PasswordConfirmationGuardTest`
Expected: FAIL — the console tests resolve `auth()->user()` on the web guard and
get null.
- [ ] **Step 3: Make the trait guard-aware**
In `app/Livewire/Concerns/ConfirmsPassword.php`, add:
```php
/**
* Which guard's record this component confirms against.
*
* Overridden to 'operator' by the console components. A default of 'web'
* keeps the portal working unchanged — and an override is explicit, where
* sniffing the request would be one more thing to get wrong on a page that
* gates access to credentials.
*/
protected function confirmationGuard(): string
{
return 'web';
}
```
and replace the two `auth()` uses in `confirmPassword()`:
```php
$account = auth()->guard($this->confirmationGuard())->user();
$key = 'confirm-password:'.$this->confirmationGuard().'|'.$account?->getAuthIdentifier().'|'.request()->ip();
```
```php
if ($account === null || ! Hash::check($this->confirmablePassword, $account->password)) {
```
The guard name goes into the rate-limiter key so a portal and a console
confirmation for the same id cannot share a bucket.
- [ ] **Step 4: Override it in the three console components**
Add to `app/Livewire/Admin/Secrets.php`, `app/Livewire/Admin/Mail.php` and
`app/Livewire/EditMailbox.php`:
```php
protected function confirmationGuard(): string
{
return 'operator';
}
```
- [ ] **Step 5: Run the tests**
Run: `docker compose exec -T app php artisan test --filter=PasswordConfirmationGuardTest`
Expected: PASS — three tests.
Then: `docker compose exec -T app php artisan test`
Expected: PASS.
- [ ] **Step 6: Commit**
```bash
git add app/Livewire/Concerns/ConfirmsPassword.php app/Livewire/Admin/Secrets.php app/Livewire/Admin/Mail.php app/Livewire/EditMailbox.php tests/Feature/Admin/PasswordConfirmationGuardTest.php
git commit -m "Confirm a console password against the console's own record"
```
---
## Task 5: Die Konsolen-Anmeldung
**Files:**
- Create: `app/Livewire/Auth/OperatorLogin.php`, `resources/views/livewire/auth/operator-login.blade.php`
- Create: `app/Livewire/Auth/OperatorTwoFactorChallenge.php`, `resources/views/livewire/auth/operator-two-factor-challenge.blade.php`
- Modify: `routes/web.php` (Gast-Gruppe für die Konsole), `routes/admin.php`
- Modify: `lang/de/auth.php`, `lang/en/auth.php`
- Test: `tests/Feature/Auth/OperatorLoginTest.php`
**Interfaces:**
- Consumes: Guard `operator` (Task 2)
- Produces: Routen `admin.login`, `admin.login.store`, `admin.two-factor`, `admin.logout`
- [ ] **Step 1: Write the failing test**
```php
<?php
use App\Livewire\Auth\OperatorLogin;
use App\Models\Operator;
use App\Models\User;
use Illuminate\Support\Facades\Auth;
use Livewire\Livewire;
it('offers no way to register — an operator account is not self-served', function () {
$this->get(route('admin.login'))->assertOk()
->assertDontSee(route('register'))
->assertDontSee(__('auth.sign_up'));
});
it('signs an operator in on the operator guard', function () {
$operator = Operator::factory()->role('Owner')->create(['password' => 'richtig-langes-pw']);
Livewire::test(OperatorLogin::class)
->set('email', $operator->email)
->set('password', 'richtig-langes-pw')
->call('authenticate')
->assertRedirect(App\Support\AdminArea::home());
expect(Auth::guard('operator')->check())->toBeTrue()
->and(Auth::guard('web')->check())->toBeFalse();
});
it('refuses a portal account at the console door', function () {
$user = User::factory()->create(['email' => 'kunde@clupilot.test', 'password' => 'kunden-passwort']);
Livewire::test(OperatorLogin::class)
->set('email', 'kunde@clupilot.test')
->set('password', 'kunden-passwort')
->call('authenticate')
->assertHasErrors('email');
expect(Auth::guard('operator')->check())->toBeFalse();
});
it('refuses a disabled operator', function () {
$operator = Operator::factory()->role('Owner')->create([
'password' => 'richtig-langes-pw', 'disabled_at' => now(),
]);
Livewire::test(OperatorLogin::class)
->set('email', $operator->email)
->set('password', 'richtig-langes-pw')
->call('authenticate')
->assertHasErrors('email');
expect(Auth::guard('operator')->check())->toBeFalse();
});
it('sends an operator with confirmed two-factor to the challenge instead of the console', function () {
$operator = Operator::factory()->role('Owner')->create([
'password' => 'richtig-langes-pw',
'two_factor_secret' => encrypt('JBSWY3DPEHPK3PXP'),
'two_factor_confirmed_at' => now(),
]);
Livewire::test(OperatorLogin::class)
->set('email', $operator->email)
->set('password', 'richtig-langes-pw')
->call('authenticate')
->assertRedirect(route('admin.two-factor'));
// Not signed in yet — the challenge is not decoration.
expect(Auth::guard('operator')->check())->toBeFalse();
});
it('throttles repeated failures', function () {
$operator = Operator::factory()->role('Owner')->create(['password' => 'richtig-langes-pw']);
$component = Livewire::test(OperatorLogin::class)->set('email', $operator->email)->set('password', 'falsch');
foreach (range(1, 5) as $ignored) {
$component->call('authenticate');
}
$component->call('authenticate')->assertHasErrors('email');
expect(session()->has('login.id'))->toBeFalse();
});
it('records when the operator last signed in', function () {
$operator = Operator::factory()->role('Owner')->create(['password' => 'richtig-langes-pw']);
Livewire::test(OperatorLogin::class)
->set('email', $operator->email)->set('password', 'richtig-langes-pw')
->call('authenticate');
expect($operator->fresh()->last_login_at)->not->toBeNull();
});
```
- [ ] **Step 2: Run test to verify it fails**
Run: `docker compose exec -T app php artisan test --filter=OperatorLoginTest`
Expected: FAIL — `Class "App\Livewire\Auth\OperatorLogin" not found`.
- [ ] **Step 3: Write the login component**
```php
<?php
namespace App\Livewire\Auth;
use App\Models\Operator;
use App\Support\AdminArea;
use Illuminate\Support\Facades\Auth;
use Illuminate\Support\Facades\Hash;
use Illuminate\Support\Facades\RateLimiter;
use Illuminate\Validation\ValidationException;
use Livewire\Attributes\Layout;
use Livewire\Component;
/**
* The console's own sign-in.
*
* Not Fortify: Fortify binds to exactly one guard, and hanging both the portal
* and the console off it would be the shared login this separation exists to
* end. The two-factor MECHANICS are still Fortify's — the same
* TwoFactorAuthenticatable trait and the same TOTP provider — only the flow is
* ours.
*/
#[Layout('layouts.portal')]
class OperatorLogin extends Component
{
public string $email = '';
public string $password = '';
public bool $remember = false;
public function authenticate(): void
{
// Rules on the action: a #[Validate] attribute on a property applies
// class-wide and would be dragged into any other action added later.
$this->validate([
'email' => ['required', 'email'],
'password' => ['required', 'string'],
]);
$key = 'operator-login:'.mb_strtolower($this->email).'|'.request()->ip();
if (RateLimiter::tooManyAttempts($key, 5)) {
throw ValidationException::withMessages([
'email' => __('auth.throttle', ['seconds' => RateLimiter::availableIn($key)]),
]);
}
$operator = Operator::where('email', $this->email)->first();
// One message for every failure — a different message for "no such
// account" tells a stranger which addresses are operators.
if ($operator === null
|| ! Hash::check($this->password, $operator->password)
|| ! $operator->isActive()
|| ! $operator->isOperator()) {
RateLimiter::hit($key, 60);
$this->reset('password');
throw ValidationException::withMessages(['email' => __('auth.failed')]);
}
RateLimiter::clear($key);
if ($operator->two_factor_confirmed_at !== null) {
// Not signed in yet. The id is parked in the session and the guard
// stays untouched until the code checks out.
session(['operator.2fa.id' => $operator->id, 'operator.2fa.remember' => $this->remember]);
$this->redirectRoute('admin.two-factor', navigate: false);
return;
}
$this->completeLogin($operator, $this->remember);
}
/** Shared with the two-factor challenge, so both exits behave identically. */
public static function completeLogin(Operator $operator, bool $remember): void
{
Auth::guard('operator')->login($operator, $remember);
session()->regenerate();
$operator->forceFill(['last_login_at' => now()])->save();
}
private function completeLoginAndRedirect(Operator $operator, bool $remember): void
{
self::completeLogin($operator, $remember);
$this->redirect(AdminArea::home(), navigate: false);
}
public function render()
{
return view('livewire.auth.operator-login');
}
}
```
Replace the `$this->completeLogin($operator, $this->remember);` call in
`authenticate()` with `$this->completeLoginAndRedirect($operator, $this->remember);`.
- [ ] **Step 4: Write the two-factor challenge**
```php
<?php
namespace App\Livewire\Auth;
use App\Models\Operator;
use App\Support\AdminArea;
use Illuminate\Support\Facades\RateLimiter;
use Illuminate\Validation\ValidationException;
use Laravel\Fortify\Contracts\TwoFactorAuthenticationProvider;
use Livewire\Attributes\Layout;
use Livewire\Component;
/**
* The operator's two-factor step.
*
* Fortify's TOTP provider does the arithmetic — there is no second
* implementation of the algorithm here, only a second flow, because Fortify's
* own flow is bound to the web guard.
*/
#[Layout('layouts.portal')]
class OperatorTwoFactorChallenge extends Component
{
public string $code = '';
public string $recoveryCode = '';
public bool $usingRecovery = false;
public function mount(): void
{
// Reachable only between password and code. Landing here directly is a
// dead end, not a way in.
abort_if(! session()->has('operator.2fa.id'), 403);
}
public function verify(): void
{
$operator = Operator::find(session('operator.2fa.id'));
abort_if($operator === null, 403);
$key = 'operator-2fa:'.$operator->id.'|'.request()->ip();
if (RateLimiter::tooManyAttempts($key, 5)) {
throw ValidationException::withMessages([
'code' => __('auth.throttle', ['seconds' => RateLimiter::availableIn($key)]),
]);
}
if (! $this->passes($operator)) {
RateLimiter::hit($key, 60);
$this->reset('code', 'recoveryCode');
throw ValidationException::withMessages(['code' => __('auth.twofa_invalid')]);
}
RateLimiter::clear($key);
$remember = (bool) session('operator.2fa.remember', false);
session()->forget(['operator.2fa.id', 'operator.2fa.remember']);
OperatorLogin::completeLogin($operator, $remember);
$this->redirect(AdminArea::home(), navigate: false);
}
private function passes(Operator $operator): bool
{
if ($this->usingRecovery) {
$codes = json_decode(decrypt($operator->two_factor_recovery_codes), true) ?: [];
if (! in_array($this->recoveryCode, $codes, true)) {
return false;
}
// A recovery code is single use — leaving it valid turns a one-time
// escape hatch into a second password.
$operator->forceFill([
'two_factor_recovery_codes' => encrypt(json_encode(array_values(
array_diff($codes, [$this->recoveryCode])
))),
])->save();
return true;
}
return app(TwoFactorAuthenticationProvider::class)
->verify(decrypt($operator->two_factor_secret), $this->code);
}
public function render()
{
return view('livewire.auth.operator-two-factor-challenge');
}
}
```
- [ ] **Step 5: Register the routes**
In `routes/web.php`, the console block currently applies
`['admin.host', 'auth', 'admin']` to everything in `routes/admin.php`. Split it
so the sign-in pages are reachable **before** `auth`. In both the exclusive and
the fallback branch, register a guest group first:
```php
Route::middleware(['admin.host', 'guest:operator'])
->prefix(AdminArea::prefix())
->name('admin.')
->group(base_path('routes/admin-guest.php'));
```
and create `routes/admin-guest.php`:
```php
<?php
use App\Livewire\Auth\OperatorLogin;
use App\Livewire\Auth\OperatorTwoFactorChallenge;
use Illuminate\Support\Facades\Route;
/*
| The console's own front door.
|
| Registered before the authenticated console routes so that /login on the
| console host resolves here, and NOT to the portal's sign-in page — which is
| what put a "Registrieren" link in front of an operator and a 404 behind it.
*/
Route::get('/login', OperatorLogin::class)->name('login');
Route::get('/two-factor', OperatorTwoFactorChallenge::class)->name('two-factor');
```
and in `routes/admin.php`, inside the authenticated group, add the logout:
```php
// POST so Laravel's CSRF middleware protects it, like every other state change.
Route::post('/logout', function () {
Auth::guard('operator')->logout();
session()->invalidate();
session()->regenerateToken();
return redirect()->route('admin.login');
})->name('logout');
```
with `use Illuminate\Support\Facades\Auth;` at the top of that file.
Then point `resources/views/layouts/admin.blade.php:34` at `route('admin.logout')`
instead of `route('logout')`.
- [ ] **Step 6: Write the views**
`resources/views/livewire/auth/operator-login.blade.php` — copy the structure of
`resources/views/livewire/auth/login.blade.php`, with three differences:
1. **No `<x-auth.brand>` fact plate and no marketing copy.** Whoever stands here
knows what CluPilot is. A plain centred card.
2. **No registration link.** That block is the whole reason this page exists.
3. `wire:submit="authenticate"` on the form, `wire:model` on the fields, rather
than a POST to Fortify.
```blade
<div class="flex min-h-screen items-center justify-center bg-bg px-6 py-12">
<div class="w-full max-w-sm animate-rise">
<div class="mb-9 flex items-center gap-2.5">
<x-ui.logo class="size-8" />
<span class="text-lg font-bold tracking-tight text-ink">Clu<span class="text-accent-text">Pilot</span></span>
</div>
<h1 class="text-2xl font-bold leading-tight tracking-tight text-ink">{{ __('auth.console_login_title') }}</h1>
<p class="mt-2 text-sm text-muted">{{ __('auth.console_login_subtitle') }}</p>
<form wire:submit="authenticate" class="mt-8 space-y-5">
<x-ui.input name="email" type="email" wire:model="email" :label="__('auth.email')" autocomplete="username" autofocus required />
<x-ui.input name="password" type="password" wire:model="password" :label="__('auth.password_label')" autocomplete="current-password" required />
<x-ui.checkbox name="remember" wire:model="remember" :label="__('auth.remember')" value="1" />
<x-ui.button type="submit" variant="primary" size="md" class="w-full">{{ __('auth.sign_in') }}</x-ui.button>
</form>
</div>
</div>
```
`resources/views/livewire/auth/operator-two-factor-challenge.blade.php` — the
same shell, with `<x-ui.otp-input>` for the code and a link that flips
`usingRecovery`. Read `resources/views/livewire/auth/two-factor-challenge.blade.php`
for how that component is used in this repo, and copy its markup.
Add to `lang/de/auth.php` (and the English equivalents to `lang/en/auth.php`):
```php
'console_login_title' => 'Konsole',
'console_login_subtitle' => 'Anmeldung für Betreiber.',
'twofa_invalid' => 'Der Code stimmt nicht.',
```
**On the layout:** there is no `layouts.guest`. The layout set is `admin`,
`portal-app` and `portal`, and all three existing auth pages
(`Login`, `Register`, `TwoFactorChallenge`) use `#[Layout('layouts.portal')]`
that is the guest shell, despite the name. Use it. Do not add a fourth layout
for this: read `resources/views/layouts/portal.blade.php` first and confirm it
carries nothing portal-specific that would look wrong above a console sign-in.
If it does, say so in your report rather than inventing a layout.
- [ ] **Step 7: Run the tests**
Run: `docker compose exec -T app php artisan test --filter=OperatorLoginTest`
Expected: PASS — seven tests.
Then: `docker compose exec -T app php artisan test`
Expected: PASS.
Then: `docker compose exec -T app npm run build`
- [ ] **Step 8: Commit**
```bash
git add app/Livewire/Auth/Operator*.php resources/views/livewire/auth/operator-*.blade.php routes/ resources/views/layouts/admin.blade.php lang/ tests/Feature/Auth/OperatorLoginTest.php
git commit -m "Give the console a front door of its own"
```
---
## Task 6: Die Trennung wird scharf — hier sterben Registrieren-Link und 404
**Files:**
- Modify: `app/Http/Middleware/RestrictAdminHost.php`
- Delete: `app/Http/Responses/LandsWhereSignedIn.php`, `ConsoleAwareLoginResponse.php`, `ConsoleAwareTwoFactorLoginResponse.php`
- Modify: `app/Providers/FortifyServiceProvider.php`
- Test: `tests/Feature/Admin/ConsoleHostSeparationTest.php` (erweitern), `tests/Feature/Auth/ConsoleLoginLandsInTheConsoleTest.php` (ersetzen)
**Interfaces:**
- Consumes: Routen `admin.login`, `admin.two-factor` (Task 5)
- [ ] **Step 1: Write the failing test**
Add to `tests/Feature/Admin/ConsoleHostSeparationTest.php`, inside the
`describe('exclusive host', …)` block:
```php
it('does not serve the portal sign-in or registration on the console host', function () {
// The reported fault, at its root: the console used to answer /login
// with the portal's page, which offers "Registrieren" — and /register
// was never in SHARED, so the link 404'd.
expect(guard('admin.example.test', '/register', 'register'))->toBe('404')
->and(guard('admin.example.test', '/login', 'login'))->toBe('404');
});
it('still serves the console's own sign-in on its host', function () {
expect(guard('admin.example.test', '/login', 'admin.login'))->toBe('through')
->and(guard('admin.example.test', '/two-factor', 'admin.two-factor'))->toBe('through');
});
it('keeps only the endpoints both sides genuinely share', function () {
expect(guard('admin.example.test', '/livewire/update', null))->toBe('through')
->and(guard('admin.example.test', '/up', null))->toBe('through');
});
```
- [ ] **Step 2: Run test to verify it fails**
Run: `docker compose exec -T app php artisan test --filter=ConsoleHostSeparationTest`
Expected: FAIL — `/login` currently passes through because `login` is in `SHARED`.
- [ ] **Step 3: Shrink SHARED**
In `app/Http/Middleware/RestrictAdminHost.php`:
```php
/**
* Endpoints both sides genuinely share.
*
* `login`, `logout` and `two-factor-challenge` used to be here, from when
* one sign-in page served both. They are gone: the console has its own at
* `admin.login`, and leaving the portal's here is what put a registration
* link in front of an operator — with a 404 behind it, because `register`
* was never in this list and could not be, since the console has no
* registration by design.
*/
private const SHARED = [
'livewire/*',
'up',
];
```
- [ ] **Step 4: Delete the three response classes**
They existed only to route a shared sign-in to the right destination. Each side
now lands where it belongs by construction.
```bash
git rm app/Http/Responses/LandsWhereSignedIn.php app/Http/Responses/ConsoleAwareLoginResponse.php app/Http/Responses/ConsoleAwareTwoFactorLoginResponse.php
```
In `app/Providers/FortifyServiceProvider.php`, remove both `$this->app->singleton(...)`
bindings (around lines 39-44) and their imports. Fortify falls back to its own
responses, which redirect to `Fortify::redirects('login')` — the portal
dashboard. That is now correct, because Fortify only ever serves the portal.
Delete `tests/Feature/Auth/ConsoleLoginLandsInTheConsoleTest.php` — it tested
the deleted classes. Its intent is covered by Task 5's "signs an operator in on
the operator guard" and by the separation tests above; confirm both exist before
deleting, and say so in the commit message.
- [ ] **Step 5: Run the tests**
Run: `docker compose exec -T app php artisan test`
Expected: PASS. Watch for tests that reached the console through `/login` — they
now need `route('admin.login')`.
- [ ] **Step 6: Verify the reported fault is actually gone**
```bash
docker compose exec -T app php artisan route:list | grep -E "login|register"
```
On a console host in exclusive mode, `/register` must not resolve at all, and
`/login` must resolve to `admin.login`. Note in the report what you saw.
- [ ] **Step 7: Commit**
```bash
git add -A
git commit -m "Stop the console serving the portal's sign-in page"
```
---
## Task 7: Impersonation über eine signierte Einmal-URL
**Files:**
- Modify: `app/Http/Controllers/ImpersonationController.php`
- Modify: `routes/web.php` (Einlöse-Route im Portal)
- Test: `tests/Feature/ImpersonationTest.php` (umschreiben)
**Interfaces:**
- Produces: Route `impersonate.enter` (signiert, im Portal)
> **The URL addresses the CUSTOMER by uuid, not the user.** `users` has no
> `uuid` column — checked — so a link naming the user would have to expose the
> integer primary key, against R11. `Customer` and `Operator` both carry one
> through `HasUuid`, and the customer is what the operator actually selected;
> the portal end resolves the user with `ensureUser()`, exactly as the console
> end does.
- [ ] **Step 1: Write the failing test**
```php
<?php
use App\Models\Customer;
use App\Models\Operator;
use Illuminate\Support\Facades\Auth;
use Illuminate\Support\Facades\URL;
it('hands over a signed link instead of relying on a shared cookie', function () {
$operator = Operator::factory()->role('Owner')->create();
$customer = Customer::factory()->create();
$response = $this->actingAs($operator, 'operator')
->post(route('admin.impersonate', $customer));
// The console and the portal are different hosts in exclusive mode, and a
// session cookie is host-bound — so the handover cannot be a cookie.
$response->assertRedirectContains('signature=');
});
it('signs the customer in on the web guard when the link is followed', function () {
$operator = Operator::factory()->role('Owner')->create();
$customer = Customer::factory()->create();
$user = $customer->ensureUser();
$url = URL::temporarySignedRoute('impersonate.enter', now()->addSeconds(60), [
'customer' => $customer->uuid, 'operator' => $operator->uuid,
]);
$this->get($url)->assertRedirect(route('dashboard'));
expect(Auth::guard('web')->id())->toBe($user->id);
});
it('refuses the same link twice', function () {
$operator = Operator::factory()->role('Owner')->create();
$customer = Customer::factory()->create();
$user = $customer->ensureUser();
$url = URL::temporarySignedRoute('impersonate.enter', now()->addSeconds(60), [
'customer' => $customer->uuid, 'operator' => $operator->uuid,
]);
$this->get($url)->assertRedirect(route('dashboard'));
Auth::guard('web')->logout();
// A link that still works after use is a password with an expiry date.
$this->get($url)->assertForbidden();
});
it('refuses an expired link', function () {
$operator = Operator::factory()->role('Owner')->create();
$customer = Customer::factory()->create();
$user = $customer->ensureUser();
$url = URL::temporarySignedRoute('impersonate.enter', now()->subSecond(), [
'customer' => $customer->uuid, 'operator' => $operator->uuid,
]);
$this->get($url)->assertForbidden();
});
it('leaves the operator session untouched, so leaving returns to the console', function () {
$operator = Operator::factory()->role('Owner')->create();
$customer = Customer::factory()->create();
$user = $customer->ensureUser();
$url = URL::temporarySignedRoute('impersonate.enter', now()->addSeconds(60), [
'customer' => $customer->uuid, 'operator' => $operator->uuid,
]);
$this->actingAs($operator, 'operator')->get($url);
expect(Auth::guard('operator')->check())->toBeTrue();
$this->post(route('impersonate.leave'));
expect(Auth::guard('web')->check())->toBeFalse()
->and(Auth::guard('operator')->check())->toBeTrue();
});
```
- [ ] **Step 2: Run test to verify it fails**
Run: `docker compose exec -T app php artisan test --filter=ImpersonationTest`
Expected: FAIL — the controller still calls `Auth::login()` directly.
- [ ] **Step 3: Rewrite the controller**
```php
<?php
namespace App\Http\Controllers;
use App\Models\Customer;
use App\Models\Operator;
use App\Models\User;
use Illuminate\Http\Request;
use Illuminate\Http\RedirectResponse;
use Illuminate\Support\Facades\Auth;
use Illuminate\Support\Facades\Cache;
use Illuminate\Support\Facades\URL;
/**
* Looking at a customer's portal as that customer.
*
* Not a shared cookie. Once the console has a hostname of its own, a session
* cookie set on the console host never reaches the portal host — SESSION_DOMAIN
* is null and cookies are host-bound. So the handover is a signed, single-use
* link: it works regardless of cookie scope, it expires, and it leaves a record
* of who looked at whom.
*/
class ImpersonationController extends Controller
{
/** Sixty seconds is a handover, not a credential. */
private const WINDOW = 60;
public function start(Customer $customer): RedirectResponse
{
$this->authorize('customers.impersonate');
$operator = Auth::guard('operator')->user();
// Addressed by CUSTOMER uuid, not user: `users` has no uuid column, and
// naming the integer key in a URL is what R11 forbids. The portal end
// resolves the account the same way this end would have.
return redirect()->away(URL::temporarySignedRoute(
'impersonate.enter',
now()->addSeconds(self::WINDOW),
['customer' => $customer->uuid, 'operator' => $operator->uuid],
));
}
/** The portal end of the handover. Signed, and good exactly once. */
public function enter(Request $request, string $customer, string $operator): RedirectResponse
{
abort_unless($request->hasValidSignature(), 403);
// Single use: the signature's own hash is the key, and it lives exactly
// as long as the link could. Redis, not the database — the marker is as
// short-lived as the link and should not need tidying up.
$marker = 'impersonate:'.hash('sha256', (string) $request->query('signature'));
abort_unless(Cache::add($marker, true, self::WINDOW), 403);
$target = Customer::where('uuid', $customer)->firstOrFail()->ensureUser();
$who = Operator::where('uuid', $operator)->firstOrFail();
Auth::guard('web')->login($target);
session(['impersonator_id' => $who->id]);
return redirect()->route('dashboard');
}
/** Back out of the customer's portal. The operator session was never touched. */
public function leave(): RedirectResponse
{
session()->forget('impersonator_id');
Auth::guard('web')->logout();
return redirect()->to(\App\Support\AdminArea::home());
}
}
```
- [ ] **Step 4: Register the redemption route**
In `routes/web.php`, outside the `auth` group — the visitor is not signed in yet
when they follow the link:
```php
// Signed and single-use; see ImpersonationController. Deliberately not behind
// `auth`: following the link is what creates the session.
Route::get('/impersonate/enter/{customer}/{operator}', [ImpersonationController::class, 'enter'])
->name('impersonate.enter');
```
- [ ] **Step 5: Run the tests**
Run: `docker compose exec -T app php artisan test --filter=ImpersonationTest`
Expected: PASS — five tests.
Then: `docker compose exec -T app php artisan test`
Expected: PASS.
- [ ] **Step 6: Commit**
```bash
git add app/Http/Controllers/ImpersonationController.php routes/web.php tests/Feature/ImpersonationTest.php
git commit -m "Hand impersonation over by signed link, not by a cookie that cannot cross hosts"
```
---
## Task 8: Der 2FA-Pflichtschalter
Freiwillig bleibt die Vorgabe. Der Inhaber kann sie für alle verbindlich machen —
und darf sich dabei nicht selbst aussperren.
**Files:**
- Create: `app/Http/Middleware/RequireOperatorTwoFactor.php`
- Modify: `bootstrap/app.php`, `routes/web.php`, `app/Livewire/Admin/Settings.php`, `resources/views/livewire/admin/settings.blade.php`
- Modify: `lang/de/admin_settings.php`, `lang/en/admin_settings.php`
- Test: `tests/Feature/Admin/OperatorTwoFactorPolicyTest.php`
**Interfaces:**
- Produces: Einstellung `console.require_2fa`; Middleware-Alias `operator.2fa`
- [ ] **Step 1: Write the failing test**
```php
<?php
use App\Livewire\Admin\Settings;
use App\Models\Operator;
use App\Support\Settings as AppSettings;
use Livewire\Livewire;
it('is voluntary by default', function () {
expect(AppSettings::bool('console.require_2fa', false))->toBeFalse();
$operator = Operator::factory()->role('Owner')->create();
$this->actingAs($operator, 'operator')->get(route('admin.overview'))->assertOk();
});
it('sends an operator without two-factor to the setup page once the switch is on', function () {
AppSettings::set('console.require_2fa', true);
$operator = Operator::factory()->role('Owner')->create();
$this->actingAs($operator, 'operator')
->get(route('admin.overview'))
->assertRedirect(route('admin.settings'));
});
it('lets an operator with confirmed two-factor through', function () {
AppSettings::set('console.require_2fa', true);
$operator = Operator::factory()->role('Owner')->create([
'two_factor_secret' => encrypt('JBSWY3DPEHPK3PXP'),
'two_factor_confirmed_at' => now(),
]);
$this->actingAs($operator, 'operator')->get(route('admin.overview'))->assertOk();
});
it('refuses to switch it on while your own account has no two-factor', function () {
// The lock-out this exists to prevent: the page that would turn it back off
// sits behind the switch. Same shape as console.allowed_ips.
$operator = Operator::factory()->role('Owner')->create();
Livewire::actingAs($operator, 'operator')->test(Settings::class)
->set('requireTwoFactor', true)
->call('saveTwoFactorPolicy')
->assertHasErrors('requireTwoFactor');
expect(AppSettings::bool('console.require_2fa', false))->toBeFalse();
});
it('allows it once your own two-factor is confirmed', function () {
$operator = Operator::factory()->role('Owner')->create([
'two_factor_secret' => encrypt('JBSWY3DPEHPK3PXP'),
'two_factor_confirmed_at' => now(),
]);
Livewire::actingAs($operator, 'operator')->test(Settings::class)
->set('requireTwoFactor', true)
->call('saveTwoFactorPolicy')
->assertHasNoErrors();
expect(AppSettings::bool('console.require_2fa', false))->toBeTrue();
});
```
- [ ] **Step 2: Run test to verify it fails**
Run: `docker compose exec -T app php artisan test --filter=OperatorTwoFactorPolicyTest`
Expected: FAIL — no such middleware, no such action.
- [ ] **Step 3: Write the middleware**
```php
<?php
namespace App\Http\Middleware;
use App\Support\Settings;
use Closure;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Auth;
use Symfony\Component\HttpFoundation\Response;
/**
* Two-factor for operators, when the owner has made it compulsory.
*
* Off by default. The settings page itself is exempt, because it is where the
* operator sets two-factor up — gating it would leave nobody able to satisfy
* the requirement.
*/
class RequireOperatorTwoFactor
{
public function handle(Request $request, Closure $next): Response
{
if (! Settings::bool('console.require_2fa', false)) {
return $next($request);
}
$operator = Auth::guard('operator')->user();
if ($operator === null || $operator->two_factor_confirmed_at !== null) {
return $next($request);
}
// The one page that must stay reachable: where two-factor is enrolled.
if (\App\Support\AdminArea::routeIs('admin.settings') || $request->routeIs('admin.logout')) {
return $next($request);
}
return redirect()->route('admin.settings')
->with('status', __('admin_settings.two_factor_required'));
}
}
```
- [ ] **Step 4: Register and apply it**
In `bootstrap/app.php`, add to the alias list:
```php
'operator.2fa' => \App\Http\Middleware\RequireOperatorTwoFactor::class,
```
In `routes/web.php`, add `'operator.2fa'` to the authenticated console group's
middleware array, after `'admin'`, in **both** the exclusive and the fallback
branch.
- [ ] **Step 5: Add the switch to the settings page**
In `app/Livewire/Admin/Settings.php`:
```php
public bool $requireTwoFactor = false;
public function saveTwoFactorPolicy(): void
{
$this->authorize('site.manage');
// The lock-out guard, same shape as console.allowed_ips: the page that
// could switch this back off sits behind the switch.
if ($this->requireTwoFactor && Auth::guard('operator')->user()?->two_factor_confirmed_at === null) {
$this->addError('requireTwoFactor', __('admin_settings.two_factor_self_first'));
return;
}
Settings::set('console.require_2fa', $this->requireTwoFactor);
$this->dispatch('notify', message: __('admin_settings.saved'));
}
```
Load `$this->requireTwoFactor = Settings::bool('console.require_2fa', false);`
in `mount()`. Add a checkbox and a save button to the blade, following the
card markup already in that file.
Add to both language files:
```php
'two_factor_required' => 'Zwei-Faktor ist für die Konsole verbindlich. Bitte richten Sie sie hier ein.',
'two_factor_self_first' => 'Richten Sie zuerst Ihre eigene Zwei-Faktor-Bestätigung ein — sonst sperren Sie sich mit diesem Schalter selbst aus.',
```
- [ ] **Step 6: Run the tests**
Run: `docker compose exec -T app php artisan test --filter=OperatorTwoFactorPolicyTest`
Expected: PASS — five tests.
Then: `docker compose exec -T app php artisan test`
Expected: PASS.
- [ ] **Step 7: Commit**
```bash
git add app/Http/Middleware/RequireOperatorTwoFactor.php bootstrap/app.php routes/web.php app/Livewire/Admin/Settings.php resources/views/livewire/admin/settings.blade.php lang/ tests/Feature/Admin/OperatorTwoFactorPolicyTest.php
git commit -m "Let the owner make two-factor compulsory, without locking themselves out"
```
---
## Task 9: R21, der erzwingende Test, und das Kommando
**Files:**
- Create: `tests/Feature/IdentitySeparationTest.php`
- Modify: `CLAUDE.md`
- Modify: `app/Console/Commands/CreateAdmin.php`
- Test: siehe unten
**Interfaces:**
- Consumes: alles Vorherige
- [ ] **Step 1: Write the enforcing test**
```php
<?php
use App\Models\Operator;
use App\Models\User;
use Illuminate\Support\Facades\Auth;
use Spatie\Permission\Models\Permission;
use Spatie\Permission\Models\Role;
/**
* R21 — the console and the portal share no identity.
*
* Not a style rule. A shared sign-in page showed an operator a registration
* link with a 404 behind it, and that was not a display fault: it was two
* different groups of people in one table.
*/
it('leaves no role or permission on the web guard', function () {
expect(Role::where('guard_name', 'web')->count())->toBe(0)
->and(Permission::where('guard_name', 'web')->count())->toBe(0);
});
it('gives the User model no way to hold a role', function () {
expect(method_exists(User::class, 'hasRole'))->toBeFalse()
->and(method_exists(User::class, 'isOperator'))->toBeFalse();
});
it('cannot authenticate an operator on the web guard', function () {
$operator = Operator::factory()->role('Owner')->create(['password' => 'operator-passwort']);
expect(Auth::guard('web')->attempt([
'email' => $operator->email, 'password' => 'operator-passwort',
]))->toBeFalse();
});
it('cannot authenticate a portal user on the operator guard', function () {
$user = User::factory()->create(['password' => 'kunden-passwort']);
expect(Auth::guard('operator')->attempt([
'email' => $user->email, 'password' => 'kunden-passwort',
]))->toBeFalse();
});
it('shares no authentication route between the two sides', function () {
$shared = (new ReflectionClass(App\Http\Middleware\RestrictAdminHost::class))
->getConstant('SHARED');
foreach (['login', 'logout', 'register', 'two-factor-challenge'] as $path) {
expect($shared)->not->toContain($path);
}
});
it('has no console-aware login response left, because there is nothing to disambiguate', function () {
expect(class_exists(App\Http\Responses\ConsoleAwareLoginResponse::class))->toBeFalse()
->and(class_exists(App\Http\Responses\LandsWhereSignedIn::class))->toBeFalse();
});
```
- [ ] **Step 2: Run it**
Run: `docker compose exec -T app php artisan test --filter=IdentitySeparationTest`
Expected: PASS — six tests. If any fails, an earlier task is incomplete; fix
there, not here.
- [ ] **Step 3: Write R21 into `CLAUDE.md`**
Append, in the same shape as R18R20 (Verbote, Warum, Wie gebaut, Erzwungen durch):
```markdown
## R21 — Konsole und Portal teilen keine Identität
**Betreiber und Kunden sind zwei Personengruppen, nicht zwei Zustände einer.**
Verboten:
1. **Eine Rolle oder Berechtigung am `User`.** Alle siebzehn Berechtigungen sind
Konsolen-Berechtigungen; sie liegen am `operator`-Guard. Ein `users`-Datensatz
mit Konsolenzugang ist die Vermischung in klein.
2. **Eine Auth-Route, die beide Seiten bedient.** `RestrictAdminHost::SHARED`
führt nur noch `livewire/*` und `up`.
3. **Eine Anmeldeansicht für beide.** Das Portal hat Fortify, die Konsole hat
`App\Livewire\Auth\OperatorLogin`.
### Warum das aufgeschrieben wurde
Die Konsole lieferte die Anmeldeseite des Portals aus — mit „Kein Konto?
Registrieren" darauf. `register` stand nicht in `SHARED` und konnte dort auch
nicht stehen, weil eine Konsole keine Selbstregistrierung hat. Der Link führte
also zwangsläufig in eine 404. Das war kein Anzeigefehler, sondern die Identität
zweier Personengruppen in einer Tabelle.
### Erzwungen durch
`tests/Feature/IdentitySeparationTest.php`
```
- [ ] **Step 4: Rename the command**
In `app/Console/Commands/CreateAdmin.php`: change the signature's first line to
`clupilot:create-operator`, keep the old name reachable by adding
`protected $aliases = ['clupilot:create-admin'];`, and change the body to create
an `Operator` with the `Owner` role instead of a `User`. Verify the alias works:
```bash
docker compose exec -T app php artisan clupilot:create-admin --email=probe@clupilot.test --name=Probe --password=ein-langes-testpasswort
docker compose exec -T app php artisan tinker --execute="echo App\Models\Operator::where('email','probe@clupilot.test')->exists() ? 'angelegt' : 'FEHLT';"
```
Then remove the probe account.
- [ ] **Step 5: Run the full suite and build**
Run: `docker compose exec -T app php artisan test`
Expected: PASS.
Run: `docker compose exec -T app npm run build`
- [ ] **Step 6: Commit**
```bash
git add tests/Feature/IdentitySeparationTest.php CLAUDE.md app/Console/Commands/CreateAdmin.php
git commit -m "Write down that the console and the portal share no identity"
```
---
## Nach dem letzten Task
- [ ] **Im Browser ansehen, über das VPN.** `/admin` ohne Anmeldung muss auf die
Konsolen-Anmeldung führen, nicht auf die des Portals. Kein Registrieren-Link.
Null Konsolenfehler. Aus dem LAN ohne VPN ist `/admin` weiterhin 404 — das ist
gewollt (`console.network_restricted`), kein Fehler.
- [ ] **Codex-Review (R15)**, Ablauf in der Nutzer-Memory `clupilot-r15-codex-review`.
Im Hintergrund laufen lassen; der Aufruf überschreitet zwei Minuten. Schleifen,
bis keine Befunde mehr kommen — bei der Postfach-Arbeit waren das zehn Runden
mit sechs P1.
- [ ] **Vor dem Live-Update prüfen, dass niemand ausgesperrt wird.** Die Migration
überträgt Passwort-Hash und 2FA, es gilt also dasselbe Passwort wie vorher.
Trotzdem auf dev durchspielen, inklusive `migrate:rollback`.
- [ ] Handoff fortschreiben.
## Risiken
| Risiko | Gegenmaßnahme |
|---|---|
| **Aussperren beim Live-Update** | Hash und 2FA werden 1:1 übernommen; zusätzlich `clupilot:create-operator` über die Kommandozeile. Vorher auf dev durchspielen. |
| Spatie-Rollencache hält alte `guard_name` | `forgetCachedPermissions()` in beiden Richtungen der Migration, danach `config:clear` |
| Task 3 ist groß und lässt sich nicht teilen | Bewusst so: ein Guard-Wechsel ist atomar. Dafür ist die Aufgabe davor und danach klein. |
| `VpnPeerPolicy` bekommt je nach Guard ein anderes Modell | Getrennt, nicht verzweigt; beide Wege einzeln getestet |
| Eine Person ist Betreiber **und** zahlender Kunde | Die Migration bricht ab und nennt die Adresse, statt Abrechnungsdaten zu löschen |