diff --git a/app/Livewire/Modals/CreateUser.php b/app/Livewire/Modals/CreateUser.php index 7970de3..c0ed2bb 100644 --- a/app/Livewire/Modals/CreateUser.php +++ b/app/Livewire/Modals/CreateUser.php @@ -2,6 +2,7 @@ namespace App\Livewire\Modals; +use App\Enums\Role; use App\Models\AuditEvent; use App\Models\User; use Illuminate\Support\Facades\Auth; @@ -12,7 +13,8 @@ use Illuminate\Validation\Rules\Password; use LivewireUI\Modal\ModalComponent; /** - * Form modal: add a new administrator account. Every account is a full admin (no roles). The creator + * Form modal: add a new account with an RBAC role (admin > operator > viewer; new accounts + * default to `operator` — least privilege). Restricted to admins (`manage-users`). The creator * may set a password directly, or leave it blank — then a strong initial password is generated, the * account is flagged `must_change_password`, and the clear-text password is revealed ONCE in the modal * (never persisted or logged). An operator-set password is the user's own, so no forced rotation. @@ -23,6 +25,9 @@ class CreateUser extends ModalComponent public string $email = ''; + /** New account role (admin > operator > viewer). Defaults to operator — least privilege. */ + public string $role = 'operator'; + /** Optional operator-set password; blank = generate an initial password and reveal it once. */ public string $password = ''; @@ -32,6 +37,11 @@ class CreateUser extends ModalComponent /** Name of the account just created, used in the reveal copy. */ public string $createdName = ''; + public function mount(): void + { + abort_unless(auth()->user()?->can('manage-users'), 403); + } + public static function modalMaxWidth(): string { return 'lg'; @@ -47,6 +57,7 @@ class CreateUser extends ModalComponent 'email' => ['required', 'string', 'email', 'max:255', Rule::unique('users', 'email')], // Optional — when set it must meet the same strength as a self-chosen password. 'password' => ['nullable', 'string', Password::min(6)], + 'role' => ['required', Rule::enum(Role::class)], ]; } @@ -59,11 +70,14 @@ class CreateUser extends ModalComponent 'name' => __('accounts.name_label'), 'email' => __('accounts.email_label'), 'password' => __('accounts.password_label'), + 'role' => __('accounts.role_label'), ]; } public function save(): void { + abort_unless(auth()->user()?->can('manage-users'), 403); + $data = $this->validate(); // Re-check uniqueness server-side regardless of the live validator state. @@ -84,6 +98,7 @@ class CreateUser extends ModalComponent 'email' => $email, 'password' => Hash::make($password), 'must_change_password' => ! $operatorSet, + 'role' => $data['role'], ]); AuditEvent::create([ diff --git a/app/Livewire/Settings/Users.php b/app/Livewire/Settings/Users.php index 6186c19..5d5c9db 100644 --- a/app/Livewire/Settings/Users.php +++ b/app/Livewire/Settings/Users.php @@ -2,6 +2,7 @@ namespace App\Livewire\Settings; +use App\Enums\Role; use App\Models\AuditEvent; use App\Models\User; use App\Services\SessionService; @@ -13,11 +14,15 @@ use Livewire\Attributes\On; use Livewire\Component; /** - * Multi-user account management. Every account is a full administrator — there are - * no roles. Destructive actions (remove, force-logout) go through the R5 confirm - * modal, which writes one AuditEvent and re-dispatches the apply event back here + * Multi-user account management. Accounts now carry an RBAC role (admin > operator > + * viewer); the mutating actions here are gated behind the `manage-users` ability (admin + * only). Destructive actions (remove, force-logout) and role changes go through the R5 + * confirm modal, which writes one AuditEvent and re-dispatches the apply event back here * (mirrors Settings\Security / Settings\Sessions). * + * The last-admin invariant is enforced everywhere: the sole remaining admin can neither + * be removed nor demoted, so the panel can never be stranded with zero administrators. + * * The current operator is always recorded as the actor on each audit row. */ class Users extends Component @@ -25,6 +30,8 @@ class Users extends Component /** Open the CreateUser modal; it dispatches `usersChanged` on success. */ public function create(): void { + abort_unless(auth()->user()?->can('manage-users'), 403); + $this->dispatch('openModal', component: 'modals.create-user'); } @@ -35,6 +42,8 @@ class Users extends Component */ public function confirmRemove(int $userId): void { + abort_unless(auth()->user()?->can('manage-users'), 403); + $user = User::find($userId); if (! $user || ! $this->canRemove($user)) { return; @@ -62,6 +71,8 @@ class Users extends Component #[On('userRemoved')] public function remove(string $confirmToken, SessionService $sessions): void { + abort_unless(auth()->user()?->can('manage-users'), 403); + try { $payload = ConfirmToken::consume($confirmToken, 'userRemoved'); } catch (InvalidConfirmToken) { @@ -74,6 +85,8 @@ class Users extends Component // Atomic: lock the user rows + recount inside the transaction so two concurrent // deletes can't both pass the "more than one account" check and strand the // install with zero users (a lock-out). The last account is never removable. + // Sentinel: null = silent no-op (self / last account / gone); 'last_admin' = + // refused because it would strand zero admins → surface the guard toast. $email = DB::transaction(function () use ($userId, $self, $sessions): ?string { if (User::lockForUpdate()->count() <= 1) { return null; @@ -84,6 +97,14 @@ class Users extends Component return null; } + // Last-admin invariant: removing the sole administrator would leave the panel + // with zero admins (no one can manage users again). Lock + recount admins in + // the same transaction so concurrent removes can't both slip past the check. + if ($user->role === Role::Admin + && User::where('role', Role::Admin->value)->lockForUpdate()->count() <= 1) { + return 'last_admin'; + } + $email = $user->email; // Kill every session + rotate the remember_token BEFORE deleting, so a @@ -99,6 +120,12 @@ class Users extends Component return; } + if ($email === 'last_admin') { + $this->dispatch('notify', message: __('accounts.last_admin_guard'), level: 'error'); + + return; + } + $this->audit('user.delete', $email); $this->dispatch('notify', message: __('accounts.remove_notify')); } @@ -109,6 +136,8 @@ class Users extends Component */ public function confirmLogout(int $userId): void { + abort_unless(auth()->user()?->can('manage-users'), 403); + $user = User::find($userId); if (! $user || $user->is(Auth::user())) { return; @@ -135,6 +164,8 @@ class Users extends Component #[On('userLoggedOut')] public function logout(string $confirmToken, SessionService $sessions): void { + abort_unless(auth()->user()?->can('manage-users'), 403); + try { $payload = ConfirmToken::consume($confirmToken, 'userLoggedOut'); } catch (InvalidConfirmToken) { @@ -153,6 +184,103 @@ class Users extends Component $this->dispatch('notify', message: __('accounts.logout_notify')); } + /** + * Sensitive (R5): open the confirm modal for changing another account's role. The + * new role must be a valid Role case; the apply handler re-checks the last-admin + * invariant so a stale event can never demote the sole admin after the modal opens. + */ + public function confirmRoleChange(int $userId, string $role): void + { + abort_unless(auth()->user()?->can('manage-users'), 403); + + $user = User::find($userId); + if (! $user || ! in_array($role, array_column(Role::cases(), 'value'), true)) { + return; + } + + $this->dispatch('openModal', + component: 'modals.confirm-action', + arguments: [ + 'heading' => __('accounts.role_change_confirm_heading'), + 'body' => __('accounts.role_change_confirm_body', [ + 'name' => $user->name, + 'role' => Role::from($role)->label(), + ]), + 'confirmLabel' => __('accounts.role_change_confirm'), + 'danger' => false, + 'icon' => 'shield', + // Defer the toast: the apply handler reports the real outcome. + 'notify' => '', + 'token' => ConfirmToken::issue( + 'userRoleChanged', + ['userId' => $userId, 'role' => $role], + auditTarget: $user->email, + ), + ], + ); + } + + #[On('userRoleChanged')] + public function applyRoleChange(string $confirmToken): void + { + abort_unless(auth()->user()?->can('manage-users'), 403); + + try { + $payload = ConfirmToken::consume($confirmToken, 'userRoleChanged'); + } catch (InvalidConfirmToken) { + return; // forged / replayed / direct-bypass attempt — no-op + } + $userId = $payload['params']['userId']; + $role = $payload['params']['role']; + + // Atomic: lock the target + recount admins inside the transaction so two concurrent + // demotions can't both pass the last-admin check and strand zero administrators. + // Sentinel: null = silent no-op (gone / same role); 'last_admin' = refused because + // it would demote the sole admin → surface the guard toast. + $result = DB::transaction(function () use ($userId, $role): ?string { + $user = User::whereKey($userId)->lockForUpdate()->first(); + if (! $user) { + return null; + } + + $newRole = Role::from($role); + + // Last-admin invariant: never demote the sole administrator away from admin. + if ($user->role === Role::Admin + && $newRole !== Role::Admin + && User::where('role', Role::Admin->value)->lockForUpdate()->count() <= 1) { + return 'last_admin'; + } + + $user->role = $newRole; + $user->save(); + + AuditEvent::create([ + 'user_id' => Auth::id(), + 'actor' => Auth::user()?->name, + 'action' => 'user.role_change', + 'target' => $user->email, + 'ip' => request()->ip(), + 'meta' => ['to' => $role], + ]); + + return $user->email; + }); + + if ($result === null) { + return; + } + + if ($result === 'last_admin') { + $this->dispatch('notify', message: __('accounts.last_admin_guard'), level: 'error'); + + return; + } + + $this->dispatch('usersChanged'); + $this->dispatch('notify', message: __('accounts.role_changed_toast')); + } + /** Reload the list after the CreateUser modal adds an account. */ #[On('usersChanged')] public function refreshList(): void @@ -161,9 +289,10 @@ class Users extends Component } /** - * A user may be removed unless it is the current operator or the last - * remaining account (no lock-out). Mirrors the create-user "equal admins" - * model — anyone can remove anyone else. + * A user may be offered the remove control unless it is the current operator or the + * last remaining account (no lock-out). This is only the UI gate; the removal itself + * is restricted to admins (`manage-users`) and the apply handler additionally refuses + * to strand zero administrators (last-admin invariant). */ private function canRemove(User $user): bool { diff --git a/app/Support/Confirm/ConfirmToken.php b/app/Support/Confirm/ConfirmToken.php index 465c380..06852c5 100644 --- a/app/Support/Confirm/ConfirmToken.php +++ b/app/Support/Confirm/ConfirmToken.php @@ -32,9 +32,11 @@ use JsonException; * - the token is bound to the issuing operator (uid), so it cannot be replayed by * another account. * - * All accounts are equal admins (no RBAC); this is an integrity/confirmation control, - * not an authorization tier. Server-scoped actions additionally seal the server id — - * the handler must match it against the server it is about to act on. + * RBAC now exists (admin > operator > viewer); the mutating actions guard the + * `manage-users`/`operate`/etc. abilities themselves. ConfirmToken remains an orthogonal + * integrity/confirmation control — it proves a human passed through the modal and seals the + * routing/audit descriptor, it does NOT decide authorization. Server-scoped actions + * additionally seal the server id — the handler must match it against the server it acts on. */ class ConfirmToken { @@ -43,6 +45,7 @@ class ConfirmToken 'serviceConfirmed', 'userRemoved', 'userLoggedOut', + 'userRoleChanged', 'domainChanged', 'tlsModeChanged', 'fileConfirmed', diff --git a/lang/de/accounts.php b/lang/de/accounts.php index aa04f83..2861ec0 100644 --- a/lang/de/accounts.php +++ b/lang/de/accounts.php @@ -1,11 +1,11 @@ Operator > Betrachter). Eine Gruppe pro Feature (R16); geteilte Buttons in common.php. return [ // Panel header 'title' => 'Konten', - 'subtitle' => 'Jedes Konto ist vollwertiger Administrator', + 'subtitle' => 'Konten mit Rollen: Administrator, Operator, Betrachter', 'create' => 'Konto hinzufügen', // List @@ -14,7 +14,7 @@ return [ // Create modal 'create_title' => 'Konto hinzufügen', - 'create_subtitle' => 'Vollwertiger Administrator. Setze ein Passwort — oder lass es leer, dann wird ein Initialpasswort erzeugt und einmalig angezeigt.', + 'create_subtitle' => 'Wähle eine Rolle. Setze ein Passwort — oder lass es leer, dann wird ein Initialpasswort erzeugt und einmalig angezeigt.', 'name_label' => 'Name', 'name_placeholder' => 'Erika Mustermann', 'email_label' => 'E-Mail', @@ -25,6 +25,19 @@ return [ 'created_notify' => 'Konto :name angelegt.', 'create_submit' => 'Konto anlegen', + // Role (badge, selector, hint) + 'role_label' => 'Rolle', + 'role_hint' => 'Administratoren steuern das gesamte Panel; Operatoren führen den Betrieb; Betrachter sehen nur.', + + // Role change (R5 confirm) + 'role_change_confirm' => 'Rolle ändern', + 'role_change_confirm_heading' => 'Rolle ändern', + 'role_change_confirm_body' => 'Setzt die Rolle von :name auf :role. Berechtigungen ändern sich sofort.', + 'role_changed_toast' => 'Rolle geändert.', + + // Last-admin invariant + 'last_admin_guard' => 'Der letzte Administrator kann nicht herabgestuft oder entfernt werden.', + // Temp-password reveal (shown once) 'temp_heading' => 'Konto angelegt', 'temp_intro' => 'Gib dieses Initialpasswort an :name weiter. Es sollte beim ersten Anmelden geändert werden.', diff --git a/lang/de/audit.php b/lang/de/audit.php index bda1768..8e33f62 100644 --- a/lang/de/audit.php +++ b/lang/de/audit.php @@ -70,6 +70,7 @@ return [ 'user.create' => 'Benutzer angelegt', 'user.delete' => 'Benutzer gelöscht', 'user.logout' => 'Sitzung beendet', + 'user.role_change' => 'Rolle geändert', 'webauthn.register' => 'Sicherheitsschlüssel registriert', 'webauthn.remove' => 'Sicherheitsschlüssel entfernt', 'wg.setup' => 'WireGuard eingerichtet', diff --git a/lang/en/accounts.php b/lang/en/accounts.php index 6151fa0..3c2347d 100644 --- a/lang/en/accounts.php +++ b/lang/en/accounts.php @@ -1,11 +1,11 @@ operator > viewer). One group per feature (R16); shared buttons live in common.php. return [ // Panel header 'title' => 'Accounts', - 'subtitle' => 'Every account is a full administrator', + 'subtitle' => 'Accounts with roles: admin, operator, viewer', 'create' => 'Add account', // List @@ -14,7 +14,7 @@ return [ // Create modal 'create_title' => 'Add account', - 'create_subtitle' => 'Full administrator. Set a password — or leave it blank to generate a initial password shown once.', + 'create_subtitle' => 'Pick a role. Set a password — or leave it blank to generate an initial password shown once.', 'name_label' => 'Name', 'name_placeholder' => 'Jane Doe', 'email_label' => 'Email', @@ -25,6 +25,19 @@ return [ 'created_notify' => 'Account :name created.', 'create_submit' => 'Create account', + // Role (badge, selector, hint) + 'role_label' => 'Role', + 'role_hint' => 'Admins control the whole panel; operators run operations; viewers only look.', + + // Role change (R5 confirm) + 'role_change_confirm' => 'Change role', + 'role_change_confirm_heading' => 'Change role', + 'role_change_confirm_body' => 'Sets :name’s role to :role. Permissions change immediately.', + 'role_changed_toast' => 'Role changed.', + + // Last-admin invariant + 'last_admin_guard' => 'The last administrator cannot be demoted or removed.', + // Temp-password reveal (shown once) 'temp_heading' => 'Account created', 'temp_intro' => 'Share this initial password with :name. They are prompted to change it on first sign-in.', diff --git a/lang/en/audit.php b/lang/en/audit.php index dc3a7b2..4705774 100644 --- a/lang/en/audit.php +++ b/lang/en/audit.php @@ -70,6 +70,7 @@ return [ 'user.create' => 'User created', 'user.delete' => 'User deleted', 'user.logout' => 'Session ended', + 'user.role_change' => 'Role changed', 'webauthn.register' => 'Security key registered', 'webauthn.remove' => 'Security key removed', 'wg.setup' => 'WireGuard set up', diff --git a/resources/views/livewire/modals/create-user.blade.php b/resources/views/livewire/modals/create-user.blade.php index 64ae603..e44b047 100644 --- a/resources/views/livewire/modals/create-user.blade.php +++ b/resources/views/livewire/modals/create-user.blade.php @@ -25,6 +25,18 @@ @error('email')
{{ __('accounts.role_hint') }}
+{{ $user->email }}
{{ __('accounts.none') }}
diff --git a/tests/Feature/RbacUsersTest.php b/tests/Feature/RbacUsersTest.php new file mode 100644 index 0000000..d61dc31 --- /dev/null +++ b/tests/Feature/RbacUsersTest.php @@ -0,0 +1,209 @@ +{$state}()->create(); + + Livewire::actingAs($user)->test(Users::class) + ->call('create') + ->assertForbidden(); + } + } + + public function test_non_admin_cannot_confirm_remove(): void + { + $operator = User::factory()->operator()->create(); + $victim = User::factory()->create(); + + Livewire::actingAs($operator)->test(Users::class) + ->call('confirmRemove', $victim->id) + ->assertForbidden(); + } + + public function test_non_admin_cannot_confirm_role_change(): void + { + $viewer = User::factory()->viewer()->create(); + $target = User::factory()->operator()->create(); + + Livewire::actingAs($viewer)->test(Users::class) + ->call('confirmRoleChange', $target->id, 'viewer') + ->assertForbidden(); + } + + public function test_admin_may_open_create_and_confirm_role_change(): void + { + $admin = User::factory()->create(); + $target = User::factory()->operator()->create(); + + Livewire::actingAs($admin)->test(Users::class) + ->call('create') + ->assertOk() + ->assertDispatched('openModal'); + + Livewire::actingAs($admin)->test(Users::class) + ->call('confirmRoleChange', $target->id, 'viewer') + ->assertOk() + ->assertDispatched('openModal'); + } + + // ── applyRoleChange: happy paths ───────────────────────────────────────────────── + + public function test_admin_demotes_operator_to_viewer_and_audits(): void + { + $admin = User::factory()->create(); + $target = User::factory()->operator()->create(['email' => 'op@example.com']); + + $this->actingAs($admin); + $token = $this->confirmedToken('userRoleChanged', ['userId' => $target->id, 'role' => 'viewer']); + + Livewire::actingAs($admin)->test(Users::class) + ->call('applyRoleChange', $token) + ->assertDispatched('usersChanged'); + + $this->assertSame(Role::Viewer, $target->fresh()->role); + $this->assertDatabaseHas('audit_events', [ + 'action' => 'user.role_change', + 'target' => 'op@example.com', + 'user_id' => $admin->id, + ]); + } + + public function test_admin_promotes_operator_to_admin(): void + { + $admin = User::factory()->create(); + $target = User::factory()->operator()->create(); + + $this->actingAs($admin); + $token = $this->confirmedToken('userRoleChanged', ['userId' => $target->id, 'role' => 'admin']); + + Livewire::actingAs($admin)->test(Users::class) + ->call('applyRoleChange', $token); + + $this->assertSame(Role::Admin, $target->fresh()->role); + } + + // ── Last-admin invariant ───────────────────────────────────────────────────────── + + public function test_sole_admin_cannot_be_demoted(): void + { + // Exactly one admin (self); an operator keeps the account count > 1. + $admin = User::factory()->create(); + User::factory()->operator()->create(); + + $this->actingAs($admin); + $token = $this->confirmedToken('userRoleChanged', ['userId' => $admin->id, 'role' => 'viewer']); + + Livewire::actingAs($admin)->test(Users::class) + ->call('applyRoleChange', $token) + ->assertNotDispatched('usersChanged'); + + // Role unchanged — the sole admin stays admin, no exception thrown. + $this->assertSame(Role::Admin, $admin->fresh()->role); + $this->assertSame(0, AuditEvent::where('action', 'user.role_change')->count()); + } + + public function test_one_of_two_admins_may_be_demoted(): void + { + $admin = User::factory()->create(); + $other = User::factory()->create(); // second admin (factory default role) + + $this->actingAs($admin); + $token = $this->confirmedToken('userRoleChanged', ['userId' => $other->id, 'role' => 'operator']); + + Livewire::actingAs($admin)->test(Users::class) + ->call('applyRoleChange', $token) + ->assertDispatched('usersChanged'); + + $this->assertSame(Role::Operator, $other->fresh()->role); + } + + public function test_removing_the_sole_admin_is_refused_and_admin_survives(): void + { + // Two accounts so the last-ACCOUNT guard passes, but only one admin (self). The + // acting admin removing itself is caught by the self-guard first; the last-admin + // removal guard is the defense-in-depth behind it. Either way: the admin survives. + $admin = User::factory()->create(); + User::factory()->operator()->create(); + + $this->actingAs($admin); + $token = $this->confirmedToken('userRemoved', ['userId' => $admin->id]); + + Livewire::actingAs($admin)->test(Users::class) + ->call('remove', $token); + + $this->assertDatabaseHas('users', ['id' => $admin->id]); + $this->assertSame(Role::Admin, $admin->fresh()->role); + } + + // ── CreateUser modal: admin-only, default role, validation ─────────────────────── + + public function test_non_admin_cannot_mount_create_user(): void + { + $operator = User::factory()->operator()->create(); + + Livewire::actingAs($operator)->test(CreateUser::class) + ->assertForbidden(); + } + + public function test_admin_creates_user_defaulting_to_operator(): void + { + $admin = User::factory()->create(); + + Livewire::actingAs($admin)->test(CreateUser::class) + ->set('name', 'New Op') + ->set('email', 'newop@example.com') + ->call('save') + ->assertHasNoErrors(); + + $new = User::where('email', 'newop@example.com')->first(); + $this->assertNotNull($new); + $this->assertSame(Role::Operator, $new->role); + } + + public function test_create_user_role_validation_rejects_invalid_role(): void + { + $admin = User::factory()->create(); + + Livewire::actingAs($admin)->test(CreateUser::class) + ->set('name', 'Bad Role') + ->set('email', 'badrole@example.com') + ->set('role', 'superuser') + ->call('save') + ->assertHasErrors('role'); + + $this->assertNull(User::where('email', 'badrole@example.com')->first()); + } +}