Write down that the console and the portal share no identity

feat/operator-identity
nexxo 2026-07-28 12:15:13 +02:00
parent e519653f66
commit bdb9c0a04a
3 changed files with 101 additions and 8 deletions

View File

@ -150,3 +150,31 @@ Bearbeitung inline gebaut, obwohl das Muster danebenlag.
`tests/Feature/EditInModalTest.php` — kein Seiten-Blade darf ein Eingabefeld in `tests/Feature/EditInModalTest.php` — kein Seiten-Blade darf ein Eingabefeld in
einem `<td>` wachsen lassen, und die Benutzertabelle muss `edit-seat` per einem `<td>` wachsen lassen, und die Benutzertabelle muss `edit-seat` per
`openModal` öffnen. `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`

View File

@ -3,7 +3,7 @@
namespace App\Console\Commands; namespace App\Console\Commands;
use App\Models\Customer; use App\Models\Customer;
use App\Models\User; use App\Models\Operator;
use Illuminate\Console\Command; use Illuminate\Console\Command;
use Illuminate\Support\Facades\Hash; use Illuminate\Support\Facades\Hash;
use Illuminate\Validation\Rules\Password; 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 * Promotion is deliberate: running this twice with the same address should fix
* a lost role, not fail with "email already taken". * 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 class CreateAdmin extends Command
{ {
protected $signature = 'clupilot:create-admin protected $signature = 'clupilot:create-operator
{--email= : Login address} {--email= : Login address}
{--name= : Display name} {--name= : Display name}
{--password= : Leave empty to be prompted}'; {--password= : Leave empty to be prompted}';
protected $aliases = ['clupilot:create-admin'];
protected $description = 'Create or promote an operator account with the Owner role'; protected $description = 'Create or promote an operator account with the Owner role';
public function handle(): int public function handle(): int
@ -57,17 +63,15 @@ class CreateAdmin extends Command
return self::FAILURE; return self::FAILURE;
} }
$user = User::query()->firstOrNew(['email' => $email]); $operator = Operator::query()->firstOrNew(['email' => $email]);
$existed = $user->exists; $existed = $operator->exists;
$user->fill([ $operator->fill([
'name' => $name, 'name' => $name,
'password' => Hash::make($password), 'password' => Hash::make($password),
'is_admin' => true,
'email_verified_at' => $user->email_verified_at ?? now(),
])->save(); ])->save();
$user->syncRoles(['Owner']); $operator->syncRoles(['Owner']);
app(PermissionRegistrar::class)->forgetCachedPermissions(); app(PermissionRegistrar::class)->forgetCachedPermissions();
$this->info($existed $this->info($existed

View File

@ -0,0 +1,61 @@
<?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 () {
// 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();
});