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
parent
27b35e1f72
commit
25ded63d87
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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');
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
}
|
||||
}
|
||||
Loading…
Reference in New Issue