feat(rbac): gate service + file mutations behind operate (viewer read-only)

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

View File

@ -80,6 +80,8 @@ class Index extends Component
/** Upload the selected file into the current directory (SFTP put). */
public function updatedUpload(FleetService $fleet): void
{
abort_unless(auth()->user()?->can('operate'), 403);
$this->validate(['upload' => ['file', 'max:51200']]); // 50 MB
$active = $this->activeServer();
@ -167,6 +169,8 @@ class Index extends Component
*/
public function confirmDelete(int $index): void
{
abort_unless(auth()->user()?->can('operate'), 403);
$name = $this->entries[$index]['name'] ?? null;
if ($name === null) {
return;
@ -200,6 +204,8 @@ class Index extends Component
#[On('fileConfirmed')]
public function deleteEntry(string $confirmToken, FleetService $fleet): void
{
abort_unless(auth()->user()?->can('operate'), 403);
try {
$payload = ConfirmToken::consume($confirmToken, 'fileConfirmed');
} catch (InvalidConfirmToken) {

View File

@ -136,6 +136,8 @@ class FileEditor extends ModalComponent
public function save(FleetService $fleet): void
{
abort_unless(auth()->user()?->can('operate'), 403);
$this->error = null;
// Images are a read-only preview — never write them back.

View File

@ -105,6 +105,8 @@ class Index extends Component
*/
public function confirm(string $op, int $index): void
{
abort_unless(auth()->user()?->can('operate'), 403);
$name = $this->filteredServices[$index]['name'] ?? null;
if ($name === null) {
return;
@ -140,6 +142,8 @@ class Index extends Component
#[On('serviceConfirmed')]
public function applyService(string $confirmToken, FleetService $fleet): void
{
abort_unless(auth()->user()?->can('operate'), 403);
try {
$payload = ConfirmToken::consume($confirmToken, 'serviceConfirmed');
} catch (InvalidConfirmToken) {

View File

@ -0,0 +1,282 @@
<?php
namespace Tests\Feature;
use App\Livewire\Files\Index as Files;
use App\Livewire\Modals\FileEditor;
use App\Livewire\Services\Index as Services;
use App\Models\Server;
use App\Models\User;
use App\Services\FleetService;
use App\Support\Confirm\ConfirmToken;
use App\Support\Ssh\Sftp;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Livewire\Livewire;
use Mockery;
use Tests\TestCase;
/**
* RBAC `operate` gate on the day-to-day MUTATIONS of a managed server. Viewing the
* lists stays open to everyone (read-only), but changing a systemd unit's state or
* writing the remote filesystem requires operate (admin + operator; viewer denied).
*
* The RBAC foundation (Role enum, User::roleAtLeast(), Gate `operate`) plus the
* network/fleet gates are committed separately; this proves the abort_unless(...)
* guards are wired at every service/file mutation entry point:
* - Services\Index::applyService (systemctl start/stop/restart apply handler);
* - Files\Index::deleteEntry (SFTP unlink apply handler);
* - Modals\FileEditor::save (SFTP write).
* Every SSH/FleetService/Sftp call is mocked no real host is ever touched. Viewer
* 403; operator ALLOWED; admin (factory default) ALLOWED. The pure readers (service
* list render, file browse) stay open for a viewer proving read-only, not locked-out.
*/
class RbacOperateGateTest 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]);
}
/** An online server with a credential, selected as the active fleet server. */
private function activeServer(): Server
{
$server = Server::create(['name' => 'box', 'ip' => '10.0.0.2', 'ssh_port' => 22, 'status' => 'online']);
$server->credential()->create(['username' => 'root', 'auth_type' => 'password', 'secret' => 'x']);
session(['active_server_id' => $server->id]);
return $server;
}
/** A confirmed, single-use token for the given apply handler + server. */
private function confirmed(string $event, array $params, int $serverId): string
{
$token = ConfirmToken::issue($event, $params, '', null, $serverId);
ConfirmToken::confirm($token); // the modal confirmation step
return $token;
}
// ── operate: Services\Index::applyService (systemctl restart) ────────────────
public function test_viewer_cannot_apply_a_service_action(): void
{
$this->actingAs($this->viewer());
$server = $this->activeServer();
// The privileged SSH call must never be reached — the guard fires first.
$fleet = Mockery::mock(FleetService::class);
$fleet->shouldReceive('serviceAction')->never();
app()->instance(FleetService::class, $fleet);
$token = $this->confirmed('serviceConfirmed', ['op' => 'restart', 'name' => 'nginx.service'], $server->id);
Livewire::test(Services::class)
->call('applyService', $token)
->assertForbidden();
}
public function test_operator_can_apply_a_service_action(): void
{
$this->actingAs($this->operator());
$server = $this->activeServer();
$fleet = Mockery::mock(FleetService::class);
$fleet->shouldReceive('serviceAction')->once()
->with(Mockery::type(Server::class), 'restart', 'nginx.service')
->andReturn(['ok' => true, 'output' => '']);
$fleet->shouldReceive('systemd')->andReturn(['services' => []]); // reload after success
app()->instance(FleetService::class, $fleet);
$token = $this->confirmed('serviceConfirmed', ['op' => 'restart', 'name' => 'nginx.service'], $server->id);
Livewire::test(Services::class)
->call('applyService', $token)
->assertOk(); // allowed through the guard, SSH mocked away
}
public function test_admin_can_apply_a_service_action(): void
{
$this->actingAs($this->admin());
$server = $this->activeServer();
$fleet = Mockery::mock(FleetService::class);
$fleet->shouldReceive('serviceAction')->once()
->with(Mockery::type(Server::class), 'restart', 'nginx.service')
->andReturn(['ok' => true, 'output' => '']);
$fleet->shouldReceive('systemd')->andReturn(['services' => []]);
app()->instance(FleetService::class, $fleet);
$token = $this->confirmed('serviceConfirmed', ['op' => 'restart', 'name' => 'nginx.service'], $server->id);
Livewire::test(Services::class)
->call('applyService', $token)
->assertOk();
}
// ── operate: Files\Index::deleteEntry (SFTP unlink) ─────────────────────────
public function test_viewer_cannot_delete_a_file(): void
{
$this->actingAs($this->viewer());
$server = $this->activeServer();
$fleet = Mockery::mock(FleetService::class);
$fleet->shouldReceive('files')->andReturn([]); // any dir listing during mount/load
$fleet->shouldReceive('deleteFile')->never(); // guard fires before the SFTP unlink
app()->instance(FleetService::class, $fleet);
$token = $this->confirmed('fileConfirmed', ['path' => '/etc/passwd'], $server->id);
Livewire::test(Files::class)
->call('deleteEntry', $token)
->assertForbidden();
}
public function test_operator_can_delete_a_file(): void
{
$this->actingAs($this->operator());
$server = $this->activeServer();
$fleet = Mockery::mock(FleetService::class);
$fleet->shouldReceive('files')->andReturn([]);
$fleet->shouldReceive('deleteFile')->once()
->with(Mockery::type(Server::class), '/etc/passwd');
app()->instance(FleetService::class, $fleet);
$token = $this->confirmed('fileConfirmed', ['path' => '/etc/passwd'], $server->id);
Livewire::test(Files::class)
->call('deleteEntry', $token)
->assertOk(); // allowed through the guard, SFTP mocked away
}
public function test_admin_can_delete_a_file(): void
{
$this->actingAs($this->admin());
$server = $this->activeServer();
$fleet = Mockery::mock(FleetService::class);
$fleet->shouldReceive('files')->andReturn([]);
$fleet->shouldReceive('deleteFile')->once()
->with(Mockery::type(Server::class), '/etc/passwd');
app()->instance(FleetService::class, $fleet);
$token = $this->confirmed('fileConfirmed', ['path' => '/etc/passwd'], $server->id);
Livewire::test(Files::class)
->call('deleteEntry', $token)
->assertOk();
}
// ── operate: Modals\FileEditor::save (SFTP write) ───────────────────────────
public function test_viewer_cannot_save_a_file_edit(): void
{
$this->actingAs($this->viewer());
$server = Server::create(['name' => 's', 'ip' => '10.0.0.9', 'ssh_port' => 22, 'status' => 'online']);
$fleet = Mockery::mock(FleetService::class);
$fleet->shouldReceive('writeFile')->never(); // guard fires before the remote write
$this->app->instance(FleetService::class, $fleet);
$this->app->instance(Sftp::class, Mockery::mock(Sftp::class)); // resolvable, unused for text
Livewire::test(FileEditor::class, ['serverId' => $server->id, 'path' => '/etc/x.conf', 'name' => 'x.conf'])
->set('content', 'edited = wert')
->call('save')
->assertForbidden();
}
public function test_operator_can_save_a_file_edit(): void
{
$this->actingAs($this->operator());
$server = Server::create(['name' => 's', 'ip' => '10.0.0.9', 'ssh_port' => 22, 'status' => 'online']);
$fleet = Mockery::mock(FleetService::class);
$fleet->shouldReceive('writeFile')->once()
->with(Mockery::type(Server::class), '/etc/x.conf', 'edited = wert');
$this->app->instance(FleetService::class, $fleet);
$this->app->instance(Sftp::class, Mockery::mock(Sftp::class));
Livewire::test(FileEditor::class, ['serverId' => $server->id, 'path' => '/etc/x.conf', 'name' => 'x.conf'])
->set('content', 'edited = wert')
->call('save')
->assertOk(); // allowed through the guard, SFTP mocked away
}
public function test_admin_can_save_a_file_edit(): void
{
$this->actingAs($this->admin());
$server = Server::create(['name' => 's', 'ip' => '10.0.0.9', 'ssh_port' => 22, 'status' => 'online']);
$fleet = Mockery::mock(FleetService::class);
$fleet->shouldReceive('writeFile')->once()
->with(Mockery::type(Server::class), '/etc/x.conf', 'edited = wert');
$this->app->instance(FleetService::class, $fleet);
$this->app->instance(Sftp::class, Mockery::mock(Sftp::class));
Livewire::test(FileEditor::class, ['serverId' => $server->id, 'path' => '/etc/x.conf', 'name' => 'x.conf'])
->set('content', 'edited = wert')
->call('save')
->assertOk();
}
// ── read-only: a viewer keeps READ access (proves read-only, not locked-out) ─
public function test_viewer_can_load_the_services_list(): void
{
$this->actingAs($this->viewer());
$server = $this->activeServer();
$fleet = Mockery::mock(FleetService::class);
$fleet->shouldReceive('systemd')->andReturn([
'services' => [['name' => 'nginx.service', 'status' => 'online', 'sub' => 'running', 'enabled' => true, 'desc' => 'web']],
'journal' => [],
'cursor' => null,
]);
app()->instance(FleetService::class, $fleet);
Livewire::test(Services::class)
->assertOk() // page mounts for a viewer
->call('load') // the lazy read runs
->assertOk()
->assertSet('connected', true)
->assertSet('services.0.name', 'nginx.service'); // the list is visible
}
public function test_viewer_can_browse_the_file_manager(): void
{
$this->actingAs($this->viewer());
$this->activeServer();
$fleet = Mockery::mock(FleetService::class);
$fleet->shouldReceive('files')->andReturn([
['name' => 'etc', 'type' => 'dir', 'size' => null, 'perms' => 'drwxr-xr-x', 'owner' => 'root', 'modified' => 'now'],
]);
app()->instance(FleetService::class, $fleet);
Livewire::test(Files::class)
->assertOk() // page mounts for a viewer
->call('load') // the lazy directory read runs
->assertOk()
->assertSet('connected', true)
->assertSet('entries.0.name', 'etc'); // the listing is visible
}
}