From b5292011868ea2e791948f4c041466ac3110e50d Mon Sep 17 00:00:00 2001 From: boban Date: Sun, 5 Jul 2026 01:06:06 +0200 Subject: [PATCH] feat(rbac): gate service + file mutations behind operate (viewer read-only) Co-Authored-By: Claude Opus 4.8 --- app/Livewire/Files/Index.php | 6 + app/Livewire/Modals/FileEditor.php | 2 + app/Livewire/Services/Index.php | 4 + tests/Feature/RbacOperateGateTest.php | 282 ++++++++++++++++++++++++++ 4 files changed, 294 insertions(+) create mode 100644 tests/Feature/RbacOperateGateTest.php diff --git a/app/Livewire/Files/Index.php b/app/Livewire/Files/Index.php index 0e50e04..fda2e35 100644 --- a/app/Livewire/Files/Index.php +++ b/app/Livewire/Files/Index.php @@ -80,6 +80,8 @@ class Index extends Component /** Upload the selected file into the current directory (SFTP put). */ public function updatedUpload(FleetService $fleet): void { + abort_unless(auth()->user()?->can('operate'), 403); + $this->validate(['upload' => ['file', 'max:51200']]); // 50 MB $active = $this->activeServer(); @@ -167,6 +169,8 @@ class Index extends Component */ public function confirmDelete(int $index): void { + abort_unless(auth()->user()?->can('operate'), 403); + $name = $this->entries[$index]['name'] ?? null; if ($name === null) { return; @@ -200,6 +204,8 @@ class Index extends Component #[On('fileConfirmed')] public function deleteEntry(string $confirmToken, FleetService $fleet): void { + abort_unless(auth()->user()?->can('operate'), 403); + try { $payload = ConfirmToken::consume($confirmToken, 'fileConfirmed'); } catch (InvalidConfirmToken) { diff --git a/app/Livewire/Modals/FileEditor.php b/app/Livewire/Modals/FileEditor.php index 386f9db..c22cee3 100644 --- a/app/Livewire/Modals/FileEditor.php +++ b/app/Livewire/Modals/FileEditor.php @@ -136,6 +136,8 @@ class FileEditor extends ModalComponent public function save(FleetService $fleet): void { + abort_unless(auth()->user()?->can('operate'), 403); + $this->error = null; // Images are a read-only preview — never write them back. diff --git a/app/Livewire/Services/Index.php b/app/Livewire/Services/Index.php index 7dfa9b9..48fc8fb 100644 --- a/app/Livewire/Services/Index.php +++ b/app/Livewire/Services/Index.php @@ -105,6 +105,8 @@ class Index extends Component */ public function confirm(string $op, int $index): void { + abort_unless(auth()->user()?->can('operate'), 403); + $name = $this->filteredServices[$index]['name'] ?? null; if ($name === null) { return; @@ -140,6 +142,8 @@ class Index extends Component #[On('serviceConfirmed')] public function applyService(string $confirmToken, FleetService $fleet): void { + abort_unless(auth()->user()?->can('operate'), 403); + try { $payload = ConfirmToken::consume($confirmToken, 'serviceConfirmed'); } catch (InvalidConfirmToken) { diff --git a/tests/Feature/RbacOperateGateTest.php b/tests/Feature/RbacOperateGateTest.php new file mode 100644 index 0000000..f082fb7 --- /dev/null +++ b/tests/Feature/RbacOperateGateTest.php @@ -0,0 +1,282 @@ +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]); + } + + /** An online server with a credential, selected as the active fleet server. */ + private function activeServer(): Server + { + $server = Server::create(['name' => 'box', 'ip' => '10.0.0.2', 'ssh_port' => 22, 'status' => 'online']); + $server->credential()->create(['username' => 'root', 'auth_type' => 'password', 'secret' => 'x']); + session(['active_server_id' => $server->id]); + + return $server; + } + + /** A confirmed, single-use token for the given apply handler + server. */ + private function confirmed(string $event, array $params, int $serverId): string + { + $token = ConfirmToken::issue($event, $params, '', null, $serverId); + ConfirmToken::confirm($token); // the modal confirmation step + + return $token; + } + + // ── operate: Services\Index::applyService (systemctl restart) ──────────────── + + public function test_viewer_cannot_apply_a_service_action(): void + { + $this->actingAs($this->viewer()); + $server = $this->activeServer(); + + // The privileged SSH call must never be reached — the guard fires first. + $fleet = Mockery::mock(FleetService::class); + $fleet->shouldReceive('serviceAction')->never(); + app()->instance(FleetService::class, $fleet); + + $token = $this->confirmed('serviceConfirmed', ['op' => 'restart', 'name' => 'nginx.service'], $server->id); + + Livewire::test(Services::class) + ->call('applyService', $token) + ->assertForbidden(); + } + + public function test_operator_can_apply_a_service_action(): void + { + $this->actingAs($this->operator()); + $server = $this->activeServer(); + + $fleet = Mockery::mock(FleetService::class); + $fleet->shouldReceive('serviceAction')->once() + ->with(Mockery::type(Server::class), 'restart', 'nginx.service') + ->andReturn(['ok' => true, 'output' => '']); + $fleet->shouldReceive('systemd')->andReturn(['services' => []]); // reload after success + app()->instance(FleetService::class, $fleet); + + $token = $this->confirmed('serviceConfirmed', ['op' => 'restart', 'name' => 'nginx.service'], $server->id); + + Livewire::test(Services::class) + ->call('applyService', $token) + ->assertOk(); // allowed through the guard, SSH mocked away + } + + public function test_admin_can_apply_a_service_action(): void + { + $this->actingAs($this->admin()); + $server = $this->activeServer(); + + $fleet = Mockery::mock(FleetService::class); + $fleet->shouldReceive('serviceAction')->once() + ->with(Mockery::type(Server::class), 'restart', 'nginx.service') + ->andReturn(['ok' => true, 'output' => '']); + $fleet->shouldReceive('systemd')->andReturn(['services' => []]); + app()->instance(FleetService::class, $fleet); + + $token = $this->confirmed('serviceConfirmed', ['op' => 'restart', 'name' => 'nginx.service'], $server->id); + + Livewire::test(Services::class) + ->call('applyService', $token) + ->assertOk(); + } + + // ── operate: Files\Index::deleteEntry (SFTP unlink) ───────────────────────── + + public function test_viewer_cannot_delete_a_file(): void + { + $this->actingAs($this->viewer()); + $server = $this->activeServer(); + + $fleet = Mockery::mock(FleetService::class); + $fleet->shouldReceive('files')->andReturn([]); // any dir listing during mount/load + $fleet->shouldReceive('deleteFile')->never(); // guard fires before the SFTP unlink + app()->instance(FleetService::class, $fleet); + + $token = $this->confirmed('fileConfirmed', ['path' => '/etc/passwd'], $server->id); + + Livewire::test(Files::class) + ->call('deleteEntry', $token) + ->assertForbidden(); + } + + public function test_operator_can_delete_a_file(): void + { + $this->actingAs($this->operator()); + $server = $this->activeServer(); + + $fleet = Mockery::mock(FleetService::class); + $fleet->shouldReceive('files')->andReturn([]); + $fleet->shouldReceive('deleteFile')->once() + ->with(Mockery::type(Server::class), '/etc/passwd'); + app()->instance(FleetService::class, $fleet); + + $token = $this->confirmed('fileConfirmed', ['path' => '/etc/passwd'], $server->id); + + Livewire::test(Files::class) + ->call('deleteEntry', $token) + ->assertOk(); // allowed through the guard, SFTP mocked away + } + + public function test_admin_can_delete_a_file(): void + { + $this->actingAs($this->admin()); + $server = $this->activeServer(); + + $fleet = Mockery::mock(FleetService::class); + $fleet->shouldReceive('files')->andReturn([]); + $fleet->shouldReceive('deleteFile')->once() + ->with(Mockery::type(Server::class), '/etc/passwd'); + app()->instance(FleetService::class, $fleet); + + $token = $this->confirmed('fileConfirmed', ['path' => '/etc/passwd'], $server->id); + + Livewire::test(Files::class) + ->call('deleteEntry', $token) + ->assertOk(); + } + + // ── operate: Modals\FileEditor::save (SFTP write) ─────────────────────────── + + public function test_viewer_cannot_save_a_file_edit(): void + { + $this->actingAs($this->viewer()); + $server = Server::create(['name' => 's', 'ip' => '10.0.0.9', 'ssh_port' => 22, 'status' => 'online']); + + $fleet = Mockery::mock(FleetService::class); + $fleet->shouldReceive('writeFile')->never(); // guard fires before the remote write + $this->app->instance(FleetService::class, $fleet); + $this->app->instance(Sftp::class, Mockery::mock(Sftp::class)); // resolvable, unused for text + + Livewire::test(FileEditor::class, ['serverId' => $server->id, 'path' => '/etc/x.conf', 'name' => 'x.conf']) + ->set('content', 'edited = wert') + ->call('save') + ->assertForbidden(); + } + + public function test_operator_can_save_a_file_edit(): void + { + $this->actingAs($this->operator()); + $server = Server::create(['name' => 's', 'ip' => '10.0.0.9', 'ssh_port' => 22, 'status' => 'online']); + + $fleet = Mockery::mock(FleetService::class); + $fleet->shouldReceive('writeFile')->once() + ->with(Mockery::type(Server::class), '/etc/x.conf', 'edited = wert'); + $this->app->instance(FleetService::class, $fleet); + $this->app->instance(Sftp::class, Mockery::mock(Sftp::class)); + + Livewire::test(FileEditor::class, ['serverId' => $server->id, 'path' => '/etc/x.conf', 'name' => 'x.conf']) + ->set('content', 'edited = wert') + ->call('save') + ->assertOk(); // allowed through the guard, SFTP mocked away + } + + public function test_admin_can_save_a_file_edit(): void + { + $this->actingAs($this->admin()); + $server = Server::create(['name' => 's', 'ip' => '10.0.0.9', 'ssh_port' => 22, 'status' => 'online']); + + $fleet = Mockery::mock(FleetService::class); + $fleet->shouldReceive('writeFile')->once() + ->with(Mockery::type(Server::class), '/etc/x.conf', 'edited = wert'); + $this->app->instance(FleetService::class, $fleet); + $this->app->instance(Sftp::class, Mockery::mock(Sftp::class)); + + Livewire::test(FileEditor::class, ['serverId' => $server->id, 'path' => '/etc/x.conf', 'name' => 'x.conf']) + ->set('content', 'edited = wert') + ->call('save') + ->assertOk(); + } + + // ── read-only: a viewer keeps READ access (proves read-only, not locked-out) ─ + + public function test_viewer_can_load_the_services_list(): void + { + $this->actingAs($this->viewer()); + $server = $this->activeServer(); + + $fleet = Mockery::mock(FleetService::class); + $fleet->shouldReceive('systemd')->andReturn([ + 'services' => [['name' => 'nginx.service', 'status' => 'online', 'sub' => 'running', 'enabled' => true, 'desc' => 'web']], + 'journal' => [], + 'cursor' => null, + ]); + app()->instance(FleetService::class, $fleet); + + Livewire::test(Services::class) + ->assertOk() // page mounts for a viewer + ->call('load') // the lazy read runs + ->assertOk() + ->assertSet('connected', true) + ->assertSet('services.0.name', 'nginx.service'); // the list is visible + } + + public function test_viewer_can_browse_the_file_manager(): void + { + $this->actingAs($this->viewer()); + $this->activeServer(); + + $fleet = Mockery::mock(FleetService::class); + $fleet->shouldReceive('files')->andReturn([ + ['name' => 'etc', 'type' => 'dir', 'size' => null, 'perms' => 'drwxr-xr-x', 'owner' => 'root', 'modified' => 'now'], + ]); + app()->instance(FleetService::class, $fleet); + + Livewire::test(Files::class) + ->assertOk() // page mounts for a viewer + ->call('load') // the lazy directory read runs + ->assertOk() + ->assertSet('connected', true) + ->assertSet('entries.0.name', 'etc'); // the listing is visible + } +}