From bdb9c0a04a742147e6c415e35eaf463e9f2a3e0e Mon Sep 17 00:00:00 2001 From: nexxo Date: Tue, 28 Jul 2026 12:15:13 +0200 Subject: [PATCH] Write down that the console and the portal share no identity --- CLAUDE.md | 28 +++++++++++ app/Console/Commands/CreateAdmin.php | 20 ++++---- tests/Feature/IdentitySeparationTest.php | 61 ++++++++++++++++++++++++ 3 files changed, 101 insertions(+), 8 deletions(-) create mode 100644 tests/Feature/IdentitySeparationTest.php diff --git a/CLAUDE.md b/CLAUDE.md index 57d1e35..9ebd373 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -150,3 +150,31 @@ Bearbeitung inline gebaut, obwohl das Muster danebenlag. `tests/Feature/EditInModalTest.php` — kein Seiten-Blade darf ein Eingabefeld in einem `` wachsen lassen, und die Benutzertabelle muss `edit-seat` per `openModal` öffnen. + +--- + +## 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` diff --git a/app/Console/Commands/CreateAdmin.php b/app/Console/Commands/CreateAdmin.php index 7ace7d1..772a2e9 100644 --- a/app/Console/Commands/CreateAdmin.php +++ b/app/Console/Commands/CreateAdmin.php @@ -3,7 +3,7 @@ namespace App\Console\Commands; use App\Models\Customer; -use App\Models\User; +use App\Models\Operator; use Illuminate\Console\Command; use Illuminate\Support\Facades\Hash; use Illuminate\Validation\Rules\Password; @@ -16,14 +16,20 @@ use Spatie\Permission\PermissionRegistrar; * * Promotion is deliberate: running this twice with the same address should fix * a lost role, not fail with "email already taken". + * + * Renamed from clupilot:create-admin now that the account this creates is an + * Operator, not a User with is_admin set — the old name stays reachable as an + * alias so existing install scripts and muscle memory keep working. */ class CreateAdmin extends Command { - protected $signature = 'clupilot:create-admin + protected $signature = 'clupilot:create-operator {--email= : Login address} {--name= : Display name} {--password= : Leave empty to be prompted}'; + protected $aliases = ['clupilot:create-admin']; + protected $description = 'Create or promote an operator account with the Owner role'; public function handle(): int @@ -57,17 +63,15 @@ class CreateAdmin extends Command return self::FAILURE; } - $user = User::query()->firstOrNew(['email' => $email]); - $existed = $user->exists; + $operator = Operator::query()->firstOrNew(['email' => $email]); + $existed = $operator->exists; - $user->fill([ + $operator->fill([ 'name' => $name, 'password' => Hash::make($password), - 'is_admin' => true, - 'email_verified_at' => $user->email_verified_at ?? now(), ])->save(); - $user->syncRoles(['Owner']); + $operator->syncRoles(['Owner']); app(PermissionRegistrar::class)->forgetCachedPermissions(); $this->info($existed diff --git a/tests/Feature/IdentitySeparationTest.php b/tests/Feature/IdentitySeparationTest.php new file mode 100644 index 0000000..af43cb5 --- /dev/null +++ b/tests/Feature/IdentitySeparationTest.php @@ -0,0 +1,61 @@ +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 () { + // A third class lived here too: ConsoleAwareTwoFactorLoginResponse, Fortify's + // TwoFactorLoginResponse-contract sibling of ConsoleAwareLoginResponse, for + // the two-factor completion path (see the deletion in 8f8a0ad — every one of + // the three landing-disambiguation classes went together, not just these two). + // Checking two of the three would let the third quietly come back. + expect(class_exists(App\Http\Responses\ConsoleAwareLoginResponse::class))->toBeFalse() + ->and(class_exists(App\Http\Responses\ConsoleAwareTwoFactorLoginResponse::class))->toBeFalse() + ->and(class_exists(App\Http\Responses\LandsWhereSignedIn::class))->toBeFalse(); +});