2008 lines
74 KiB
Markdown
2008 lines
74 KiB
Markdown
# 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 R18–R20 (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 |
|