fix(auth): restrict Sentinel access and register S3 policy

This commit is contained in:
Andras Bacsai 2026-06-15 12:31:30 +02:00
parent 46a9578d69
commit d2deaa8363
9 changed files with 146 additions and 10 deletions

View file

@ -3,11 +3,14 @@
namespace App\Livewire\Server\Sentinel;
use App\Models\Server;
use Illuminate\Foundation\Auth\Access\AuthorizesRequests;
use Illuminate\View\View;
use Livewire\Component;
class Logs extends Component
{
use AuthorizesRequests;
public ?Server $server = null;
public array $parameters = [];
@ -19,7 +22,11 @@ public function mount(): void
$this->server = Server::ownedByCurrentTeam()->whereUuid(request()->server_uuid)->firstOrFail();
} catch (\Throwable $e) {
handleError($e, $this);
return;
}
$this->authorize('viewSentinel', $this->server);
}
public function render(): View

View file

@ -3,11 +3,14 @@
namespace App\Livewire\Server\Sentinel;
use App\Models\Server;
use Illuminate\Foundation\Auth\Access\AuthorizesRequests;
use Illuminate\View\View;
use Livewire\Component;
class Show extends Component
{
use AuthorizesRequests;
public ?Server $server = null;
public array $parameters = [];
@ -19,7 +22,11 @@ public function mount(): void
$this->server = Server::ownedByCurrentTeam()->whereUuid(request()->server_uuid)->firstOrFail();
} catch (\Throwable $e) {
handleError($e, $this);
return;
}
$this->authorize('viewSentinel', $this->server);
}
public function render(): View

View file

@ -79,6 +79,14 @@ public function manageSentinel(User $user, Server $server): bool
return $user->isAdminOfTeam($server->team_id);
}
/**
* Determine whether the user can view Sentinel configuration and logs.
*/
public function viewSentinel(User $user, Server $server): bool
{
return $user->isAdminOfTeam($server->team_id);
}
/**
* Determine whether the user can manage CA certificates.
*/

View file

@ -17,6 +17,7 @@
use App\Models\PrivateKey;
use App\Models\Project;
use App\Models\PushoverNotificationSettings;
use App\Models\S3Storage;
use App\Models\Server;
use App\Models\Service;
use App\Models\ServiceApplication;
@ -51,6 +52,7 @@
use App\Policies\PrivateKeyPolicy;
use App\Policies\ProjectPolicy;
use App\Policies\ResourceCreatePolicy;
use App\Policies\S3StoragePolicy;
use App\Policies\ServerPolicy;
use App\Policies\ServiceApplicationPolicy;
use App\Policies\ServiceDatabasePolicy;
@ -109,6 +111,9 @@ class AuthServiceProvider extends ServiceProvider
// Instance settings policy
InstanceSettings::class => InstanceSettingsPolicy::class,
// S3 storage policy
S3Storage::class => S3StoragePolicy::class,
// Team policy
Team::class => TeamPolicy::class,

View file

@ -1,10 +1,12 @@
<div class="sub-menu-wrapper">
<a class="{{ request()->routeIs('server.sentinel') ? 'sub-menu-item menu-item-active' : 'sub-menu-item' }}" {{ wireNavigate() }}
href="{{ route('server.sentinel', $parameters) }}">
<span class="menu-item-label">Configuration</span>
</a>
<a class="{{ request()->routeIs('server.sentinel.logs') ? 'sub-menu-item menu-item-active' : 'sub-menu-item' }}" {{ wireNavigate() }}
href="{{ route('server.sentinel.logs', $parameters) }}">
<span class="menu-item-label">Logs</span>
</a>
@can('viewSentinel', $server)
<a class="{{ request()->routeIs('server.sentinel') ? 'sub-menu-item menu-item-active' : 'sub-menu-item' }}" {{ wireNavigate() }}
href="{{ route('server.sentinel', $parameters) }}">
<span class="menu-item-label">Configuration</span>
</a>
<a class="{{ request()->routeIs('server.sentinel.logs') ? 'sub-menu-item menu-item-active' : 'sub-menu-item' }}" {{ wireNavigate() }}
href="{{ route('server.sentinel.logs', $parameters) }}">
<span class="menu-item-label">Logs</span>
</a>
@endcan
</div>

View file

@ -78,7 +78,7 @@ class="flex items-center gap-6 overflow-x-scroll sm:overflow-x-hidden scrollbar
@endif
</a>
@endif
@if ($server->isFunctional() && !$server->isSwarm() && !$server->settings->is_build_server)
@if ($server->isFunctional() && !$server->isSwarm() && !$server->settings->is_build_server && auth()->user()?->can('viewSentinel', $server))
<a class="{{ request()->routeIs('server.sentinel') || request()->routeIs('server.sentinel.*') ? 'dark:text-white' : '' }} flex items-center gap-1" href="{{ route('server.sentinel', [
'server_uuid' => data_get($server, 'uuid'),
]) }}" {{ wireNavigate() }}>

