From 25ded63d8786378c4df82573e02abdb3ecff9074 Mon Sep 17 00:00:00 2001 From: boban Date: Sun, 5 Jul 2026 01:01:24 +0200 Subject: [PATCH] feat(rbac): gate fleet, host terminal + domain/TLS actions (manage-fleet/operate/manage-panel) Co-Authored-By: Claude Opus 4.8 --- app/Livewire/Modals/AddSshKey.php | 6 + app/Livewire/Modals/CreateServer.php | 10 + app/Livewire/Modals/EditCredential.php | 6 + app/Livewire/Modals/HostShell.php | 9 + app/Livewire/Modals/SshKeyProvision.php | 7 + app/Livewire/Modals/SystemUpdate.php | 6 + app/Livewire/Servers/Show.php | 14 ++ app/Livewire/System/Index.php | 14 ++ app/Livewire/Terminal/Index.php | 7 + tests/Feature/RbacFleetGateTest.php | 264 ++++++++++++++++++++++++ 10 files changed, 343 insertions(+) create mode 100644 tests/Feature/RbacFleetGateTest.php diff --git a/app/Livewire/Modals/AddSshKey.php b/app/Livewire/Modals/AddSshKey.php index 4bcbc7d..6331ebb 100644 --- a/app/Livewire/Modals/AddSshKey.php +++ b/app/Livewire/Modals/AddSshKey.php @@ -28,6 +28,9 @@ class AddSshKey extends ModalComponent public function mount(int $serverId): void { + // Installing an authorized_keys entry over SSH is a fleet-admin action. + abort_unless(auth()->user()?->can('manage-fleet'), 403); + $this->serverId = $serverId; } @@ -72,6 +75,9 @@ class AddSshKey extends ModalComponent public function save(FleetService $fleet): void { + // Re-gate on save: refuse a demoted user / hand-crafted /livewire/update after mount. + abort_unless(auth()->user()?->can('manage-fleet'), 403); + $this->error = null; $server = Server::find($this->serverId); diff --git a/app/Livewire/Modals/CreateServer.php b/app/Livewire/Modals/CreateServer.php index 43dda88..cde5858 100644 --- a/app/Livewire/Modals/CreateServer.php +++ b/app/Livewire/Modals/CreateServer.php @@ -36,6 +36,12 @@ class CreateServer extends ModalComponent public string $credentialName = ''; + public function mount(): void + { + // Registering a server + depositing its SSH login is a fleet-admin action. + abort_unless(auth()->user()?->can('manage-fleet'), 403); + } + public static function modalMaxWidth(): string { return 'lg'; @@ -87,6 +93,10 @@ class CreateServer extends ModalComponent public function save(): void { + // Re-gate on save: mount()'s guard is not enough — a demotion mid-session or a + // hand-crafted /livewire/update must still be refused at the write. + abort_unless(auth()->user()?->can('manage-fleet'), 403); + $data = $this->validate(); // Atomic: create server + credential, then VERIFY the SSH login. A failed diff --git a/app/Livewire/Modals/EditCredential.php b/app/Livewire/Modals/EditCredential.php index ee6ca10..911521a 100644 --- a/app/Livewire/Modals/EditCredential.php +++ b/app/Livewire/Modals/EditCredential.php @@ -29,6 +29,9 @@ class EditCredential extends ModalComponent public function mount(int $serverId): void { + // Depositing/updating a server's SSH login is a fleet-admin action. + abort_unless(auth()->user()?->can('manage-fleet'), 403); + $this->serverId = $serverId; if ($cred = Server::find($serverId)?->credential) { @@ -46,6 +49,9 @@ class EditCredential extends ModalComponent public function save(): void { + // Re-gate on save: refuse a demoted user / hand-crafted /livewire/update after mount. + abort_unless(auth()->user()?->can('manage-fleet'), 403); + $this->error = null; $server = Server::find($this->serverId); diff --git a/app/Livewire/Modals/HostShell.php b/app/Livewire/Modals/HostShell.php index f67639b..c623fa7 100644 --- a/app/Livewire/Modals/HostShell.php +++ b/app/Livewire/Modals/HostShell.php @@ -41,6 +41,9 @@ class HostShell extends ModalComponent public function mount(): void { + // The host SSH login is the Clusev machine itself — the most dangerous credential. + abort_unless(auth()->user()?->can('manage-fleet'), 403); + $hc = HostCredential::current(); if ($hc !== null) { $this->configured = true; @@ -82,6 +85,9 @@ class HostShell extends ModalComponent public function save(): void { + // Re-gate on save: refuse a demoted user / hand-crafted /livewire/update after mount. + abort_unless(auth()->user()?->can('manage-fleet'), 403); + $data = $this->validate(); $existing = HostCredential::current(); @@ -120,6 +126,9 @@ class HostShell extends ModalComponent public function remove(): void { + // Re-gate on remove: refuse a demoted user / hand-crafted /livewire/update after mount. + abort_unless(auth()->user()?->can('manage-fleet'), 403); + $hc = HostCredential::current(); if ($hc !== null) { $target = $hc->username.'@'.$hc->host.':'.$hc->port; diff --git a/app/Livewire/Modals/SshKeyProvision.php b/app/Livewire/Modals/SshKeyProvision.php index 500abb7..8e5226e 100644 --- a/app/Livewire/Modals/SshKeyProvision.php +++ b/app/Livewire/Modals/SshKeyProvision.php @@ -31,6 +31,10 @@ class SshKeyProvision extends ModalComponent public function mount(int $serverId): void { + // Rotating to key-only access (installs a key, switches the credential, disables + // password login) over SSH is a fleet-admin action. + abort_unless(auth()->user()?->can('manage-fleet'), 403); + $this->serverId = $serverId; $this->serverName = Server::find($serverId)?->name ?? ''; } @@ -42,6 +46,9 @@ class SshKeyProvision extends ModalComponent public function run(SshKeyProvisioner $provisioner): void { + // Re-gate on run: refuse a demoted user / hand-crafted /livewire/update after mount. + abort_unless(auth()->user()?->can('manage-fleet'), 403); + if ($this->done) { return; } diff --git a/app/Livewire/Modals/SystemUpdate.php b/app/Livewire/Modals/SystemUpdate.php index ddcafc2..b36ba63 100644 --- a/app/Livewire/Modals/SystemUpdate.php +++ b/app/Livewire/Modals/SystemUpdate.php @@ -42,6 +42,9 @@ class SystemUpdate extends ModalComponent public function mount(int $serverId, MaintenanceService $maintenance): void { + // Applying apt/dnf/zypper package updates on a managed host over SSH is a fleet-admin action. + abort_unless(auth()->user()?->can('manage-fleet'), 403); + $this->serverId = $serverId; $server = Server::find($serverId); @@ -69,6 +72,9 @@ class SystemUpdate extends ModalComponent public function upgrade(MaintenanceService $maintenance): void { + // Re-gate on upgrade: refuse a demoted user / hand-crafted /livewire/update after mount. + abort_unless(auth()->user()?->can('manage-fleet'), 403); + if ($this->error !== null || $this->done || ! $this->supported) { return; } diff --git a/app/Livewire/Servers/Show.php b/app/Livewire/Servers/Show.php index b32cf71..c9540cd 100644 --- a/app/Livewire/Servers/Show.php +++ b/app/Livewire/Servers/Show.php @@ -165,6 +165,8 @@ class Show extends Component */ public function confirmKeyRemoval(int $index): void { + abort_unless(auth()->user()?->can('manage-fleet'), 403); + $key = $this->sshKeys[$index] ?? null; if ($key === null) { return; @@ -197,6 +199,10 @@ class Show extends Component #[On('keyRemoved')] public function removeKey(string $confirmToken, FleetService $fleet): void { + // Re-gate on consume: ConfirmToken is uid-bound, not role-bound, so a token issued + // while the user could manage the fleet must still be refused after a demotion. + abort_unless(auth()->user()?->can('manage-fleet'), 403); + try { $payload = ConfirmToken::consume($confirmToken, 'keyRemoved'); } catch (InvalidConfirmToken) { @@ -253,6 +259,8 @@ class Show extends Component */ public function toggleCredential(): void { + abort_unless(auth()->user()?->can('manage-fleet'), 403); + $cred = $this->server->credential; if (! $cred) { return; @@ -280,6 +288,8 @@ class Show extends Component */ public function confirmDeleteCredential(): void { + abort_unless(auth()->user()?->can('manage-fleet'), 403); + $cred = $this->server->credential; if (! $cred) { return; @@ -309,6 +319,10 @@ class Show extends Component #[On('credentialDeleted')] public function deleteCredential(string $confirmToken): void { + // Re-gate on consume: ConfirmToken is uid-bound, not role-bound, so a token issued + // while the user could manage the fleet must still be refused after a demotion. + abort_unless(auth()->user()?->can('manage-fleet'), 403); + try { $payload = ConfirmToken::consume($confirmToken, 'credentialDeleted'); } catch (InvalidConfirmToken) { diff --git a/app/Livewire/System/Index.php b/app/Livewire/System/Index.php index b6f8606..bfc8f7c 100644 --- a/app/Livewire/System/Index.php +++ b/app/Livewire/System/Index.php @@ -62,6 +62,8 @@ class Index extends Component /** Validate, then confirm the domain change via the shared modal (audited there). */ public function confirmDomain(DeploymentService $deployment): void { + abort_unless(auth()->user()?->can('manage-panel'), 403); + $this->validate($this->domainRules(), [ 'domainInput.regex' => __('system.domain_invalid'), 'domainInput.max' => __('system.domain_invalid'), @@ -97,6 +99,10 @@ class Index extends Component #[On('domainChanged')] public function applyDomain(string $confirmToken, DeploymentService $deployment): void { + // Re-gate on consume: ConfirmToken is uid-bound, not role-bound, so a token issued + // while the user could manage the panel must still be refused after a demotion. + abort_unless(auth()->user()?->can('manage-panel'), 403); + try { $payload = ConfirmToken::consume($confirmToken, 'domainChanged'); } catch (InvalidConfirmToken) { @@ -144,6 +150,8 @@ class Index extends Component */ public function requestCertificate(DeploymentService $deployment): void { + abort_unless(auth()->user()?->can('manage-panel'), 403); + $domain = $deployment->domain(); if ($domain === null || $deployment->externalTls()) { @@ -185,6 +193,8 @@ class Index extends Component /** TLS-mode changes are audited (confirmation via the shared modal). */ public function confirmTlsMode(string $mode): void { + abort_unless(auth()->user()?->can('manage-panel'), 403); + if (! in_array($mode, self::TLS_MODES, true) || $mode === $this->tlsMode) { return; } @@ -211,6 +221,10 @@ class Index extends Component #[On('tlsModeChanged')] public function applyTlsMode(string $confirmToken, DeploymentService $deployment): void { + // Re-gate on consume: ConfirmToken is uid-bound, not role-bound, so a token issued + // while the user could manage the panel must still be refused after a demotion. + abort_unless(auth()->user()?->can('manage-panel'), 403); + try { $payload = ConfirmToken::consume($confirmToken, 'tlsModeChanged'); } catch (InvalidConfirmToken) { diff --git a/app/Livewire/Terminal/Index.php b/app/Livewire/Terminal/Index.php index 8de8791..56ff651 100644 --- a/app/Livewire/Terminal/Index.php +++ b/app/Livewire/Terminal/Index.php @@ -37,6 +37,10 @@ class Index extends Component /** Mint a single-use token for the chosen target and hand it to the xterm island via an event. */ public function open(string $kind, ?string $uuid = null): void { + // Split by target: the host shell is a real login into the Clusev machine (admin-only), + // while a server shell is a day-to-day operator action. + abort_unless(auth()->user()?->can($kind === 'host' ? 'manage-fleet' : 'operate'), 403); + $serverId = null; $label = __('terminal.host_label'); $this->activeKey = 'host'; @@ -76,6 +80,9 @@ class Index extends Component /** Open the modal that stores the Clusev host SSH login. */ public function configureHost(): void { + // Configuring the host SSH login is a fleet-admin action (HostShell::mount re-gates too). + abort_unless(auth()->user()?->can('manage-fleet'), 403); + $this->dispatch('openModal', component: 'modals.host-shell'); } diff --git a/tests/Feature/RbacFleetGateTest.php b/tests/Feature/RbacFleetGateTest.php new file mode 100644 index 0000000..5b220e5 --- /dev/null +++ b/tests/Feature/RbacFleetGateTest.php @@ -0,0 +1,264 @@ +create(['must_change_password' => false]); + } + + private function operator(): User + { + return User::factory()->operator()->create(['must_change_password' => false]); + } + + private function viewer(): User + { + return User::factory()->viewer()->create(['must_change_password' => false]); + } + + private function server(string $name = 's', string $ip = '10.0.0.9'): Server + { + return Server::create(['name' => $name, 'ip' => $ip, 'ssh_port' => 22, 'status' => 'online']); + } + + /** A modal-confirmed, server-scoped token (mirrors the real confirm step). */ + private function confirmed(string $event, array $params, ?int $serverId = null): string + { + $token = ConfirmToken::issue($event, $params, '', null, $serverId); + ConfirmToken::confirm($token); + + return $token; + } + + // ── manage-fleet: CreateServer modal (mount + save) ───────────────────────── + + public function test_admin_can_open_and_save_create_server(): void + { + // SSH probe is mocked so the admin path performs no real login. + $this->mock(FleetService::class, fn ($m) => $m->shouldReceive('testConnection')->andReturn(['ok' => true, 'error' => null])); + + Livewire::actingAs($this->admin()) + ->test(CreateServer::class) + ->set('name', 'web-1')->set('ip', '10.0.0.5')->set('sshPort', 22) + ->set('username', 'root')->set('authType', 'password')->set('secret', 'pw') + ->call('save') + ->assertHasNoErrors(); // allowed through, SSH mocked away + + $this->assertSame(1, Server::count()); + } + + public function test_operator_cannot_open_create_server(): void + { + // No FleetService probe should ever be reached — the mount guard fires first. + $this->mock(FleetService::class, fn ($m) => $m->shouldReceive('testConnection')->never()); + + Livewire::actingAs($this->operator()) + ->test(CreateServer::class) + ->assertForbidden(); + + $this->assertSame(0, Server::count()); + } + + public function test_viewer_cannot_open_create_server(): void + { + Livewire::actingAs($this->viewer()) + ->test(CreateServer::class) + ->assertForbidden(); + } + + // ── manage-fleet: Servers\Show::deleteCredential ──────────────────────────── + + public function test_admin_can_delete_a_credential(): void + { + // ConfirmToken binds to the issuing uid — authenticate BEFORE minting it. + $this->actingAs($this->admin()); + + $server = $this->server(); + $server->credential()->create(['username' => 'root', 'auth_type' => 'password', 'secret' => 'x']); + + $token = $this->confirmed('credentialDeleted', [], $server->id); + + Livewire::test(Show::class, ['server' => $server]) + ->call('deleteCredential', $token); + + $this->assertFalse($server->credential()->exists()); // allowed → credential gone + } + + public function test_operator_cannot_delete_a_credential(): void + { + $server = $this->server(); + $server->credential()->create(['username' => 'root', 'auth_type' => 'password', 'secret' => 'x']); + + // The method guard fires before the token is even consumed — no token needed. + Livewire::actingAs($this->operator()) + ->test(Show::class, ['server' => $server]) + ->call('deleteCredential', 'irrelevant') + ->assertForbidden(); + + $this->assertTrue($server->credential()->exists(), 'operator must not be able to delete the credential'); + } + + public function test_viewer_cannot_delete_a_credential(): void + { + $server = $this->server(); + $server->credential()->create(['username' => 'root', 'auth_type' => 'password', 'secret' => 'x']); + + Livewire::actingAs($this->viewer()) + ->test(Show::class, ['server' => $server]) + ->call('deleteCredential', 'irrelevant') + ->assertForbidden(); + + $this->assertTrue($server->credential()->exists()); + } + + // ── manage-fleet vs operate: Terminal\Index::open (split by target) ────────── + + public function test_admin_can_open_host_and_server_shells(): void + { + HostCredential::create([ + 'host' => 'host.docker.internal', 'port' => 22, 'username' => 'root', + 'auth_type' => 'password', 'secret' => 'r00t-pw', + ]); + $server = $this->server(); + + Livewire::actingAs($this->admin()) + ->test(TerminalIndex::class) + ->call('open', 'host')->assertSet('activeKey', 'host') + ->call('open', 'server', $server->uuid)->assertSet('activeKey', $server->uuid); + + $this->assertSame(2, TerminalSession::count()); // both targets minted a session + } + + public function test_operator_cannot_open_the_host_shell(): void + { + HostCredential::create([ + 'host' => 'host.docker.internal', 'port' => 22, 'username' => 'root', + 'auth_type' => 'password', 'secret' => 'r00t-pw', + ]); + + Livewire::actingAs($this->operator()) + ->test(TerminalIndex::class) + ->call('open', 'host') + ->assertForbidden(); // host shell = manage-fleet + + $this->assertSame(0, TerminalSession::count()); + } + + public function test_operator_can_open_a_server_shell(): void + { + $server = $this->server(); + + Livewire::actingAs($this->operator()) + ->test(TerminalIndex::class) + ->call('open', 'server', $server->uuid) + ->assertSet('activeKey', $server->uuid); // server shell = operate → allowed + + $this->assertSame(1, TerminalSession::count()); + } + + public function test_viewer_cannot_open_host_or_server_shell(): void + { + $server = $this->server(); + + Livewire::actingAs($this->viewer()) + ->test(TerminalIndex::class) + ->call('open', 'host') + ->assertForbidden(); + + Livewire::actingAs($this->viewer()) + ->test(TerminalIndex::class) + ->call('open', 'server', $server->uuid) + ->assertForbidden(); + + $this->assertSame(0, TerminalSession::count()); + } + + public function test_operator_cannot_configure_the_host_login(): void + { + Livewire::actingAs($this->operator()) + ->test(TerminalIndex::class) + ->call('configureHost') + ->assertForbidden(); + } + + // ── manage-panel: System\Index domain/TLS mutations ───────────────────────── + + public function test_admin_can_apply_a_domain_change(): void + { + // ConfirmToken binds to the issuing uid — authenticate BEFORE minting it. + $this->actingAs($this->admin()); + + $token = $this->confirmed('domainChanged', ['domain' => 'panel.example.test']); + + Livewire::test(SystemIndex::class) + ->call('applyDomain', $token) + ->assertSet('domainInput', 'panel.example.test'); // allowed → domain persisted + + $this->assertSame('panel.example.test', app(DeploymentService::class)->configuredDomain()); + } + + public function test_operator_cannot_apply_a_domain_change(): void + { + Livewire::actingAs($this->operator()) + ->test(SystemIndex::class) + ->call('applyDomain', 'irrelevant') + ->assertForbidden(); + + $this->assertNull(app(DeploymentService::class)->configuredDomain()); + } + + public function test_operator_cannot_confirm_a_domain_change(): void + { + Livewire::actingAs($this->operator()) + ->test(SystemIndex::class) + ->set('domainInput', 'panel.example.test') + ->call('confirmDomain') + ->assertForbidden(); + } + + public function test_viewer_cannot_apply_a_domain_change(): void + { + Livewire::actingAs($this->viewer()) + ->test(SystemIndex::class) + ->call('applyDomain', 'irrelevant') + ->assertForbidden(); + } +}