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');
}
/** 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. */
public function effectiveTarget(): string
/**
* 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
{
if ($this->target === 'host' || $this->target === 'server') {
return $this->target;
return (bool) Auth::user()?->can('manage-fleet');
}
return $this->hostConfigured() ? 'host' : 'server';
/** 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
{
// 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->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;

View File

@ -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);

View File

@ -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;
}

View File

@ -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;

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.
$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());