View file

@ -1,5 +1,6 @@
<?php
use App\Http\Middleware\PreventRequestsDuringMaintenance;
use App\Livewire\Server\Navbar as ServerNavbar;
use App\Models\InstanceSettings;
use App\Models\Server;
@ -13,7 +14,9 @@
uses(RefreshDatabase::class);
beforeEach(function () {
InstanceSettings::updateOrCreate(['id' => 0]);
$this->withoutMiddleware(PreventRequestsDuringMaintenance::class);
InstanceSettings::unguarded(fn () => InstanceSettings::updateOrCreate(['id' => 0], ['id' => 0]));
$this->team = Team::factory()->create();
@ -217,6 +220,44 @@
$this->get("/server/{$this->server->uuid}")->assertSuccessful();
});
test('admin can access sentinel configuration and logs pages', function () {
$this->actingAs($this->admin);
session(['currentTeam' => $this->team]);
$this->get(route('server.sentinel', ['server_uuid' => $this->server->uuid]))
->assertSuccessful();
$this->get(route('server.sentinel.logs', ['server_uuid' => $this->server->uuid]))
->assertSuccessful();
});
test('member cannot access sentinel configuration and logs pages', function () {
$this->actingAs($this->member);
session(['currentTeam' => $this->team]);
$this->get(route('server.sentinel', ['server_uuid' => $this->server->uuid]))
->assertForbidden();
$this->get(route('server.sentinel.logs', ['server_uuid' => $this->server->uuid]))
->assertForbidden();
});
test('sentinel navigation is only visible to team admins', function () {
$this->actingAs($this->member);
session(['currentTeam' => $this->team]);
$this->get("/server/{$this->server->uuid}")
->assertSuccessful()
->assertDontSee(route('server.sentinel', ['server_uuid' => $this->server->uuid]));
$this->actingAs($this->admin);
session(['currentTeam' => $this->team]);
$this->get("/server/{$this->server->uuid}")
->assertSuccessful()
->assertSee(route('server.sentinel', ['server_uuid' => $this->server->uuid]));
});
test('unauthenticated user cannot access server page', function () {
$this->get("/server/{$this->server->uuid}")->assertRedirect('/login');
});

View file

@ -0,0 +1,54 @@
<?php
use App\Models\S3Storage;
use App\Models\Team;
use App\Models\User;
use App\Policies\S3StoragePolicy;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Facades\Gate;
uses(RefreshDatabase::class);
test('s3 storage model resolves its registered policy through the gate', function () {
expect(Gate::getPolicyFor(S3Storage::class))->toBeInstanceOf(S3StoragePolicy::class);
});
test('s3 storage create ability is enforced through the registered policy', function () {
$team = Team::factory()->create();
$admin = User::factory()->create();
$admin->teams()->attach($team, ['role' => 'admin']);
$member = User::factory()->create();
$member->teams()->attach($team, ['role' => 'member']);
$this->actingAs($admin);
session(['currentTeam' => $team]);
expect($admin->can('create', S3Storage::class))->toBeTrue()
->and($member->can('create', S3Storage::class))->toBeFalse();
});
test('s3 storage validate connection ability is enforced through the registered policy', function () {
$team = Team::factory()->create();
$admin = User::factory()->create();
$admin->teams()->attach($team, ['role' => 'admin']);
$member = User::factory()->create();
$member->teams()->attach($team, ['role' => 'member']);
$storage = S3Storage::create([
'team_id' => $team->id,
'name' => 'Backups',
'description' => 'Team backup storage',
'region' => 'us-east-1',
'key' => 'access-key',
'secret' => 'secret-key',
'bucket' => 'coolify-backups',
'endpoint' => 'https://s3.us-east-1.amazonaws.com',
]);
expect($admin->can('validateConnection', $storage))->toBeTrue()
->and($member->can('validateConnection', $storage))->toBeFalse();
});

View file

@ -152,6 +152,18 @@
$policy = new ServerPolicy;
expect($policy->manageSentinel($user, $server))->toBeTrue();
expect($policy->viewSentinel($user, $server))->toBeTrue();
expect($policy->manageCaCertificate($user, $server))->toBeTrue();
expect($policy->viewSecurity($user, $server))->toBeTrue();
});
it('denies team member to view sentinel', function () {
$user = Mockery::mock(User::class)->makePartial();
$user->shouldReceive('isAdminOfTeam')->with(1)->andReturn(false);
$server = Mockery::mock(Server::class)->makePartial();
$server->team_id = 1;
$policy = new ServerPolicy;
expect($policy->viewSentinel($user, $server))->toBeFalse();
});