feat(rbac): gate fleet, host terminal + domain/TLS actions (manage-fleet/operate/manage-panel)

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
feat/v1-foundation
boban 2026-07-05 01:01:24 +02:00
parent 27b35e1f72
commit 25ded63d87
10 changed files with 343 additions and 0 deletions

View File

@ -28,6 +28,9 @@ class AddSshKey extends ModalComponent
public function mount(int $serverId): void
{
// Installing an authorized_keys entry over SSH is a fleet-admin action.
abort_unless(auth()->user()?->can('manage-fleet'), 403);
$this->serverId = $serverId;
}
@ -72,6 +75,9 @@ class AddSshKey extends ModalComponent
public function save(FleetService $fleet): void
{
// Re-gate on save: refuse a demoted user / hand-crafted /livewire/update after mount.
abort_unless(auth()->user()?->can('manage-fleet'), 403);
$this->error = null;
$server = Server::find($this->serverId);

View File

@ -36,6 +36,12 @@ class CreateServer extends ModalComponent
public string $credentialName = '';
public function mount(): void
{
// Registering a server + depositing its SSH login is a fleet-admin action.
abort_unless(auth()->user()?->can('manage-fleet'), 403);
}
public static function modalMaxWidth(): string
{
return 'lg';
@ -87,6 +93,10 @@ class CreateServer extends ModalComponent
public function save(): void
{
// Re-gate on save: mount()'s guard is not enough — a demotion mid-session or a
// hand-crafted /livewire/update must still be refused at the write.
abort_unless(auth()->user()?->can('manage-fleet'), 403);
$data = $this->validate();
// Atomic: create server + credential, then VERIFY the SSH login. A failed

View File

@ -29,6 +29,9 @@ class EditCredential extends ModalComponent
public function mount(int $serverId): void
{
// Depositing/updating a server's SSH login is a fleet-admin action.
abort_unless(auth()->user()?->can('manage-fleet'), 403);
$this->serverId = $serverId;
if ($cred = Server::find($serverId)?->credential) {
@ -46,6 +49,9 @@ class EditCredential extends ModalComponent
public function save(): void
{
// Re-gate on save: refuse a demoted user / hand-crafted /livewire/update after mount.
abort_unless(auth()->user()?->can('manage-fleet'), 403);
$this->error = null;
$server = Server::find($this->serverId);

View File

@ -41,6 +41,9 @@ class HostShell extends ModalComponent
public function mount(): void
{
// The host SSH login is the Clusev machine itself — the most dangerous credential.
abort_unless(auth()->user()?->can('manage-fleet'), 403);
$hc = HostCredential::current();
if ($hc !== null) {
$this->configured = true;
@ -82,6 +85,9 @@ class HostShell extends ModalComponent
public function save(): void
{
// Re-gate on save: refuse a demoted user / hand-crafted /livewire/update after mount.
abort_unless(auth()->user()?->can('manage-fleet'), 403);
$data = $this->validate();
$existing = HostCredential::current();
@ -120,6 +126,9 @@ class HostShell extends ModalComponent
public function remove(): void
{
// Re-gate on remove: refuse a demoted user / hand-crafted /livewire/update after mount.
abort_unless(auth()->user()?->can('manage-fleet'), 403);
$hc = HostCredential::current();
if ($hc !== null) {
$target = $hc->username.'@'.$hc->host.':'.$hc->port;

View File

@ -31,6 +31,10 @@ class SshKeyProvision extends ModalComponent
public function mount(int $serverId): void
{
// Rotating to key-only access (installs a key, switches the credential, disables
// password login) over SSH is a fleet-admin action.
abort_unless(auth()->user()?->can('manage-fleet'), 403);
$this->serverId = $serverId;
$this->serverName = Server::find($serverId)?->name ?? '';
}
@ -42,6 +46,9 @@ class SshKeyProvision extends ModalComponent
public function run(SshKeyProvisioner $provisioner): void
{
// Re-gate on run: refuse a demoted user / hand-crafted /livewire/update after mount.
abort_unless(auth()->user()?->can('manage-fleet'), 403);
if ($this->done) {
return;
}

View File

@ -42,6 +42,9 @@ class SystemUpdate extends ModalComponent
public function mount(int $serverId, MaintenanceService $maintenance): void
{
// Applying apt/dnf/zypper package updates on a managed host over SSH is a fleet-admin action.
abort_unless(auth()->user()?->can('manage-fleet'), 403);
$this->serverId = $serverId;
$server = Server::find($serverId);
@ -69,6 +72,9 @@ class SystemUpdate extends ModalComponent
public function upgrade(MaintenanceService $maintenance): void
{
// Re-gate on upgrade: refuse a demoted user / hand-crafted /livewire/update after mount.
abort_unless(auth()->user()?->can('manage-fleet'), 403);
if ($this->error !== null || $this->done || ! $this->supported) {
return;
}

View File

@ -165,6 +165,8 @@ class Show extends Component
*/
public function confirmKeyRemoval(int $index): void
{
abort_unless(auth()->user()?->can('manage-fleet'), 403);
$key = $this->sshKeys[$index] ?? null;
if ($key === null) {
return;
@ -197,6 +199,10 @@ class Show extends Component
#[On('keyRemoved')]
public function removeKey(string $confirmToken, FleetService $fleet): void
{
// Re-gate on consume: ConfirmToken is uid-bound, not role-bound, so a token issued
// while the user could manage the fleet must still be refused after a demotion.
abort_unless(auth()->user()?->can('manage-fleet'), 403);
try {
$payload = ConfirmToken::consume($confirmToken, 'keyRemoved');
} catch (InvalidConfirmToken) {
@ -253,6 +259,8 @@ class Show extends Component
*/
public function toggleCredential(): void
{
abort_unless(auth()->user()?->can('manage-fleet'), 403);
$cred = $this->server->credential;
if (! $cred) {
return;
@ -280,6 +288,8 @@ class Show extends Component
*/
public function confirmDeleteCredential(): void
{
abort_unless(auth()->user()?->can('manage-fleet'), 403);
$cred = $this->server->credential;
if (! $cred) {
return;
@ -309,6 +319,10 @@ class Show extends Component
#[On('credentialDeleted')]
public function deleteCredential(string $confirmToken): void
{
// Re-gate on consume: ConfirmToken is uid-bound, not role-bound, so a token issued
// while the user could manage the fleet must still be refused after a demotion.
abort_unless(auth()->user()?->can('manage-fleet'), 403);
try {
$payload = ConfirmToken::consume($confirmToken, 'credentialDeleted');
} catch (InvalidConfirmToken) {

View File

@ -62,6 +62,8 @@ class Index extends Component
/** Validate, then confirm the domain change via the shared modal (audited there). */
public function confirmDomain(DeploymentService $deployment): void
{
abort_unless(auth()->user()?->can('manage-panel'), 403);
$this->validate($this->domainRules(), [
'domainInput.regex' => __('system.domain_invalid'),
'domainInput.max' => __('system.domain_invalid'),
@ -97,6 +99,10 @@ class Index extends Component
#[On('domainChanged')]
public function applyDomain(string $confirmToken, DeploymentService $deployment): void
{
// Re-gate on consume: ConfirmToken is uid-bound, not role-bound, so a token issued
// while the user could manage the panel must still be refused after a demotion.
abort_unless(auth()->user()?->can('manage-panel'), 403);
try {
$payload = ConfirmToken::consume($confirmToken, 'domainChanged');
} catch (InvalidConfirmToken) {
@ -144,6 +150,8 @@ class Index extends Component
*/
public function requestCertificate(DeploymentService $deployment): void
{
abort_unless(auth()->user()?->can('manage-panel'), 403);
$domain = $deployment->domain();
if ($domain === null || $deployment->externalTls()) {
@ -185,6 +193,8 @@ class Index extends Component
/** TLS-mode changes are audited (confirmation via the shared modal). */
public function confirmTlsMode(string $mode): void
{
abort_unless(auth()->user()?->can('manage-panel'), 403);
if (! in_array($mode, self::TLS_MODES, true) || $mode === $this->tlsMode) {
return;
}
@ -211,6 +221,10 @@ class Index extends Component
#[On('tlsModeChanged')]
public function applyTlsMode(string $confirmToken, DeploymentService $deployment): void
{
// Re-gate on consume: ConfirmToken is uid-bound, not role-bound, so a token issued
// while the user could manage the panel must still be refused after a demotion.
abort_unless(auth()->user()?->can('manage-panel'), 403);
try {
$payload = ConfirmToken::consume($confirmToken, 'tlsModeChanged');
} catch (InvalidConfirmToken) {

View File

@ -37,6 +37,10 @@ class Index extends Component
/** Mint a single-use token for the chosen target and hand it to the xterm island via an event. */
public function open(string $kind, ?string $uuid = null): void
{
// Split by target: the host shell is a real login into the Clusev machine (admin-only),
// while a server shell is a day-to-day operator action.
abort_unless(auth()->user()?->can($kind === 'host' ? 'manage-fleet' : 'operate'), 403);
$serverId = null;
$label = __('terminal.host_label');
$this->activeKey = 'host';
@ -76,6 +80,9 @@ class Index extends Component
/** Open the modal that stores the Clusev host SSH login. */
public function configureHost(): void
{
// Configuring the host SSH login is a fleet-admin action (HostShell::mount re-gates too).
abort_unless(auth()->user()?->can('manage-fleet'), 403);
$this->dispatch('openModal', component: 'modals.host-shell');
}

View File

@ -0,0 +1,264 @@
<?php
namespace Tests\Feature;
use App\Livewire\Modals\CreateServer;
use App\Livewire\Servers\Show;
use App\Livewire\System\Index as SystemIndex;
use App\Livewire\Terminal\Index as TerminalIndex;
use App\Models\HostCredential;
use App\Models\Server;
use App\Models\TerminalSession;
use App\Models\User;
use App\Services\DeploymentService;
use App\Services\FleetService;
use App\Support\Confirm\ConfirmToken;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Livewire\Livewire;
use Mockery;
use Tests\TestCase;
/**
* RBAC guards on the DANGEROUS fleet + host-terminal + domain/TLS actions. The RBAC foundation
* (Role enum, Gates manage-fleet/manage-panel + operate) is committed separately; this proves the
* abort_unless(...) guards fire at every dangerous entry point:
* - manage-fleet gates server registration + credential surface (CreateServer, credential
* delete/toggle, SSH-key install, host-terminal login) and the HOST shell target;
* - operate gates a SERVER terminal session (day-to-day), not the host shell;
* - manage-panel gates the domain/TLS control-plane mutations (System\Index).
* Admin (factory default) may act; operator/viewer are refused with 403 where they lack the
* ability. Every host/SSH service is mocked so no real network is touched (hermetic RefreshDatabase).
*/
class RbacFleetGateTest extends TestCase
{
use RefreshDatabase;
protected function tearDown(): void
{
Mockery::close();
parent::tearDown();
}
private function admin(): User
{
return User::factory()->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]);
}
private function server(string $name = 's', string $ip = '10.0.0.9'): Server
{
return Server::create(['name' => $name, 'ip' => $ip, 'ssh_port' => 22, 'status' => 'online']);
}
/** A modal-confirmed, server-scoped token (mirrors the real confirm step). */
private function confirmed(string $event, array $params, ?int $serverId = null): string
{
$token = ConfirmToken::issue($event, $params, '', null, $serverId);
ConfirmToken::confirm($token);
return $token;
}
// ── manage-fleet: CreateServer modal (mount + save) ─────────────────────────
public function test_admin_can_open_and_save_create_server(): void
{
// SSH probe is mocked so the admin path performs no real login.
$this->mock(FleetService::class, fn ($m) => $m->shouldReceive('testConnection')->andReturn(['ok' => true, 'error' => null]));
Livewire::actingAs($this->admin())
->test(CreateServer::class)
->set('name', 'web-1')->set('ip', '10.0.0.5')->set('sshPort', 22)
->set('username', 'root')->set('authType', 'password')->set('secret', 'pw')
->call('save')
->assertHasNoErrors(); // allowed through, SSH mocked away
$this->assertSame(1, Server::count());
}
public function test_operator_cannot_open_create_server(): void
{
// No FleetService probe should ever be reached — the mount guard fires first.
$this->mock(FleetService::class, fn ($m) => $m->shouldReceive('testConnection')->never());
Livewire::actingAs($this->operator())
->test(CreateServer::class)
->assertForbidden();
$this->assertSame(0, Server::count());
}
public function test_viewer_cannot_open_create_server(): void
{
Livewire::actingAs($this->viewer())
->test(CreateServer::class)
->assertForbidden();
}
// ── manage-fleet: Servers\Show::deleteCredential ────────────────────────────
public function test_admin_can_delete_a_credential(): void
{
// ConfirmToken binds to the issuing uid — authenticate BEFORE minting it.
$this->actingAs($this->admin());
$server = $this->server();
$server->credential()->create(['username' => 'root', 'auth_type' => 'password', 'secret' => 'x']);
$token = $this->confirmed('credentialDeleted', [], $server->id);
Livewire::test(Show::class, ['server' => $server])
->call('deleteCredential', $token);
$this->assertFalse($server->credential()->exists()); // allowed → credential gone
}
public function test_operator_cannot_delete_a_credential(): void
{
$server = $this->server();
$server->credential()->create(['username' => 'root', 'auth_type' => 'password', 'secret' => 'x']);
// The method guard fires before the token is even consumed — no token needed.
Livewire::actingAs($this->operator())
->test(Show::class, ['server' => $server])
->call('deleteCredential', 'irrelevant')
->assertForbidden();
$this->assertTrue($server->credential()->exists(), 'operator must not be able to delete the credential');
}
public function test_viewer_cannot_delete_a_credential(): void
{
$server = $this->server();
$server->credential()->create(['username' => 'root', 'auth_type' => 'password', 'secret' => 'x']);
Livewire::actingAs($this->viewer())
->test(Show::class, ['server' => $server])
->call('deleteCredential', 'irrelevant')
->assertForbidden();
$this->assertTrue($server->credential()->exists());
}
// ── manage-fleet vs operate: Terminal\Index::open (split by target) ──────────
public function test_admin_can_open_host_and_server_shells(): void
{
HostCredential::create([
'host' => 'host.docker.internal', 'port' => 22, 'username' => 'root',
'auth_type' => 'password', 'secret' => 'r00t-pw',
]);
$server = $this->server();
Livewire::actingAs($this->admin())
->test(TerminalIndex::class)
->call('open', 'host')->assertSet('activeKey', 'host')
->call('open', 'server', $server->uuid)->assertSet('activeKey', $server->uuid);
$this->assertSame(2, TerminalSession::count()); // both targets minted a session
}
public function test_operator_cannot_open_the_host_shell(): void
{
HostCredential::create([
'host' => 'host.docker.internal', 'port' => 22, 'username' => 'root',
'auth_type' => 'password', 'secret' => 'r00t-pw',
]);
Livewire::actingAs($this->operator())
->test(TerminalIndex::class)
->call('open', 'host')
->assertForbidden(); // host shell = manage-fleet
$this->assertSame(0, TerminalSession::count());
}
public function test_operator_can_open_a_server_shell(): void
{
$server = $this->server();
Livewire::actingAs($this->operator())
->test(TerminalIndex::class)
->call('open', 'server', $server->uuid)
->assertSet('activeKey', $server->uuid); // server shell = operate → allowed
$this->assertSame(1, TerminalSession::count());
}
public function test_viewer_cannot_open_host_or_server_shell(): void
{
$server = $this->server();
Livewire::actingAs($this->viewer())
->test(TerminalIndex::class)
->call('open', 'host')
->assertForbidden();
Livewire::actingAs($this->viewer())
->test(TerminalIndex::class)
->call('open', 'server', $server->uuid)
->assertForbidden();
$this->assertSame(0, TerminalSession::count());
}
public function test_operator_cannot_configure_the_host_login(): void
{
Livewire::actingAs($this->operator())
->test(TerminalIndex::class)
->call('configureHost')
->assertForbidden();
}
// ── manage-panel: System\Index domain/TLS mutations ─────────────────────────
public function test_admin_can_apply_a_domain_change(): void
{
// ConfirmToken binds to the issuing uid — authenticate BEFORE minting it.
$this->actingAs($this->admin());
$token = $this->confirmed('domainChanged', ['domain' => 'panel.example.test']);
Livewire::test(SystemIndex::class)
->call('applyDomain', $token)
->assertSet('domainInput', 'panel.example.test'); // allowed → domain persisted
$this->assertSame('panel.example.test', app(DeploymentService::class)->configuredDomain());
}
public function test_operator_cannot_apply_a_domain_change(): void
{
Livewire::actingAs($this->operator())
->test(SystemIndex::class)
->call('applyDomain', 'irrelevant')
->assertForbidden();
$this->assertNull(app(DeploymentService::class)->configuredDomain());
}
public function test_operator_cannot_confirm_a_domain_change(): void
{
Livewire::actingAs($this->operator())
->test(SystemIndex::class)
->set('domainInput', 'panel.example.test')
->call('confirmDomain')
->assertForbidden();
}
public function test_viewer_cannot_apply_a_domain_change(): void
{
Livewire::actingAs($this->viewer())
->test(SystemIndex::class)
->call('applyDomain', 'irrelevant')
->assertForbidden();
}
}