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:
Helge Sverre
2026-08-18 16:01:17 +02:00
committed by GitHub
co-authored by Andras Bacsai
parent 8aa2cbe462
commit 8170798115
5 changed files with 210 additions and 1 deletions
@@ -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) {
+70
View File
@@ -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;
}
}
+5
View File
@@ -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();
});
});