Write down that the console and the portal share no identity
parent
e519653f66
commit
bdb9c0a04a
28
CLAUDE.md
28
CLAUDE.md
|
|
@ -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`
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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();
|
||||||
|
});
|
||||||
Loading…
Reference in New Issue