mirror of
https://github.com/coollabsio/coolify.git
synced 2026-08-24 10:05:47 -05:00
fix(security): enforce team-scoped authorization for scheduled tasks (#11239)
Co-authored-by: Andras Bacsai <5845193+andrasbacsai@users.noreply.github.com>
This commit is contained in:
co-authored by
Andras Bacsai
parent
8aa2cbe462
commit
8170798115
@@ -10,6 +10,7 @@ use Livewire\Component;
|
||||
|
||||
class Executions extends Component
|
||||
{
|
||||
#[Locked]
|
||||
public ScheduledTask $task;
|
||||
|
||||
#[Locked]
|
||||
@@ -28,6 +29,7 @@ class Executions extends Component
|
||||
|
||||
public $logsPerPage = 100;
|
||||
|
||||
#[Locked]
|
||||
public $selectedExecution = null;
|
||||
|
||||
public $isPollingActive = false;
|
||||
@@ -45,7 +47,7 @@ class Executions extends Component
|
||||
{
|
||||
try {
|
||||
$this->taskId = $taskId;
|
||||
$this->task = ScheduledTask::findOrFail($taskId);
|
||||
$this->task = ScheduledTask::where('team_id', Auth::user()->currentTeam()->id)->findOrFail($taskId);
|
||||
$this->executions = $this->task->executions()->take(20)->get();
|
||||
$this->serverTimezone = data_get($this->task, 'application.destination.server.settings.server_timezone');
|
||||
if (! $this->serverTimezone) {
|
||||
|
||||
@@ -15,8 +15,10 @@ class Show extends Component
|
||||
{
|
||||
use AuthorizesRequests;
|
||||
|
||||
#[Locked]
|
||||
public Application|Service $resource;
|
||||
|
||||
#[Locked]
|
||||
public ScheduledTask $task;
|
||||
|
||||
#[Locked]
|
||||
@@ -115,6 +117,7 @@ class Show extends Component
|
||||
{
|
||||
try {
|
||||
$this->authorize('update', $this->resource);
|
||||
$this->authorize('update', $this->task);
|
||||
$this->isEnabled = ! $this->isEnabled;
|
||||
$this->task->enabled = $this->isEnabled;
|
||||
$this->task->save();
|
||||
@@ -128,6 +131,7 @@ class Show extends Component
|
||||
{
|
||||
try {
|
||||
$this->authorize('update', $this->resource);
|
||||
$this->authorize('update', $this->task);
|
||||
$this->syncData(true);
|
||||
$this->dispatch('success', 'Scheduled task updated.');
|
||||
$this->refreshTasks();
|
||||
@@ -140,6 +144,7 @@ class Show extends Component
|
||||
{
|
||||
try {
|
||||
$this->authorize('update', $this->resource);
|
||||
$this->authorize('update', $this->task);
|
||||
$this->syncData(true);
|
||||
$this->dispatch('success', 'Scheduled task updated.');
|
||||
} catch (\Exception $e) {
|
||||
@@ -160,6 +165,7 @@ class Show extends Component
|
||||
{
|
||||
try {
|
||||
$this->authorize('update', $this->resource);
|
||||
$this->authorize('delete', $this->task);
|
||||
$this->task->delete();
|
||||
|
||||
if ($this->type === 'application') {
|
||||
@@ -176,6 +182,7 @@ class Show extends Component
|
||||
{
|
||||
try {
|
||||
$this->authorize('update', $this->resource);
|
||||
$this->authorize('update', $this->task);
|
||||
ScheduledTaskJob::dispatch($this->task);
|
||||
$this->dispatch('success', 'Scheduled task executed.');
|
||||
} catch (\Exception $e) {
|
||||
|
||||
@@ -0,0 +1,70 @@
|
||||
<?php
|
||||
|
||||
namespace App\Policies;
|
||||
|
||||
use App\Models\ScheduledTask;
|
||||
use App\Models\User;
|
||||
use Illuminate\Auth\Access\Response;
|
||||
|
||||
class ScheduledTaskPolicy
|
||||
{
|
||||
/**
|
||||
* Determine whether the user can view any models.
|
||||
*/
|
||||
public function viewAny(User $user): bool
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Determine whether the user can view the model.
|
||||
*/
|
||||
public function view(User $user, ScheduledTask $scheduledTask): bool
|
||||
{
|
||||
return $user->teams->contains('id', $scheduledTask->team_id);
|
||||
}
|
||||
|
||||
/**
|
||||
* Determine whether the user can create models.
|
||||
*/
|
||||
public function create(User $user): bool
|
||||
{
|
||||
return $user->isAdmin();
|
||||
}
|
||||
|
||||
/**
|
||||
* Determine whether the user can update the model.
|
||||
*/
|
||||
public function update(User $user, ScheduledTask $scheduledTask): Response
|
||||
{
|
||||
if (! $user->isAdminOfTeam($scheduledTask->team_id)) {
|
||||
return Response::deny('You need at least admin or owner permissions to update this scheduled task.');
|
||||
}
|
||||
|
||||
return Response::allow();
|
||||
}
|
||||
|
||||
/**
|
||||
* Determine whether the user can delete the model.
|
||||
*/
|
||||
public function delete(User $user, ScheduledTask $scheduledTask): bool
|
||||
{
|
||||
return $user->isAdminOfTeam($scheduledTask->team_id);
|
||||
}
|
||||
|
||||
/**
|
||||
* Determine whether the user can restore the model.
|
||||
*/
|
||||
public function restore(User $user, ScheduledTask $scheduledTask): bool
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Determine whether the user can permanently delete the model.
|
||||
*/
|
||||
public function forceDelete(User $user, ScheduledTask $scheduledTask): bool
|
||||
{
|
||||
return false;
|
||||
}
|
||||
}
|
||||
@@ -19,6 +19,7 @@ use App\Models\PrivateKey;
|
||||
use App\Models\Project;
|
||||
use App\Models\PushoverNotificationSettings;
|
||||
use App\Models\S3Storage;
|
||||
use App\Models\ScheduledTask;
|
||||
use App\Models\Server;
|
||||
use App\Models\Service;
|
||||
use App\Models\ServiceApplication;
|
||||
@@ -56,6 +57,7 @@ use App\Policies\PrivateKeyPolicy;
|
||||
use App\Policies\ProjectPolicy;
|
||||
use App\Policies\ResourceCreatePolicy;
|
||||
use App\Policies\S3StoragePolicy;
|
||||
use App\Policies\ScheduledTaskPolicy;
|
||||
use App\Policies\ServerPolicy;
|
||||
use App\Policies\ServiceApplicationPolicy;
|
||||
use App\Policies\ServiceDatabasePolicy;
|
||||
@@ -118,6 +120,9 @@ class AuthServiceProvider extends ServiceProvider
|
||||
// S3 storage policy
|
||||
S3Storage::class => S3StoragePolicy::class,
|
||||
|
||||
// Scheduled task policy
|
||||
ScheduledTask::class => ScheduledTaskPolicy::class,
|
||||
|
||||
// Team policy
|
||||
Team::class => TeamPolicy::class,
|
||||
|
||||
|
||||
@@ -0,0 +1,125 @@
|
||||
<?php
|
||||
|
||||
use App\Livewire\Project\Shared\ScheduledTask\Executions;
|
||||
use App\Livewire\Project\Shared\ScheduledTask\Show;
|
||||
use App\Models\Application;
|
||||
use App\Models\Environment;
|
||||
use App\Models\InstanceSettings;
|
||||
use App\Models\Project;
|
||||
use App\Models\ScheduledTask;
|
||||
use App\Models\Server;
|
||||
use App\Models\StandaloneDocker;
|
||||
use App\Models\Team;
|
||||
use App\Models\User;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
use Illuminate\Support\Facades\Gate;
|
||||
use Livewire\Attributes\Locked;
|
||||
use Livewire\Livewire;
|
||||
|
||||
uses(RefreshDatabase::class);
|
||||
|
||||
beforeEach(function () {
|
||||
$this->withoutVite();
|
||||
InstanceSettings::forceCreate(['id' => 0]);
|
||||
|
||||
$this->attacker = User::factory()->create();
|
||||
$this->attackerTeam = Team::factory()->create();
|
||||
$this->attacker->teams()->attach($this->attackerTeam, ['role' => 'owner']);
|
||||
|
||||
$this->victim = User::factory()->create();
|
||||
$this->victimTeam = Team::factory()->create();
|
||||
$this->victim->teams()->attach($this->victimTeam, ['role' => 'owner']);
|
||||
|
||||
// Attacker team gets a real server/project/env/app so Show can mount + authorize
|
||||
$this->server = Server::factory()->create(['team_id' => $this->attackerTeam->id]);
|
||||
$this->destination = StandaloneDocker::where('server_id', $this->server->id)->first();
|
||||
$this->project = Project::factory()->create(['team_id' => $this->attackerTeam->id]);
|
||||
$this->environment = Environment::factory()->create(['project_id' => $this->project->id]);
|
||||
$this->application = Application::factory()->create([
|
||||
'environment_id' => $this->environment->id,
|
||||
'destination_id' => $this->destination->id,
|
||||
'destination_type' => $this->destination->getMorphClass(),
|
||||
]);
|
||||
|
||||
$this->actingAs($this->attacker);
|
||||
session(['currentTeam' => $this->attackerTeam]);
|
||||
});
|
||||
|
||||
describe('ScheduledTask locked properties', function () {
|
||||
test('Show component task property has Locked attribute', function () {
|
||||
$property = new ReflectionProperty(Show::class, 'task');
|
||||
$attributes = $property->getAttributes(Locked::class);
|
||||
|
||||
expect($attributes)->not->toBeEmpty();
|
||||
});
|
||||
|
||||
test('Show component resource property has Locked attribute', function () {
|
||||
$property = new ReflectionProperty(Show::class, 'resource');
|
||||
$attributes = $property->getAttributes(Locked::class);
|
||||
|
||||
expect($attributes)->not->toBeEmpty();
|
||||
});
|
||||
|
||||
test('Executions component task property has Locked attribute', function () {
|
||||
$property = new ReflectionProperty(Executions::class, 'task');
|
||||
$attributes = $property->getAttributes(Locked::class);
|
||||
|
||||
expect($attributes)->not->toBeEmpty();
|
||||
});
|
||||
|
||||
test('Executions component selected execution property has Locked attribute', function () {
|
||||
$property = new ReflectionProperty(Executions::class, 'selectedExecution');
|
||||
$attributes = $property->getAttributes(Locked::class);
|
||||
|
||||
expect($attributes)->not->toBeEmpty();
|
||||
});
|
||||
});
|
||||
|
||||
describe('ScheduledTask cross-team access', function () {
|
||||
test('Executions rejects mounting another team task id', function () {
|
||||
$victimTask = ScheduledTask::factory()->create([
|
||||
'team_id' => $this->victimTeam->id,
|
||||
'command' => 'echo top-secret-victim-command',
|
||||
]);
|
||||
|
||||
Livewire::test(Executions::class, ['taskId' => $victimTask->id])
|
||||
->assertStatus(404);
|
||||
});
|
||||
|
||||
test('Show policy denies updating another team task', function () {
|
||||
$victimTask = ScheduledTask::factory()->create([
|
||||
'team_id' => $this->victimTeam->id,
|
||||
'name' => 'victim-task',
|
||||
'command' => 'echo original-victim-command',
|
||||
]);
|
||||
|
||||
expect(
|
||||
Gate::forUser($this->attacker)->allows('update', $victimTask)
|
||||
)->toBeFalse();
|
||||
});
|
||||
|
||||
test('Show policy denies deleting another team task', function () {
|
||||
$victimTask = ScheduledTask::factory()->create([
|
||||
'team_id' => $this->victimTeam->id,
|
||||
'name' => 'victim-task',
|
||||
'command' => 'echo original-victim-command',
|
||||
]);
|
||||
|
||||
expect(
|
||||
Gate::forUser($this->attacker)->allows('delete', $victimTask)
|
||||
)->toBeFalse();
|
||||
});
|
||||
|
||||
test('Show policy allows updating own team task', function () {
|
||||
$ownTask = ScheduledTask::factory()->create([
|
||||
'team_id' => $this->attackerTeam->id,
|
||||
'application_id' => $this->application->id,
|
||||
'name' => 'own-task',
|
||||
'command' => 'echo original-command',
|
||||
]);
|
||||
|
||||
expect(
|
||||
Gate::forUser($this->attacker)->allows('update', $ownTask)
|
||||
)->toBeTrue();
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user