feat(rbac): gate network + panel actions (manage-network/manage-panel)
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>feat/v1-foundation
parent
55c3975d53
commit
27b35e1f72
|
|
@ -32,6 +32,8 @@ class Fail2banBan extends ModalComponent
|
||||||
*/
|
*/
|
||||||
public function mount(int $serverId, array $jails = []): void
|
public function mount(int $serverId, array $jails = []): void
|
||||||
{
|
{
|
||||||
|
abort_unless(auth()->user()?->can('manage-network'), 403);
|
||||||
|
|
||||||
$this->serverId = $serverId;
|
$this->serverId = $serverId;
|
||||||
$this->jails = array_values($jails);
|
$this->jails = array_values($jails);
|
||||||
$this->jail = $this->jails[0] ?? 'sshd';
|
$this->jail = $this->jails[0] ?? 'sshd';
|
||||||
|
|
@ -44,6 +46,8 @@ class Fail2banBan extends ModalComponent
|
||||||
|
|
||||||
public function save(Fail2banService $fail2ban): void
|
public function save(Fail2banService $fail2ban): void
|
||||||
{
|
{
|
||||||
|
abort_unless(auth()->user()?->can('manage-network'), 403);
|
||||||
|
|
||||||
$this->error = null;
|
$this->error = null;
|
||||||
|
|
||||||
$server = Server::find($this->serverId);
|
$server = Server::find($this->serverId);
|
||||||
|
|
|
||||||
|
|
@ -35,6 +35,8 @@ class Fail2banBans extends ModalComponent
|
||||||
|
|
||||||
public function mount(int $serverId): void
|
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().
|
// Store the id ONLY so the modal opens instantly; the SSH read happens in load().
|
||||||
$this->serverId = $serverId;
|
$this->serverId = $serverId;
|
||||||
}
|
}
|
||||||
|
|
@ -77,6 +79,8 @@ class Fail2banBans extends ModalComponent
|
||||||
*/
|
*/
|
||||||
public function unbanIp(string $jail, string $ip, Fail2banService $fail2ban): void
|
public function unbanIp(string $jail, string $ip, Fail2banService $fail2ban): void
|
||||||
{
|
{
|
||||||
|
abort_unless(auth()->user()?->can('manage-network'), 403);
|
||||||
|
|
||||||
$server = Server::find($this->serverId);
|
$server = Server::find($this->serverId);
|
||||||
if (! $server) {
|
if (! $server) {
|
||||||
$this->error = __('common.server_not_found');
|
$this->error = __('common.server_not_found');
|
||||||
|
|
|
||||||
|
|
@ -35,6 +35,8 @@ class Fail2banConfig extends ModalComponent
|
||||||
|
|
||||||
public function mount(int $serverId, Fail2banService $fail2ban): void
|
public function mount(int $serverId, Fail2banService $fail2ban): void
|
||||||
{
|
{
|
||||||
|
abort_unless(auth()->user()?->can('manage-network'), 403);
|
||||||
|
|
||||||
$this->serverId = $serverId;
|
$this->serverId = $serverId;
|
||||||
|
|
||||||
$server = Server::find($serverId);
|
$server = Server::find($serverId);
|
||||||
|
|
@ -64,6 +66,8 @@ class Fail2banConfig extends ModalComponent
|
||||||
|
|
||||||
public function save(Fail2banService $fail2ban): void
|
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 —
|
// Never overwrite the remote policy with defaults the operator never saw —
|
||||||
// saving is only allowed once the current values were read successfully.
|
// saving is only allowed once the current values were read successfully.
|
||||||
if (! $this->loaded) {
|
if (! $this->loaded) {
|
||||||
|
|
|
||||||
|
|
@ -33,6 +33,8 @@ class FirewallRule extends ModalComponent
|
||||||
|
|
||||||
public function mount(int $serverId, string $tool = 'ufw'): void
|
public function mount(int $serverId, string $tool = 'ufw'): void
|
||||||
{
|
{
|
||||||
|
abort_unless(auth()->user()?->can('manage-network'), 403);
|
||||||
|
|
||||||
$this->serverId = $serverId;
|
$this->serverId = $serverId;
|
||||||
$this->tool = $tool === 'firewalld' ? 'firewalld' : 'ufw';
|
$this->tool = $tool === 'firewalld' ? 'firewalld' : 'ufw';
|
||||||
}
|
}
|
||||||
|
|
@ -44,6 +46,8 @@ class FirewallRule extends ModalComponent
|
||||||
|
|
||||||
public function save(FirewallService $firewall): void
|
public function save(FirewallService $firewall): void
|
||||||
{
|
{
|
||||||
|
abort_unless(auth()->user()?->can('manage-network'), 403);
|
||||||
|
|
||||||
$this->error = null;
|
$this->error = null;
|
||||||
|
|
||||||
$server = Server::find($this->serverId);
|
$server = Server::find($this->serverId);
|
||||||
|
|
|
||||||
|
|
@ -41,6 +41,8 @@ class HardeningAction extends ModalComponent
|
||||||
|
|
||||||
public function mount(int $serverId, string $action, bool $enable): void
|
public function mount(int $serverId, string $action, bool $enable): void
|
||||||
{
|
{
|
||||||
|
abort_unless(auth()->user()?->can('manage-network'), 403);
|
||||||
|
|
||||||
$this->serverId = $serverId;
|
$this->serverId = $serverId;
|
||||||
$this->action = $action;
|
$this->action = $action;
|
||||||
$this->enable = $enable;
|
$this->enable = $enable;
|
||||||
|
|
@ -68,6 +70,8 @@ class HardeningAction extends ModalComponent
|
||||||
|
|
||||||
public function apply(): void
|
public function apply(): void
|
||||||
{
|
{
|
||||||
|
abort_unless(auth()->user()?->can('manage-network'), 403);
|
||||||
|
|
||||||
if ($this->error !== null || $this->done) {
|
if ($this->error !== null || $this->done) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -340,6 +340,8 @@ class Show extends Component
|
||||||
*/
|
*/
|
||||||
public function confirmDeleteRule(int $index): void
|
public function confirmDeleteRule(int $index): void
|
||||||
{
|
{
|
||||||
|
abort_unless(auth()->user()?->can('manage-network'), 403);
|
||||||
|
|
||||||
$rule = $this->firewall['rules'][$index] ?? null;
|
$rule = $this->firewall['rules'][$index] ?? null;
|
||||||
if ($rule === null) {
|
if ($rule === null) {
|
||||||
return;
|
return;
|
||||||
|
|
@ -371,6 +373,10 @@ class Show extends Component
|
||||||
#[On('firewallRuleDelete')]
|
#[On('firewallRuleDelete')]
|
||||||
public function deleteFirewallRule(string $confirmToken, FirewallService $firewall): void
|
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 {
|
try {
|
||||||
$payload = ConfirmToken::consume($confirmToken, 'firewallRuleDelete');
|
$payload = ConfirmToken::consume($confirmToken, 'firewallRuleDelete');
|
||||||
} catch (InvalidConfirmToken) {
|
} catch (InvalidConfirmToken) {
|
||||||
|
|
@ -431,6 +437,8 @@ class Show extends Component
|
||||||
/** Unban a fail2ban IP — recovery action, so a direct (audited) click, no R5 modal. */
|
/** Unban a fail2ban IP — recovery action, so a direct (audited) click, no R5 modal. */
|
||||||
public function unbanIp(string $jail, string $ip, Fail2banService $fail2ban): void
|
public function unbanIp(string $jail, string $ip, Fail2banService $fail2ban): void
|
||||||
{
|
{
|
||||||
|
abort_unless(auth()->user()?->can('manage-network'), 403);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
$res = $fail2ban->unban($this->server, $jail, $ip);
|
$res = $fail2ban->unban($this->server, $jail, $ip);
|
||||||
} catch (Throwable $e) {
|
} catch (Throwable $e) {
|
||||||
|
|
@ -452,6 +460,8 @@ class Show extends Component
|
||||||
/** Add an IP/CIDR to the fail2ban whitelist (ignoreip) — non-destructive, direct + audited. */
|
/** Add an IP/CIDR to the fail2ban whitelist (ignoreip) — non-destructive, direct + audited. */
|
||||||
public function addIgnore(Fail2banService $fail2ban): void
|
public function addIgnore(Fail2banService $fail2ban): void
|
||||||
{
|
{
|
||||||
|
abort_unless(auth()->user()?->can('manage-network'), 403);
|
||||||
|
|
||||||
$ip = trim($this->newIgnoreIp);
|
$ip = trim($this->newIgnoreIp);
|
||||||
if ($ip === '') {
|
if ($ip === '') {
|
||||||
return;
|
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). */
|
/** Remove an IP/CIDR from the fail2ban whitelist — direct + audited (loopback is refused by the service). */
|
||||||
public function removeIgnore(string $ip, Fail2banService $fail2ban): void
|
public function removeIgnore(string $ip, Fail2banService $fail2ban): void
|
||||||
{
|
{
|
||||||
|
abort_unless(auth()->user()?->can('manage-network'), 403);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
$res = $fail2ban->removeIgnoreIp($this->server, $ip);
|
$res = $fail2ban->removeIgnoreIp($this->server, $ip);
|
||||||
} catch (Throwable $e) {
|
} catch (Throwable $e) {
|
||||||
|
|
|
||||||
|
|
@ -124,6 +124,8 @@ class Index extends Component
|
||||||
*/
|
*/
|
||||||
public function restartNow(DeploymentService $deployment): void
|
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.
|
// Surface a failed sentinel write instead of a "restarting" state that never resolves.
|
||||||
if (! $deployment->requestRestart()) {
|
if (! $deployment->requestRestart()) {
|
||||||
$this->dispatch('notify', message: __('system.restart_write_failed'), level: 'error');
|
$this->dispatch('notify', message: __('system.restart_write_failed'), level: 'error');
|
||||||
|
|
|
||||||
|
|
@ -182,6 +182,8 @@ class Index extends Component
|
||||||
*/
|
*/
|
||||||
public function requestUpdate(DeploymentService $deployment): mixed
|
public function requestUpdate(DeploymentService $deployment): mixed
|
||||||
{
|
{
|
||||||
|
abort_unless(auth()->user()?->can('manage-panel'), 403);
|
||||||
|
|
||||||
$channel = $this->channel();
|
$channel = $this->channel();
|
||||||
$installed = (string) config('clusev.version');
|
$installed = (string) config('clusev.version');
|
||||||
$latest = $this->resolveLatestTag($channel, forceRemote: true);
|
$latest = $this->resolveLatestTag($channel, forceRemote: true);
|
||||||
|
|
|
||||||
|
|
@ -71,6 +71,17 @@ class Index extends Component
|
||||||
|
|
||||||
public ?string $resultName = null;
|
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
|
public function setWindow(int $seconds): void
|
||||||
{
|
{
|
||||||
$this->window = $this->clampWindow($seconds);
|
$this->window = $this->clampWindow($seconds);
|
||||||
|
|
@ -83,6 +94,8 @@ class Index extends Component
|
||||||
|
|
||||||
public function addPeer(WgBridge $bridge): void
|
public function addPeer(WgBridge $bridge): void
|
||||||
{
|
{
|
||||||
|
abort_unless(auth()->user()?->can('manage-network'), 403);
|
||||||
|
|
||||||
$this->validate(['newPeer' => ['required', 'regex:'.self::PEER_NAME_RE]], [
|
$this->validate(['newPeer' => ['required', 'regex:'.self::PEER_NAME_RE]], [
|
||||||
'newPeer.regex' => __('wireguard.peer_name_invalid'),
|
'newPeer.regex' => __('wireguard.peer_name_invalid'),
|
||||||
'newPeer.required' => __('wireguard.peer_name_invalid'),
|
'newPeer.required' => __('wireguard.peer_name_invalid'),
|
||||||
|
|
@ -100,6 +113,8 @@ class Index extends Component
|
||||||
|
|
||||||
public function setupWg(WgBridge $bridge): void
|
public function setupWg(WgBridge $bridge): void
|
||||||
{
|
{
|
||||||
|
abort_unless(auth()->user()?->can('manage-network'), 403);
|
||||||
|
|
||||||
$this->validate([
|
$this->validate([
|
||||||
'setupSubnet' => ['required', 'regex:#^\d{1,3}(\.\d{1,3}){3}/\d{1,2}$#'],
|
'setupSubnet' => ['required', 'regex:#^\d{1,3}(\.\d{1,3}){3}/\d{1,2}$#'],
|
||||||
'setupPort' => ['required', 'regex:/^\d{1,5}$/'],
|
'setupPort' => ['required', 'regex:/^\d{1,5}$/'],
|
||||||
|
|
@ -144,6 +159,10 @@ class Index extends Component
|
||||||
#[On('wgPeerRemoved')]
|
#[On('wgPeerRemoved')]
|
||||||
public function applyRemovePeer(string $confirmToken, WgBridge $bridge): void
|
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 {
|
try {
|
||||||
$payload = ConfirmToken::consume($confirmToken, 'wgPeerRemoved');
|
$payload = ConfirmToken::consume($confirmToken, 'wgPeerRemoved');
|
||||||
} catch (InvalidConfirmToken) {
|
} catch (InvalidConfirmToken) {
|
||||||
|
|
@ -160,6 +179,8 @@ class Index extends Component
|
||||||
|
|
||||||
public function removePeer(WgBridge $bridge, string $name): void
|
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()) {
|
if (preg_match(self::PEER_NAME_RE, $name) !== 1 || ! $this->throttle()) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
@ -170,6 +191,8 @@ class Index extends Component
|
||||||
|
|
||||||
public function setEndpoint(WgBridge $bridge): void
|
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}$/']], [
|
$this->validate(['newEndpoint' => ['required', 'regex:/^[A-Za-z0-9.:_-]{1,128}$/']], [
|
||||||
'newEndpoint.regex' => __('wireguard.endpoint_invalid'),
|
'newEndpoint.regex' => __('wireguard.endpoint_invalid'),
|
||||||
'newEndpoint.required' => __('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). */
|
/** Set the DNS server(s) baked into future peer configs. Non-destructive (no confirm needed). */
|
||||||
public function setDns(WgBridge $bridge): void
|
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})*$/']], [
|
$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.regex' => __('wireguard.dns_invalid'),
|
||||||
'newDns.required' => __('wireguard.dns_invalid'),
|
'newDns.required' => __('wireguard.dns_invalid'),
|
||||||
|
|
@ -211,6 +236,8 @@ class Index extends Component
|
||||||
#[On('wgGateToggle')]
|
#[On('wgGateToggle')]
|
||||||
public function applyGateToggle(string $confirmToken, WgBridge $bridge): void
|
public function applyGateToggle(string $confirmToken, WgBridge $bridge): void
|
||||||
{
|
{
|
||||||
|
abort_unless(auth()->user()?->can('manage-network'), 403);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
$payload = ConfirmToken::consume($confirmToken, 'wgGateToggle');
|
$payload = ConfirmToken::consume($confirmToken, 'wgGateToggle');
|
||||||
} catch (InvalidConfirmToken) {
|
} catch (InvalidConfirmToken) {
|
||||||
|
|
@ -222,6 +249,8 @@ class Index extends Component
|
||||||
|
|
||||||
public function runGate(bool $on, ?WgBridge $bridge = null): void
|
public function runGate(bool $on, ?WgBridge $bridge = null): void
|
||||||
{
|
{
|
||||||
|
abort_unless(auth()->user()?->can('manage-network'), 403);
|
||||||
|
|
||||||
if (! $this->throttle()) {
|
if (! $this->throttle()) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
@ -248,6 +277,8 @@ class Index extends Component
|
||||||
#[On('wgSshGate')]
|
#[On('wgSshGate')]
|
||||||
public function applySshGate(string $confirmToken, WgBridge $bridge): void
|
public function applySshGate(string $confirmToken, WgBridge $bridge): void
|
||||||
{
|
{
|
||||||
|
abort_unless(auth()->user()?->can('manage-network'), 403);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
$payload = ConfirmToken::consume($confirmToken, 'wgSshGate');
|
$payload = ConfirmToken::consume($confirmToken, 'wgSshGate');
|
||||||
} catch (InvalidConfirmToken) {
|
} catch (InvalidConfirmToken) {
|
||||||
|
|
@ -259,6 +290,8 @@ class Index extends Component
|
||||||
|
|
||||||
public function runSshGate(bool $on, ?WgBridge $bridge = null): void
|
public function runSshGate(bool $on, ?WgBridge $bridge = null): void
|
||||||
{
|
{
|
||||||
|
abort_unless(auth()->user()?->can('manage-network'), 403);
|
||||||
|
|
||||||
if (! $this->throttle()) {
|
if (! $this->throttle()) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
@ -288,6 +321,8 @@ class Index extends Component
|
||||||
#[On('wgSetPort')]
|
#[On('wgSetPort')]
|
||||||
public function applySetPort(string $confirmToken, WgBridge $bridge): void
|
public function applySetPort(string $confirmToken, WgBridge $bridge): void
|
||||||
{
|
{
|
||||||
|
abort_unless(auth()->user()?->can('manage-network'), 403);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
$payload = ConfirmToken::consume($confirmToken, 'wgSetPort');
|
$payload = ConfirmToken::consume($confirmToken, 'wgSetPort');
|
||||||
} catch (InvalidConfirmToken) {
|
} catch (InvalidConfirmToken) {
|
||||||
|
|
@ -319,6 +354,8 @@ class Index extends Component
|
||||||
#[On('wgSetSubnet')]
|
#[On('wgSetSubnet')]
|
||||||
public function applySetSubnet(string $confirmToken, WgBridge $bridge): void
|
public function applySetSubnet(string $confirmToken, WgBridge $bridge): void
|
||||||
{
|
{
|
||||||
|
abort_unless(auth()->user()?->can('manage-network'), 403);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
$payload = ConfirmToken::consume($confirmToken, 'wgSetSubnet');
|
$payload = ConfirmToken::consume($confirmToken, 'wgSetSubnet');
|
||||||
} catch (InvalidConfirmToken) {
|
} catch (InvalidConfirmToken) {
|
||||||
|
|
|
||||||
|
|
@ -192,7 +192,7 @@ Route::middleware('auth')->group(function () {
|
||||||
Route::get('/versions', Versions\Index::class)->name('versions');
|
Route::get('/versions', Versions\Index::class)->name('versions');
|
||||||
Route::get('/system', System\Index::class)->name('system');
|
Route::get('/system', System\Index::class)->name('system');
|
||||||
Route::get('/help', Help\Index::class)->name('help');
|
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');
|
Route::get('/terminal', Terminal\Index::class)->name('terminal');
|
||||||
|
|
||||||
// Dev-only Release page — first of the gating triple (spec): the route is registered ONLY when
|
// Dev-only Release page — first of the gating triple (spec): the route is registered ONLY when
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,232 @@
|
||||||
|
<?php
|
||||||
|
|
||||||
|
namespace Tests\Feature;
|
||||||
|
|
||||||
|
use App\Livewire\Modals\Fail2banBans;
|
||||||
|
use App\Livewire\Modals\FirewallRule;
|
||||||
|
use App\Livewire\System\Index as SystemIndex;
|
||||||
|
use App\Livewire\Versions\Index as VersionsIndex;
|
||||||
|
use App\Livewire\Wireguard\Index as WireguardIndex;
|
||||||
|
use App\Models\Server;
|
||||||
|
use App\Models\User;
|
||||||
|
use App\Services\DeploymentService;
|
||||||
|
use App\Services\FirewallService;
|
||||||
|
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||||
|
use Illuminate\Support\Facades\Cache;
|
||||||
|
use Illuminate\Support\Facades\Http;
|
||||||
|
use Livewire\Livewire;
|
||||||
|
use Mockery;
|
||||||
|
use Tests\TestCase;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* RBAC guards on the DANGEROUS network + panel actions. The RBAC foundation (Role enum,
|
||||||
|
* User::isAdmin(), Gates manage-panel/manage-network) is committed separately; this proves the
|
||||||
|
* abort_unless(...) guards are wired at every dangerous entry point:
|
||||||
|
* - manage-panel gates the self-update (Versions\Index::requestUpdate) + stack restart
|
||||||
|
* (System\Index::restartNow);
|
||||||
|
* - manage-network gates the whole WireGuard page + its mutations and the network modals.
|
||||||
|
* Admin (factory default) may act; operator + viewer are refused with 403. Every host/SSH service
|
||||||
|
* is mocked so no real network is touched.
|
||||||
|
*/
|
||||||
|
class RbacNetworkGateTest extends TestCase
|
||||||
|
{
|
||||||
|
use RefreshDatabase;
|
||||||
|
|
||||||
|
// Neutral self-hosted host so the update check is hermetic (matches VersionUpdateCheckTest).
|
||||||
|
private const REPO = 'https://selfhosted.test/o/clusev';
|
||||||
|
|
||||||
|
private const TAGS_URL = 'selfhosted.test/api/v1/repos/o/clusev/tags*';
|
||||||
|
|
||||||
|
protected function setUp(): void
|
||||||
|
{
|
||||||
|
parent::setUp();
|
||||||
|
Cache::flush();
|
||||||
|
config()->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();
|
||||||
|
}
|
||||||
|
}
|
||||||
Loading…
Reference in New Issue