74 KiB
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, nichtmain. Die Postfach-Arbeit ist gepusht, aber noch nicht gemergt, und dieser Plan ändertapp/Livewire/Admin/Mail.php,app/Livewire/EditMailbox.phpundapp/Livewire/Concerns/ConfirmsPassword.php— alles Dateien von dort. Vonmainabzweigen erzeugt Konflikte in genau den Dateien, die dieser Plan anfasst. Istfeat/mailboxesbeim Start bereits gemergt, dann vonmain. - 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/*.TranslationParityTesterzwingt Parität beider Sprachdateien. - R18: Icon neben dem Text,
size-4in Buttons und Tabellen,size-5in 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 mitclass="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 keinx-ui.selectund keine Page-Header-Komponente — beides von Hand schreiben, Klassen ausadmin/host-create.blade.phpoderadmin/edit-datacenter.blade.phpübernehmen. - Seitenüberschriften sind
text-2xl, nichttext-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, weilclass_exists()für ein Interfacefalseist. Nie gegen einen Interface-Namen prüfen; konkrete Klasse odertry/catch. - Commit-Konvention: kurzer Aussagesatz im Imperativ, kein
feat:/chore:-Präfix. - Nach jeder Aufgabe:
docker compose exec -T app php artisan testvollstä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
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:
'fact_location' => 'EU',
lang/en/auth.php:37:
'fact_location' => 'EU',
resources/views/landing.blade.php:466 — replace the whole row:
<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
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\OperatormitisOperator(): bool,Operator::OPERATOR_ROLES; Guardoperator;OperatorFactorymit->role(string $role = 'Owner'). -
Step 1: Write the failing test
<?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
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
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
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:
// 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:
'operators' => [
'driver' => 'eloquent',
'model' => App\Models\Operator::class,
],
and to passwords (needed later for operator password resets; harmless now):
'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
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/undtests/Feature/Mail/ - Delete:
tests/Feature/OperatorInPortalTest.php - Test:
tests/Feature/Admin/RbacMoveTest.php(neu)
Interfaces:
-
Consumes:
App\Models\Operator, Guardoperator(Task 2) -
Produces: alle 17 Berechtigungen und 6 Rollen unter
guard_name = 'operator';UserohneHasRoles/is_admin/isOperator() -
Step 1: Write the failing test
<?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
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 theusersrows fromoperators. 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:
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():
if ($this->fromManagementNetwork($request) || Auth::guard('operator')->check()) {
app/Http/Middleware/EnsureCustomerActive.php:22 — the operator special case
disappears; only the impersonation flag remains:
if ($user !== null && ! $request->session()->has('impersonator_id')) {
app/Models/Customer.php:138 — replace $user->is_admin || $user->isOperator():
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:
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:
{{-- 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:
// before
Livewire::actingAs(User::factory()->operator('Owner')->create())->test(...)
// after
Livewire::actingAs(Operator::factory()->role('Owner')->create(), 'operator')->test(...)
and for HTTP tests:
$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
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
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:
/**
* 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():
$account = auth()->guard($this->confirmationGuard())->user();
$key = 'confirm-password:'.$this->confirmationGuard().'|'.$account?->getAuthIdentifier().'|'.request()->ip();
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:
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
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
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
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
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:
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
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:
// 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:
- No
<x-auth.brand>fact plate and no marketing copy. Whoever stands here knows what CluPilot is. A plain centred card. - No registration link. That block is the whole reason this page exists.
wire:submit="authenticate"on the form,wire:modelon the fields, rather than a POST to Fortify.
<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):
'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
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:
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:
/**
* 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.
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
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
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.
usershas nouuidcolumn — checked — so a link naming the user would have to expose the integer primary key, against R11.CustomerandOperatorboth carry one throughHasUuid, and the customer is what the operator actually selected; the portal end resolves the user withensureUser(), exactly as the console end does.
- Step 1: Write the failing test
<?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
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:
// 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
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-Aliasoperator.2fa -
Step 1: Write the failing test
<?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
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:
'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:
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:
'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
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
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):
## 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:
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
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.
/adminohne Anmeldung muss auf die Konsolen-Anmeldung führen, nicht auf die des Portals. Kein Registrieren-Link. Null Konsolenfehler. Aus dem LAN ohne VPN ist/adminweiterhin 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 |