feat(rbac): gate service + file mutations behind operate (viewer read-only)
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>feat/v1-foundation
parent
25ded63d87
commit
b529201186
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
}
|
||||
Loading…
Reference in New Issue