diff --git a/app/Livewire/Docker/Index.php b/app/Livewire/Docker/Index.php index 77fef84..4c6562e 100644 --- a/app/Livewire/Docker/Index.php +++ b/app/Livewire/Docker/Index.php @@ -50,20 +50,41 @@ class Index extends Component return __('docker.title'); } - /** Whether a Clusev-host SSH login exists (controls the target switch + the default target). */ + /** Whether a Clusev-host SSH login exists (controls the default target + the setup hint). */ public function hostConfigured(): bool { return HostCredential::current() !== null; } - /** Resolve '' to a concrete target: the host when one is configured, otherwise a fleet server. */ + /** + * Only admins (manage-fleet) may target the Clusev host — the same bar as the host terminal. + * The host is the control-plane machine: a `Stop` on clusev-mariadb would take Clusev down, so + * a viewer/operator must never reach it (they only ever see fleet servers). + */ + public function mayUseHost(): bool + { + return (bool) Auth::user()?->can('manage-fleet'); + } + + /** Show the host/server switch only to users allowed on the host. */ + public function canToggleHost(): bool + { + return $this->mayUseHost(); + } + + /** Resolve the target: the host (admins only) or a fleet server; '' auto-picks the host default. */ public function effectiveTarget(): string { - if ($this->target === 'host' || $this->target === 'server') { - return $this->target; + // An explicit host selection is honoured only for permitted users; everyone else is pinned + // to a fleet server so a viewer/operator can never reach the control-plane machine. + if ($this->target === 'host' && $this->mayUseHost()) { + return 'host'; + } + if ($this->target === 'server') { + return 'server'; } - return $this->hostConfigured() ? 'host' : 'server'; + return ($this->mayUseHost() && $this->hostConfigured()) ? 'host' : 'server'; } /** The Server the current target resolves to (transient host server, or the active fleet server). */ @@ -125,6 +146,11 @@ class Index extends Component abort_unless(Auth::user()?->can('operate'), 403); $isHost = $this->effectiveTarget() === 'host'; + if ($isHost) { + // Host container actions match the host-terminal bar: admin-only (stopping a + // control-plane container is as destructive as a root shell on the host). + abort_unless(Auth::user()?->can('manage-fleet'), 403); + } $server = $this->targetServer(); if (! $server || (! $isHost && ! $server->credential_exists)) { return; @@ -158,6 +184,9 @@ class Index extends Component abort_unless(Auth::user()?->can('operate'), 403); $isHost = $this->effectiveTarget() === 'host'; + if ($isHost) { + abort_unless(Auth::user()?->can('manage-fleet'), 403); // host logs: admin-only, like the host shell + } $server = $this->targetServer(); if (! $server) { return; diff --git a/app/Livewire/Modals/ContainerLogs.php b/app/Livewire/Modals/ContainerLogs.php index 97cd402..204f406 100644 --- a/app/Livewire/Modals/ContainerLogs.php +++ b/app/Livewire/Modals/ContainerLogs.php @@ -40,6 +40,10 @@ class ContainerLogs extends ModalComponent abort_unless(Auth::user()?->can('operate'), 403); // serverId 0 = the Clusev host (transient server from the host login); otherwise a fleet server. + // The host is admin-only (manage-fleet), matching the host terminal + the Docker host target. + if ($this->serverId === 0) { + abort_unless(Auth::user()?->can('manage-fleet'), 403); + } $server = $this->serverId === 0 ? HostCredential::current()?->toServer() : Server::find($this->serverId); diff --git a/app/Models/HostCredential.php b/app/Models/HostCredential.php index 6458fa5..c3e21dd 100644 --- a/app/Models/HostCredential.php +++ b/app/Models/HostCredential.php @@ -39,7 +39,7 @@ class HostCredential extends Model { $cred = new SshCredential(['username' => $this->username, 'auth_type' => $this->auth_type]); $cred->secret = $this->secret; - if ($this->passphrase) { + if ($this->passphrase !== null && $this->passphrase !== '') { // not `if ($this->passphrase)` — a "0" passphrase is valid $cred->passphrase = $this->passphrase; } diff --git a/resources/views/livewire/docker/index.blade.php b/resources/views/livewire/docker/index.blade.php index d79dff9..2fa0010 100644 --- a/resources/views/livewire/docker/index.blade.php +++ b/resources/views/livewire/docker/index.blade.php @@ -6,7 +6,7 @@ in_array($s, ['created', 'restarting'], true) => 'pending', default => 'offline', // exited / dead / removing }; - $hostOn = $this->hostConfigured(); + $hostOn = $this->canToggleHost(); $effTarget = $this->effectiveTarget(); $active = $this->activeServer(); $targetName = $effTarget === 'host' ? __('docker.host_target') : $active?->name; diff --git a/tests/Feature/DockerComponentTest.php b/tests/Feature/DockerComponentTest.php index 3d7e275..4cd2f00 100644 --- a/tests/Feature/DockerComponentTest.php +++ b/tests/Feature/DockerComponentTest.php @@ -65,10 +65,11 @@ class DockerComponentTest extends TestCase ]); } - public function test_defaults_to_the_clusev_host_when_a_host_login_is_configured(): void + public function test_admin_defaults_to_the_clusev_host_when_a_host_login_is_configured(): void { - // The host is where the operator's own containers live, so it is the default target. - $this->actingAs($this->viewer()); + // The host is where the operator's own containers live, so it is the default target — but + // only for admins (manage-fleet); the control-plane machine is not exposed to lower roles. + $this->actingAs($this->admin()); $this->hostCred(); $this->activeServer(); // a fleet server also exists, but the host wins the default $this->stubDocker([['id' => 'h1', 'name' => 'clusev-app-1', 'image' => 'clusev-app:prod', 'state' => 'running', 'status' => 'Up', 'ports' => '']]); @@ -81,9 +82,25 @@ class DockerComponentTest extends TestCase ->assertSee(__('docker.host_target')); } + public function test_non_admin_never_targets_the_host_even_when_configured(): void + { + // A viewer/operator must land on a fleet server, never the Clusev host — no toggle, no default. + $this->actingAs($this->operator()); + $this->hostCred(); + $this->activeServer(); + $this->stubDocker([['id' => 's1', 'name' => 'fleet-web', 'image' => 'nginx', 'state' => 'running', 'status' => 'Up', 'ports' => '']]); + + Livewire::test(Index::class) + ->call('load') + ->assertOk() + ->assertSet('connected', true) + ->assertSee('fleet-web') + ->assertDontSee(__('docker.host_target')); // no host switch, no host subtitle + } + public function test_switching_to_host_without_a_login_shows_the_unconfigured_state(): void { - $this->actingAs($this->viewer()); + $this->actingAs($this->admin()); $this->activeServer(); $this->stubDocker(); @@ -97,7 +114,7 @@ class DockerComponentTest extends TestCase public function test_can_switch_from_the_host_to_the_active_fleet_server(): void { - $this->actingAs($this->viewer()); + $this->actingAs($this->admin()); $this->hostCred(); $this->activeServer(); $this->stubDocker([['id' => 's1', 'name' => 'web', 'image' => 'nginx', 'state' => 'running', 'status' => 'Up', 'ports' => '80/tcp']]); @@ -109,9 +126,27 @@ class DockerComponentTest extends TestCase ->assertSee('web'); } - public function test_operator_can_action_a_host_container_and_it_is_audited(): void + public function test_operator_target_forced_to_host_still_hits_a_fleet_server_not_the_host(): void { + // Even if a non-admin forces target=host, effectiveTarget() pins them to a fleet server, so + // the action runs against the server credential path — never the control-plane host. $this->actingAs($this->operator()); + $this->hostCred(); + $this->activeServer(); + $docker = $this->stubDocker(); + $docker->shouldReceive('containerAction')->once() + ->with(Mockery::on(fn ($s) => $s->exists === true), 'web', 'restart') // a real fleet server, not the transient host + ->andReturn(['ok' => true, 'output' => '']); + + Livewire::test(Index::class) + ->set('target', 'host') + ->call('action', 'web', 'restart') + ->assertOk(); + } + + public function test_admin_can_action_a_host_container_and_it_is_audited(): void + { + $this->actingAs($this->admin()); $this->hostCred(); // default target = host $docker = $this->stubDocker(); $docker->shouldReceive('containerAction')->once() @@ -126,6 +161,20 @@ class DockerComponentTest extends TestCase $this->assertDatabaseHas('audit_events', ['action' => 'docker.action', 'server_id' => null]); } + public function test_operator_cannot_open_host_logs(): void + { + // The host-logs sentinel (serverId 0) is admin-only; an operator is refused at the read. + $this->actingAs($this->operator()); + $this->hostCred(); + $docker = Mockery::mock(DockerService::class); + $docker->shouldReceive('logs')->never(); + app()->instance(DockerService::class, $docker); + + Livewire::test(ContainerLogs::class, ['serverId' => 0, 'ref' => 'clusev-app-1']) + ->call('load') + ->assertForbidden(); + } + public function test_logs_modal_loads_a_host_container_tail(): void { $this->actingAs($this->admin());