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
parent
fd6eba5c29
commit
4c67b278bd
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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());
|
||||
|
|
|
|||
Loading…
Reference in New Issue