fix(docker): admin-gate the host target + honour a "0" key passphrase (Codex review)

Two findings from the Codex review of the host-Docker changes:

1. Under-gated host target (security). Listing/actioning/log-reading the
   CLUSEV HOST's containers only required `operate`, but the host is the
   control-plane machine — a `Stop` on clusev-mariadb takes Clusev down —
   and the host TERMINAL is already `manage-fleet`-only. Align it:
   - effectiveTarget() resolves to the host only for manage-fleet users;
     everyone else is pinned to a fleet server (no host default, no toggle).
   - action()/viewLogs() and the ContainerLogs host sentinel (serverId 0)
     now abort_unless manage-fleet.
   A viewer/operator can no longer reach the host, even by forcing
   target=host.

2. HostCredential::toServer() dropped a key passphrase equal to the string
   "0" via a falsy `if ($this->passphrase)` check, which would break key
   auth. Use an explicit null/'' check.

Added tests: non-admin never targets the host, a forced target=host still
hits a fleet server, operator cannot open host logs. 751 tests green.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
feat/v1-foundation
boban 2026-07-05 23:43:18 +02:00
parent fd6eba5c29
commit 4c67b278bd
5 changed files with 95 additions and 13 deletions

View File

@ -50,20 +50,41 @@ class Index extends Component
return __('docker.title'); 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 public function hostConfigured(): bool
{ {
return HostCredential::current() !== null; 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 public function effectiveTarget(): string
{ {
if ($this->target === 'host' || $this->target === 'server') { // An explicit host selection is honoured only for permitted users; everyone else is pinned
return $this->target; // 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). */ /** 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); abort_unless(Auth::user()?->can('operate'), 403);
$isHost = $this->effectiveTarget() === 'host'; $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(); $server = $this->targetServer();
if (! $server || (! $isHost && ! $server->credential_exists)) { if (! $server || (! $isHost && ! $server->credential_exists)) {
return; return;
@ -158,6 +184,9 @@ class Index extends Component
abort_unless(Auth::user()?->can('operate'), 403); abort_unless(Auth::user()?->can('operate'), 403);
$isHost = $this->effectiveTarget() === 'host'; $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(); $server = $this->targetServer();
if (! $server) { if (! $server) {
return; return;

View File

@ -40,6 +40,10 @@ class ContainerLogs extends ModalComponent
abort_unless(Auth::user()?->can('operate'), 403); abort_unless(Auth::user()?->can('operate'), 403);
// serverId 0 = the Clusev host (transient server from the host login); otherwise a fleet server. // 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 $server = $this->serverId === 0
? HostCredential::current()?->toServer() ? HostCredential::current()?->toServer()
: Server::find($this->serverId); : Server::find($this->serverId);

View File

@ -39,7 +39,7 @@ class HostCredential extends Model
{ {
$cred = new SshCredential(['username' => $this->username, 'auth_type' => $this->auth_type]); $cred = new SshCredential(['username' => $this->username, 'auth_type' => $this->auth_type]);
$cred->secret = $this->secret; $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; $cred->passphrase = $this->passphrase;
} }

View File

@ -6,7 +6,7 @@
in_array($s, ['created', 'restarting'], true) => 'pending', in_array($s, ['created', 'restarting'], true) => 'pending',
default => 'offline', // exited / dead / removing default => 'offline', // exited / dead / removing
}; };
$hostOn = $this->hostConfigured(); $hostOn = $this->canToggleHost();
$effTarget = $this->effectiveTarget(); $effTarget = $this->effectiveTarget();
$active = $this->activeServer(); $active = $this->activeServer();
$targetName = $effTarget === 'host' ? __('docker.host_target') : $active?->name; $targetName = $effTarget === 'host' ? __('docker.host_target') : $active?->name;

View File

@ -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. // The host is where the operator's own containers live, so it is the default target — but
$this->actingAs($this->viewer()); // only for admins (manage-fleet); the control-plane machine is not exposed to lower roles.
$this->actingAs($this->admin());
$this->hostCred(); $this->hostCred();
$this->activeServer(); // a fleet server also exists, but the host wins the default $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' => '']]); $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')); ->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 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->activeServer();
$this->stubDocker(); $this->stubDocker();
@ -97,7 +114,7 @@ class DockerComponentTest extends TestCase
public function test_can_switch_from_the_host_to_the_active_fleet_server(): void 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->hostCred();
$this->activeServer(); $this->activeServer();
$this->stubDocker([['id' => 's1', 'name' => 'web', 'image' => 'nginx', 'state' => 'running', 'status' => 'Up', 'ports' => '80/tcp']]); $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'); ->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->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 $this->hostCred(); // default target = host
$docker = $this->stubDocker(); $docker = $this->stubDocker();
$docker->shouldReceive('containerAction')->once() $docker->shouldReceive('containerAction')->once()
@ -126,6 +161,20 @@ class DockerComponentTest extends TestCase
$this->assertDatabaseHas('audit_events', ['action' => 'docker.action', 'server_id' => null]); $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 public function test_logs_modal_loads_a_host_container_tail(): void
{ {
$this->actingAs($this->admin()); $this->actingAs($this->admin());