diff --git a/app/Livewire/Modals/Fail2banBan.php b/app/Livewire/Modals/Fail2banBan.php index 00537ea..43ffe60 100644 --- a/app/Livewire/Modals/Fail2banBan.php +++ b/app/Livewire/Modals/Fail2banBan.php @@ -32,6 +32,8 @@ class Fail2banBan extends ModalComponent */ public function mount(int $serverId, array $jails = []): void { + abort_unless(auth()->user()?->can('manage-network'), 403); + $this->serverId = $serverId; $this->jails = array_values($jails); $this->jail = $this->jails[0] ?? 'sshd'; @@ -44,6 +46,8 @@ class Fail2banBan extends ModalComponent public function save(Fail2banService $fail2ban): void { + abort_unless(auth()->user()?->can('manage-network'), 403); + $this->error = null; $server = Server::find($this->serverId); diff --git a/app/Livewire/Modals/Fail2banBans.php b/app/Livewire/Modals/Fail2banBans.php index 496371a..c349644 100644 --- a/app/Livewire/Modals/Fail2banBans.php +++ b/app/Livewire/Modals/Fail2banBans.php @@ -35,6 +35,8 @@ class Fail2banBans extends ModalComponent public function mount(int $serverId): void { + abort_unless(auth()->user()?->can('manage-network'), 403); + // Store the id ONLY so the modal opens instantly; the SSH read happens in load(). $this->serverId = $serverId; } @@ -77,6 +79,8 @@ class Fail2banBans extends ModalComponent */ public function unbanIp(string $jail, string $ip, Fail2banService $fail2ban): void { + abort_unless(auth()->user()?->can('manage-network'), 403); + $server = Server::find($this->serverId); if (! $server) { $this->error = __('common.server_not_found'); diff --git a/app/Livewire/Modals/Fail2banConfig.php b/app/Livewire/Modals/Fail2banConfig.php index 2745759..e289f70 100644 --- a/app/Livewire/Modals/Fail2banConfig.php +++ b/app/Livewire/Modals/Fail2banConfig.php @@ -35,6 +35,8 @@ class Fail2banConfig extends ModalComponent public function mount(int $serverId, Fail2banService $fail2ban): void { + abort_unless(auth()->user()?->can('manage-network'), 403); + $this->serverId = $serverId; $server = Server::find($serverId); @@ -64,6 +66,8 @@ class Fail2banConfig extends ModalComponent public function save(Fail2banService $fail2ban): void { + abort_unless(auth()->user()?->can('manage-network'), 403); + // Never overwrite the remote policy with defaults the operator never saw — // saving is only allowed once the current values were read successfully. if (! $this->loaded) { diff --git a/app/Livewire/Modals/FirewallRule.php b/app/Livewire/Modals/FirewallRule.php index e9056c5..4b0ac93 100644 --- a/app/Livewire/Modals/FirewallRule.php +++ b/app/Livewire/Modals/FirewallRule.php @@ -33,6 +33,8 @@ class FirewallRule extends ModalComponent public function mount(int $serverId, string $tool = 'ufw'): void { + abort_unless(auth()->user()?->can('manage-network'), 403); + $this->serverId = $serverId; $this->tool = $tool === 'firewalld' ? 'firewalld' : 'ufw'; } @@ -44,6 +46,8 @@ class FirewallRule extends ModalComponent public function save(FirewallService $firewall): void { + abort_unless(auth()->user()?->can('manage-network'), 403); + $this->error = null; $server = Server::find($this->serverId); diff --git a/app/Livewire/Modals/HardeningAction.php b/app/Livewire/Modals/HardeningAction.php index 4dce991..ba5f7f0 100644 --- a/app/Livewire/Modals/HardeningAction.php +++ b/app/Livewire/Modals/HardeningAction.php @@ -41,6 +41,8 @@ class HardeningAction extends ModalComponent public function mount(int $serverId, string $action, bool $enable): void { + abort_unless(auth()->user()?->can('manage-network'), 403); + $this->serverId = $serverId; $this->action = $action; $this->enable = $enable; @@ -68,6 +70,8 @@ class HardeningAction extends ModalComponent public function apply(): void { + abort_unless(auth()->user()?->can('manage-network'), 403); + if ($this->error !== null || $this->done) { return; } diff --git a/app/Livewire/Servers/Show.php b/app/Livewire/Servers/Show.php index ea43646..b32cf71 100644 --- a/app/Livewire/Servers/Show.php +++ b/app/Livewire/Servers/Show.php @@ -340,6 +340,8 @@ class Show extends Component */ public function confirmDeleteRule(int $index): void { + abort_unless(auth()->user()?->can('manage-network'), 403); + $rule = $this->firewall['rules'][$index] ?? null; if ($rule === null) { return; @@ -371,6 +373,10 @@ class Show extends Component #[On('firewallRuleDelete')] public function deleteFirewallRule(string $confirmToken, FirewallService $firewall): void { + // Re-gate on consume: ConfirmToken is uid-bound, not role-bound, so a token issued + // while the user could manage the network must still be refused after a demotion. + abort_unless(auth()->user()?->can('manage-network'), 403); + try { $payload = ConfirmToken::consume($confirmToken, 'firewallRuleDelete'); } catch (InvalidConfirmToken) { @@ -431,6 +437,8 @@ class Show extends Component /** Unban a fail2ban IP — recovery action, so a direct (audited) click, no R5 modal. */ public function unbanIp(string $jail, string $ip, Fail2banService $fail2ban): void { + abort_unless(auth()->user()?->can('manage-network'), 403); + try { $res = $fail2ban->unban($this->server, $jail, $ip); } catch (Throwable $e) { @@ -452,6 +460,8 @@ class Show extends Component /** Add an IP/CIDR to the fail2ban whitelist (ignoreip) — non-destructive, direct + audited. */ public function addIgnore(Fail2banService $fail2ban): void { + abort_unless(auth()->user()?->can('manage-network'), 403); + $ip = trim($this->newIgnoreIp); if ($ip === '') { return; @@ -485,6 +495,8 @@ class Show extends Component /** Remove an IP/CIDR from the fail2ban whitelist — direct + audited (loopback is refused by the service). */ public function removeIgnore(string $ip, Fail2banService $fail2ban): void { + abort_unless(auth()->user()?->can('manage-network'), 403); + try { $res = $fail2ban->removeIgnoreIp($this->server, $ip); } catch (Throwable $e) { diff --git a/app/Livewire/System/Index.php b/app/Livewire/System/Index.php index 8fcb4ad..b6f8606 100644 --- a/app/Livewire/System/Index.php +++ b/app/Livewire/System/Index.php @@ -124,6 +124,8 @@ class Index extends Component */ public function restartNow(DeploymentService $deployment): void { + abort_unless(auth()->user()?->can('manage-panel'), 403); + // Surface a failed sentinel write instead of a "restarting" state that never resolves. if (! $deployment->requestRestart()) { $this->dispatch('notify', message: __('system.restart_write_failed'), level: 'error'); diff --git a/app/Livewire/Versions/Index.php b/app/Livewire/Versions/Index.php index 557ccf7..5f18a1d 100644 --- a/app/Livewire/Versions/Index.php +++ b/app/Livewire/Versions/Index.php @@ -182,6 +182,8 @@ class Index extends Component */ public function requestUpdate(DeploymentService $deployment): mixed { + abort_unless(auth()->user()?->can('manage-panel'), 403); + $channel = $this->channel(); $installed = (string) config('clusev.version'); $latest = $this->resolveLatestTag($channel, forceRemote: true); diff --git a/app/Livewire/Wireguard/Index.php b/app/Livewire/Wireguard/Index.php index 3794edb..c3a6381 100644 --- a/app/Livewire/Wireguard/Index.php +++ b/app/Livewire/Wireguard/Index.php @@ -71,6 +71,17 @@ class Index extends Component public ?string $resultName = null; + /** + * Page guard (RBAC): WireGuard is a manage-network capability. The route carries a + * `can:manage-network` middleware for the initial GET, but a Livewire component update + * (POST /livewire/update) is NOT re-run through the route middleware — so this mount() + * check is the real per-request guard for the page itself. + */ + public function mount(): void + { + abort_unless(auth()->user()?->can('manage-network'), 403); + } + public function setWindow(int $seconds): void { $this->window = $this->clampWindow($seconds); @@ -83,6 +94,8 @@ class Index extends Component public function addPeer(WgBridge $bridge): void { + abort_unless(auth()->user()?->can('manage-network'), 403); + $this->validate(['newPeer' => ['required', 'regex:'.self::PEER_NAME_RE]], [ 'newPeer.regex' => __('wireguard.peer_name_invalid'), 'newPeer.required' => __('wireguard.peer_name_invalid'), @@ -100,6 +113,8 @@ class Index extends Component public function setupWg(WgBridge $bridge): void { + abort_unless(auth()->user()?->can('manage-network'), 403); + $this->validate([ 'setupSubnet' => ['required', 'regex:#^\d{1,3}(\.\d{1,3}){3}/\d{1,2}$#'], 'setupPort' => ['required', 'regex:/^\d{1,5}$/'], @@ -144,6 +159,10 @@ class Index extends Component #[On('wgPeerRemoved')] public function applyRemovePeer(string $confirmToken, WgBridge $bridge): void { + // Re-gate on consume: a token issued while the user still had rights must be + // refused if the role was demoted before it was confirmed (tokens are uid-bound). + abort_unless(auth()->user()?->can('manage-network'), 403); + try { $payload = ConfirmToken::consume($confirmToken, 'wgPeerRemoved'); } catch (InvalidConfirmToken) { @@ -160,6 +179,8 @@ class Index extends Component public function removePeer(WgBridge $bridge, string $name): void { + abort_unless(auth()->user()?->can('manage-network'), 403); + if (preg_match(self::PEER_NAME_RE, $name) !== 1 || ! $this->throttle()) { return; } @@ -170,6 +191,8 @@ class Index extends Component public function setEndpoint(WgBridge $bridge): void { + abort_unless(auth()->user()?->can('manage-network'), 403); + $this->validate(['newEndpoint' => ['required', 'regex:/^[A-Za-z0-9.:_-]{1,128}$/']], [ 'newEndpoint.regex' => __('wireguard.endpoint_invalid'), 'newEndpoint.required' => __('wireguard.endpoint_invalid'), @@ -186,6 +209,8 @@ class Index extends Component /** Set the DNS server(s) baked into future peer configs. Non-destructive (no confirm needed). */ public function setDns(WgBridge $bridge): void { + abort_unless(auth()->user()?->can('manage-network'), 403); + $this->validate(['newDns' => ['required', 'regex:/^\d{1,3}(\.\d{1,3}){3}([,\s]+\d{1,3}(\.\d{1,3}){3})*$/']], [ 'newDns.regex' => __('wireguard.dns_invalid'), 'newDns.required' => __('wireguard.dns_invalid'), @@ -211,6 +236,8 @@ class Index extends Component #[On('wgGateToggle')] public function applyGateToggle(string $confirmToken, WgBridge $bridge): void { + abort_unless(auth()->user()?->can('manage-network'), 403); + try { $payload = ConfirmToken::consume($confirmToken, 'wgGateToggle'); } catch (InvalidConfirmToken) { @@ -222,6 +249,8 @@ class Index extends Component public function runGate(bool $on, ?WgBridge $bridge = null): void { + abort_unless(auth()->user()?->can('manage-network'), 403); + if (! $this->throttle()) { return; } @@ -248,6 +277,8 @@ class Index extends Component #[On('wgSshGate')] public function applySshGate(string $confirmToken, WgBridge $bridge): void { + abort_unless(auth()->user()?->can('manage-network'), 403); + try { $payload = ConfirmToken::consume($confirmToken, 'wgSshGate'); } catch (InvalidConfirmToken) { @@ -259,6 +290,8 @@ class Index extends Component public function runSshGate(bool $on, ?WgBridge $bridge = null): void { + abort_unless(auth()->user()?->can('manage-network'), 403); + if (! $this->throttle()) { return; } @@ -288,6 +321,8 @@ class Index extends Component #[On('wgSetPort')] public function applySetPort(string $confirmToken, WgBridge $bridge): void { + abort_unless(auth()->user()?->can('manage-network'), 403); + try { $payload = ConfirmToken::consume($confirmToken, 'wgSetPort'); } catch (InvalidConfirmToken) { @@ -319,6 +354,8 @@ class Index extends Component #[On('wgSetSubnet')] public function applySetSubnet(string $confirmToken, WgBridge $bridge): void { + abort_unless(auth()->user()?->can('manage-network'), 403); + try { $payload = ConfirmToken::consume($confirmToken, 'wgSetSubnet'); } catch (InvalidConfirmToken) { diff --git a/routes/web.php b/routes/web.php index cca9bcd..8ac1895 100644 --- a/routes/web.php +++ b/routes/web.php @@ -192,7 +192,7 @@ Route::middleware('auth')->group(function () { Route::get('/versions', Versions\Index::class)->name('versions'); Route::get('/system', System\Index::class)->name('system'); Route::get('/help', Help\Index::class)->name('help'); - Route::get('/wireguard', Wireguard\Index::class)->name('wireguard'); + Route::get('/wireguard', Wireguard\Index::class)->middleware('can:manage-network')->name('wireguard'); Route::get('/terminal', Terminal\Index::class)->name('terminal'); // Dev-only Release page — first of the gating triple (spec): the route is registered ONLY when diff --git a/tests/Feature/RbacNetworkGateTest.php b/tests/Feature/RbacNetworkGateTest.php new file mode 100644 index 0000000..2f9a516 --- /dev/null +++ b/tests/Feature/RbacNetworkGateTest.php @@ -0,0 +1,232 @@ +set('clusev.repository', self::REPO); + app(DeploymentService::class)->clearUpdateRequest(); // never inherit a stray sentinel + } + + protected function tearDown(): void + { + app(DeploymentService::class)->clearUpdateRequest(); + 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]); + } + + // ── manage-panel: Versions\Index::requestUpdate ───────────────────────────── + + public function test_admin_can_request_update(): void + { + config()->set('clusev.version', '0.9.4'); + Http::fake([self::TAGS_URL => Http::response([['name' => 'v0.9.6']])]); + + // Mock the sentinel write so the admin path performs no real side effect. + $deployment = Mockery::mock(DeploymentService::class)->makePartial(); + $deployment->shouldReceive('requestUpdate')->andReturn(true); + $this->app->instance(DeploymentService::class, $deployment); + + Livewire::actingAs($this->admin()) + ->test(VersionsIndex::class) + ->call('requestUpdate') + ->assertSet('updateRequested', true); // no 403 — the admin was allowed through + } + + public function test_operator_cannot_request_update(): void + { + Livewire::actingAs($this->operator()) + ->test(VersionsIndex::class) + ->call('requestUpdate') + ->assertForbidden(); + } + + public function test_viewer_cannot_request_update(): void + { + Livewire::actingAs($this->viewer()) + ->test(VersionsIndex::class) + ->call('requestUpdate') + ->assertForbidden(); + } + + // ── manage-panel: System\Index::restartNow ────────────────────────────────── + + public function test_admin_can_restart_stack(): void + { + $deployment = Mockery::mock(DeploymentService::class)->makePartial(); + $deployment->shouldReceive('requestRestart')->andReturn(true); + $this->app->instance(DeploymentService::class, $deployment); + + Livewire::actingAs($this->admin()) + ->test(SystemIndex::class) + ->call('restartNow') + ->assertSet('restartRequested', true); // allowed through the guard + } + + public function test_operator_cannot_restart_stack(): void + { + Livewire::actingAs($this->operator()) + ->test(SystemIndex::class) + ->call('restartNow') + ->assertForbidden(); + } + + public function test_viewer_cannot_restart_stack(): void + { + Livewire::actingAs($this->viewer()) + ->test(SystemIndex::class) + ->call('restartNow') + ->assertForbidden(); + } + + // ── manage-network: WireGuard page mount + mutations ──────────────────────── + + public function test_admin_can_mount_wireguard(): void + { + Livewire::actingAs($this->admin()) + ->test(WireguardIndex::class) + ->assertOk(); + } + + public function test_operator_cannot_mount_wireguard(): void + { + Livewire::actingAs($this->operator()) + ->test(WireguardIndex::class) + ->assertForbidden(); + } + + public function test_viewer_cannot_mount_wireguard(): void + { + Livewire::actingAs($this->viewer()) + ->test(WireguardIndex::class) + ->assertForbidden(); + } + + public function test_operator_cannot_add_wireguard_peer(): void + { + // Prove addPeer()'s OWN method-level guard refuses an operator (not just mount's). An admin + // boots the component so the snapshot is valid, then we swap the acting user to an operator + // and call the mutation — the addPeer() abort_unless must still fire 403. This models a + // demotion mid-session or a hand-crafted /livewire/update, both re-gated by the method guard. + $component = Livewire::actingAs($this->admin())->test(WireguardIndex::class)->assertOk(); + + $this->actingAs($this->operator()); + + $component->call('addPeer')->assertForbidden(); + } + + // ── manage-network: FirewallRule modal (mount + save) ─────────────────────── + + public function test_admin_can_mount_and_save_firewall_rule(): void + { + $server = Server::create(['name' => 's', 'ip' => '10.0.0.9', 'ssh_port' => 22, 'status' => 'online']); + + $firewall = Mockery::mock(FirewallService::class); + $firewall->shouldReceive('addRule')->once()->andReturn(['ok' => true, 'output' => '']); + $this->app->instance(FirewallService::class, $firewall); + + Livewire::actingAs($this->admin()) + ->test(FirewallRule::class, ['serverId' => $server->id, 'tool' => 'ufw']) + ->set('action', 'allow') + ->set('proto', 'tcp') + ->set('port', '2222') + ->call('save') + ->assertHasNoErrors(); // allowed through, SSH mocked away + } + + public function test_operator_cannot_mount_firewall_rule(): void + { + $server = Server::create(['name' => 's', 'ip' => '10.0.0.9', 'ssh_port' => 22, 'status' => 'online']); + + // No real firewall service should ever be reached — the mount guard fires first. + $firewall = Mockery::mock(FirewallService::class); + $firewall->shouldReceive('addRule')->never(); + $this->app->instance(FirewallService::class, $firewall); + + Livewire::actingAs($this->operator()) + ->test(FirewallRule::class, ['serverId' => $server->id, 'tool' => 'ufw']) + ->assertForbidden(); + } + + public function test_viewer_cannot_mount_firewall_rule(): void + { + $server = Server::create(['name' => 's', 'ip' => '10.0.0.9', 'ssh_port' => 22, 'status' => 'online']); + + Livewire::actingAs($this->viewer()) + ->test(FirewallRule::class, ['serverId' => $server->id, 'tool' => 'ufw']) + ->assertForbidden(); + } + + // ── manage-network: Fail2banBans modal (unban is the same privileged SSH call as + // Servers\Show::unbanIp — guarded here too so the modal isn't a bypass) ────── + + public function test_operator_cannot_mount_fail2ban_bans_modal(): void + { + $server = Server::create(['name' => 's', 'ip' => '10.0.0.9', 'ssh_port' => 22, 'status' => 'online']); + + Livewire::actingAs($this->operator()) + ->test(Fail2banBans::class, ['serverId' => $server->id]) + ->assertForbidden(); + } + + public function test_viewer_cannot_mount_fail2ban_bans_modal(): void + { + $server = Server::create(['name' => 's', 'ip' => '10.0.0.9', 'ssh_port' => 22, 'status' => 'online']); + + Livewire::actingAs($this->viewer()) + ->test(Fail2banBans::class, ['serverId' => $server->id]) + ->assertForbidden(); + } +}