diff --git a/app/Http/Controllers/Api/ApplicationsController.php b/app/Http/Controllers/Api/ApplicationsController.php index f721819976..04b574abe9 100644 --- a/app/Http/Controllers/Api/ApplicationsController.php +++ b/app/Http/Controllers/Api/ApplicationsController.php @@ -2438,7 +2438,7 @@ class ApplicationsController extends Controller #[OA\Get( summary: 'Get application logs.', - description: 'Get application logs by UUID.', + description: 'Get application logs by UUID. Requires the `read:sensitive` or `root` token ability.', path: '/applications/{uuid}/logs', operationId: 'get-application-logs-by-uuid', security: [ @@ -2505,7 +2505,7 @@ class ApplicationsController extends Controller )] #[OA\Get( summary: 'Get preview application logs.', - description: 'Get runtime container logs for a preview deployment by application UUID and pull request ID.', + description: 'Get runtime container logs for a preview deployment by application UUID and pull request ID. Requires the `read:sensitive` or `root` token ability.', path: '/applications/{uuid}/previews/{pull_request_id}/logs', operationId: 'get-preview-application-logs-by-pull-request-id', security: [ diff --git a/app/Http/Controllers/Api/DatabasesController.php b/app/Http/Controllers/Api/DatabasesController.php index 57af00469c..b4b9bfbb5f 100644 --- a/app/Http/Controllers/Api/DatabasesController.php +++ b/app/Http/Controllers/Api/DatabasesController.php @@ -2612,7 +2612,7 @@ class DatabasesController extends Controller #[OA\Get( summary: 'Get database logs.', - description: 'Get database logs by UUID.', + description: 'Get database logs by UUID. Requires the `read:sensitive` or `root` token ability.', path: '/databases/{uuid}/logs', operationId: 'get-database-logs-by-uuid', security: [ diff --git a/app/Http/Controllers/Api/ScheduledTasksController.php b/app/Http/Controllers/Api/ScheduledTasksController.php index c0eff20521..910be871ad 100644 --- a/app/Http/Controllers/Api/ScheduledTasksController.php +++ b/app/Http/Controllers/Api/ScheduledTasksController.php @@ -21,10 +21,18 @@ class ScheduledTasksController extends Controller 'application_id', 'service_id', ]); + if (! $this->canReadSensitive()) { + $task->makeHidden(['command']); + } return serializeApiResponse($task); } + private function canReadSensitive(): bool + { + return request()->attributes->get('can_read_sensitive', false) === true; + } + private function resolveApplication(Request $request, int $teamId): ?Application { return Application::ownedByCurrentTeamAPI($teamId)->where('uuid', $request->uuid)->first(); @@ -218,6 +226,9 @@ class ScheduledTasksController extends Controller $executions = $task->executions()->get()->map(function ($execution) { $execution->makeHidden(['id', 'scheduled_task_id']); + if (! $this->canReadSensitive()) { + $execution->makeHidden(['message']); + } return serializeApiResponse($execution); }); diff --git a/app/Http/Controllers/Api/ServiceApplicationsController.php b/app/Http/Controllers/Api/ServiceApplicationsController.php index adb0cb678e..49380f2a42 100644 --- a/app/Http/Controllers/Api/ServiceApplicationsController.php +++ b/app/Http/Controllers/Api/ServiceApplicationsController.php @@ -378,7 +378,7 @@ class ServiceApplicationsController extends Controller #[OA\Get( summary: 'Get service application logs', - description: 'Get Docker logs for a single compose service container.', + description: 'Get Docker logs for a single compose service container. Requires the `read:sensitive` or `root` token ability.', path: '/services/{uuid}/applications/{app_uuid}/logs', operationId: 'get-service-application-logs-by-service-and-app-uuid', security: [ @@ -447,7 +447,7 @@ class ServiceApplicationsController extends Controller )] #[OA\Post( summary: 'Get service application logs', - description: 'Get Docker logs for a single compose service container.', + description: 'Get Docker logs for a single compose service container. Requires the `read:sensitive` or `root` token ability.', path: '/services/{uuid}/applications/{app_uuid}/logs', operationId: 'post-service-application-logs-by-service-and-app-uuid', security: [['bearerAuth' => []]], diff --git a/app/Http/Controllers/Api/ServiceDatabasesController.php b/app/Http/Controllers/Api/ServiceDatabasesController.php index 62d1292192..795778ffb0 100644 --- a/app/Http/Controllers/Api/ServiceDatabasesController.php +++ b/app/Http/Controllers/Api/ServiceDatabasesController.php @@ -358,7 +358,7 @@ class ServiceDatabasesController extends Controller #[OA\Get( summary: 'Get service database logs', - description: 'Get Docker logs for a compose database container.', + description: 'Get Docker logs for a compose database container. Requires the `read:sensitive` or `root` token ability.', path: '/services/{uuid}/databases/{database_uuid}/logs', operationId: 'get-service-database-logs-by-service-and-database-uuid', security: [['bearerAuth' => []]], diff --git a/app/Http/Controllers/Api/ServicesController.php b/app/Http/Controllers/Api/ServicesController.php index f78f2a0f3d..fdcaf57f5b 100644 --- a/app/Http/Controllers/Api/ServicesController.php +++ b/app/Http/Controllers/Api/ServicesController.php @@ -873,7 +873,7 @@ class ServicesController extends Controller #[OA\Get( summary: 'Get service logs.', - description: 'Get logs for a specific service sub-resource by service UUID. The `sub_service_name` query parameter must match the `name` field of one of the service applications or databases returned by `GET /services/{uuid}`.', + description: 'Get logs for a specific service sub-resource by service UUID. The `sub_service_name` query parameter must match the `name` field of one of the service applications or databases returned by `GET /services/{uuid}`. Requires the `read:sensitive` or `root` token ability.', path: '/services/{uuid}/logs', operationId: 'get-service-logs-by-uuid', security: [ diff --git a/app/Livewire/Project/Shared/GetLogs.php b/app/Livewire/Project/Shared/GetLogs.php index 71e8ad8560..e809c1aae5 100644 --- a/app/Livewire/Project/Shared/GetLogs.php +++ b/app/Livewire/Project/Shared/GetLogs.php @@ -150,6 +150,9 @@ class GetLogs extends Component #[Renderless] public function getLogs(?string $since = null): string { + if (! $this->canReadLogs()) { + return 'Unauthorized.'; + } if (! Server::ownedByCurrentTeam()->where('id', $this->server->id)->exists()) { return 'Unauthorized.'; } @@ -174,6 +177,9 @@ class GetLogs extends Component public function copyLogs(): string { + if (! $this->canReadLogs()) { + return ''; + } if (! Server::ownedByCurrentTeam()->where('id', $this->server->id)->exists()) { return ''; } @@ -191,6 +197,9 @@ class GetLogs extends Component public function downloadAllLogs(): string { + if (! $this->canReadLogs()) { + return ''; + } if (! Server::ownedByCurrentTeam()->where('id', $this->server->id)->exists()) { return ''; } @@ -263,8 +272,19 @@ class GetLogs extends Component return "({$command}) 2>&1 | head -c ".($maxBytes + 1); } + /** + * Container output can contain secrets, so only users who can edit the resource (or the server, + * for server-level containers) may read it. + */ + private function canReadLogs(): bool + { + return auth()->user()?->can('update', $this->resource ?? $this->server) ?? false; + } + public function render() { - return view('livewire.project.shared.get-logs'); + return view('livewire.project.shared.get-logs', [ + 'canReadLogs' => $this->canReadLogs(), + ]); } } diff --git a/app/Livewire/Project/Shared/ScheduledTask/Executions.php b/app/Livewire/Project/Shared/ScheduledTask/Executions.php index e95fd2f5a1..cf426e0ca8 100644 --- a/app/Livewire/Project/Shared/ScheduledTask/Executions.php +++ b/app/Livewire/Project/Shared/ScheduledTask/Executions.php @@ -126,6 +126,10 @@ class Executions extends Component return collect(); } + if (! $this->canReadOutput()) { + return collect(['Hidden (only admins can view)']); + } + if (! $this->selectedExecution->message) { return collect(['Waiting for task output...']); } @@ -138,7 +142,7 @@ class Executions extends Component public function downloadLogs(int $executionId) { $execution = $this->executions->firstWhere('id', $executionId); - if (! $execution) { + if (! $execution || ! $this->canReadOutput()) { return; } @@ -147,9 +151,17 @@ class Executions extends Component }, 'task-execution-'.$execution->id.'.log'); } + /** + * Task output can contain secrets, so only users who can edit the task's resource may read it. + */ + public function canReadOutput(): bool + { + return auth()->user()?->can('update', $this->task->application ?? $this->task->service) ?? false; + } + public function hasMoreLogs() { - if (! $this->selectedExecution || ! $this->selectedExecution->message) { + if (! $this->selectedExecution || ! $this->selectedExecution->message || ! $this->canReadOutput()) { return false; } $lines = collect(explode("\n", $this->selectedExecution->message)); diff --git a/app/Livewire/Project/Shared/ScheduledTask/Show.php b/app/Livewire/Project/Shared/ScheduledTask/Show.php index c121f1b93b..3bfe0700d9 100644 --- a/app/Livewire/Project/Shared/ScheduledTask/Show.php +++ b/app/Livewire/Project/Shared/ScheduledTask/Show.php @@ -106,7 +106,7 @@ class Show extends Component } else { $this->isEnabled = $this->task->enabled; $this->name = $this->task->name; - $this->command = $this->task->command; + $this->command = auth()->user()?->can('update', $this->resource) ? $this->task->command : 'Hidden (only admins can view)'; $this->frequency = $this->task->frequency; $this->container = $this->task->container; $this->timeout = $this->task->timeout ?? 300; diff --git a/resources/views/livewire/project/shared/get-logs.blade.php b/resources/views/livewire/project/shared/get-logs.blade.php index 2fad7cfa7a..e05ef34eaa 100644 --- a/resources/views/livewire/project/shared/get-logs.blade.php +++ b/resources/views/livewire/project/shared/get-logs.blade.php @@ -1,3 +1,8 @@ +@if (! $canReadLogs) + + Container logs can contain secrets. + +@else
$collapsible])>
Download logs diff --git a/routes/api.php b/routes/api.php index b90345c24c..a17edb3528 100644 --- a/routes/api.php +++ b/routes/api.php @@ -250,8 +250,8 @@ Route::group([ Route::patch('/applications/{uuid}/envs/bulk', [ApplicationsController::class, 'create_bulk_envs'])->middleware(['api.ability:write']); Route::patch('/applications/{uuid}/envs', [ApplicationsController::class, 'update_env_by_uuid'])->middleware(['api.ability:write']); Route::delete('/applications/{uuid}/envs/{env_uuid}', [ApplicationsController::class, 'delete_env_by_uuid'])->middleware(['api.ability:write']); - Route::get('/applications/{uuid}/logs', [ApplicationsController::class, 'logs_by_uuid'])->middleware(['api.ability:read']); - Route::get('/applications/{uuid}/previews/{pull_request_id}/logs', [ApplicationsController::class, 'logs_by_uuid'])->middleware(['api.ability:read']); + Route::get('/applications/{uuid}/logs', [ApplicationsController::class, 'logs_by_uuid'])->middleware(['api.ability:read:sensitive']); + Route::get('/applications/{uuid}/previews/{pull_request_id}/logs', [ApplicationsController::class, 'logs_by_uuid'])->middleware(['api.ability:read:sensitive']); Route::get('/applications/{uuid}/storages', [ApplicationsController::class, 'storages'])->middleware(['api.ability:read']); Route::post('/applications/{uuid}/storages', [ApplicationsController::class, 'create_storage'])->middleware(['api.ability:write']); Route::patch('/applications/{uuid}/storages', [ApplicationsController::class, 'update_storage'])->middleware(['api.ability:write']); @@ -321,7 +321,7 @@ Route::group([ Route::post('/databases/{uuid}/backups', [DatabasesController::class, 'create_backup'])->middleware(['api.ability:write']); Route::patch('/databases/{uuid}/backups/{scheduled_backup_uuid}', [DatabasesController::class, 'update_backup'])->middleware(['api.ability:write']); Route::delete('/databases/{uuid}', [DatabasesController::class, 'delete_by_uuid'])->middleware(['api.ability:write']); - Route::get('/databases/{uuid}/logs', [DatabasesController::class, 'logs_by_uuid'])->middleware(['api.ability:read']); + Route::get('/databases/{uuid}/logs', [DatabasesController::class, 'logs_by_uuid'])->middleware(['api.ability:read:sensitive']); Route::delete('/databases/{uuid}/backups/{scheduled_backup_uuid}', [DatabasesController::class, 'delete_backup_by_uuid'])->middleware(['api.ability:write']); Route::delete('/databases/{uuid}/backups/{scheduled_backup_uuid}/executions/{execution_uuid}', [DatabasesController::class, 'delete_execution_by_uuid'])->middleware(['api.ability:write']); @@ -385,7 +385,7 @@ Route::group([ Route::patch('/services/{uuid}/envs/bulk', [ServicesController::class, 'create_bulk_envs'])->middleware(['api.ability:write']); Route::patch('/services/{uuid}/envs', [ServicesController::class, 'update_env_by_uuid'])->middleware(['api.ability:write']); Route::delete('/services/{uuid}/envs/{env_uuid}', [ServicesController::class, 'delete_env_by_uuid'])->middleware(['api.ability:write']); - Route::get('/services/{uuid}/logs', [ServicesController::class, 'logs_by_uuid'])->middleware(['api.ability:read']); + Route::get('/services/{uuid}/logs', [ServicesController::class, 'logs_by_uuid'])->middleware(['api.ability:read:sensitive']); Route::get('/services/{uuid}/tags', [ServicesController::class, 'tags'])->middleware(['api.ability:read']); Route::post('/services/{uuid}/tags', [ServicesController::class, 'create_tag'])->middleware(['api.ability:write']); @@ -404,7 +404,7 @@ Route::group([ Route::get('/services/{uuid}/applications', [ServiceApplicationsController::class, 'index'])->middleware(['api.ability:read']); Route::get('/services/{uuid}/applications/{app_uuid}', [ServiceApplicationsController::class, 'show'])->middleware(['api.ability:read']); Route::patch('/services/{uuid}/applications/{app_uuid}', [ServiceApplicationsController::class, 'update'])->middleware(['api.ability:write']); - Route::match(['get', 'post'], '/services/{uuid}/applications/{app_uuid}/logs', [ServiceApplicationsController::class, 'logs_by_uuid'])->middleware(['api.ability:read']); + Route::match(['get', 'post'], '/services/{uuid}/applications/{app_uuid}/logs', [ServiceApplicationsController::class, 'logs_by_uuid'])->middleware(['api.ability:read:sensitive']); Route::get('/services/{uuid}/applications/{app_uuid}/start', [OtherController::class, 'post_required'])->middleware(['api.ability:deploy']); Route::get('/services/{uuid}/applications/{app_uuid}/restart', [OtherController::class, 'post_required'])->middleware(['api.ability:deploy']); Route::get('/services/{uuid}/applications/{app_uuid}/stop', [OtherController::class, 'post_required'])->middleware(['api.ability:deploy']); @@ -418,7 +418,7 @@ Route::group([ Route::post('/services/{uuid}/databases/{database_uuid}/imports', [ServiceDatabasesController::class, 'create_import'])->middleware(['api.ability:deploy'])->name('api.service-databases.imports.store'); Route::get('/services/{uuid}/databases/{database_uuid}/imports/{activity_id}', [ServiceDatabasesController::class, 'show_import'])->middleware(['api.ability:read'])->name('api.service-databases.imports.show'); Route::patch('/services/{uuid}/databases/{database_uuid}', [ServiceDatabasesController::class, 'update'])->middleware(['api.ability:write']); - Route::get('/services/{uuid}/databases/{database_uuid}/logs', [ServiceDatabasesController::class, 'logs'])->middleware(['api.ability:read']); + Route::get('/services/{uuid}/databases/{database_uuid}/logs', [ServiceDatabasesController::class, 'logs'])->middleware(['api.ability:read:sensitive']); Route::post('/services/{uuid}/databases/{database_uuid}/start', [ServiceDatabasesController::class, 'start'])->middleware(['api.ability:deploy']); Route::post('/services/{uuid}/databases/{database_uuid}/restart', [ServiceDatabasesController::class, 'restart'])->middleware(['api.ability:deploy']); Route::post('/services/{uuid}/databases/{database_uuid}/stop', [ServiceDatabasesController::class, 'stop'])->middleware(['api.ability:deploy']); diff --git a/tests/Feature/Authorization/ScheduledTaskSyncAuthorizationTest.php b/tests/Feature/Authorization/ScheduledTaskSyncAuthorizationTest.php index 5a44b26c40..f84e50b732 100644 --- a/tests/Feature/Authorization/ScheduledTaskSyncAuthorizationTest.php +++ b/tests/Feature/Authorization/ScheduledTaskSyncAuthorizationTest.php @@ -80,7 +80,7 @@ test('read-only member can view an existing scheduled task', function () { Livewire::test(Show::class) ->assertSuccessful() ->assertSet('name', 'validate-benign') - ->assertSet('command', 'echo scheduled-ok') + ->assertSet('command', 'Hidden (only admins can view)') ->assertSet('frequency', '0 0 * * *'); }); diff --git a/tests/Feature/Authorization/TaskAndLogOutputVisibilityTest.php b/tests/Feature/Authorization/TaskAndLogOutputVisibilityTest.php new file mode 100644 index 0000000000..eb51d9c4b3 --- /dev/null +++ b/tests/Feature/Authorization/TaskAndLogOutputVisibilityTest.php @@ -0,0 +1,138 @@ + 0, 'is_api_enabled' => true]); + + $this->team = Team::factory()->create(); + $this->owner = User::factory()->create(); + $this->member = User::factory()->create(); + $this->team->members()->attach($this->owner, ['role' => 'owner']); + $this->team->members()->attach($this->member, ['role' => 'member']); + + $this->server = Server::factory()->create(['team_id' => $this->team->id]); + $destination = $this->server->standaloneDockers()->firstOrFail(); + $project = Project::factory()->create(['team_id' => $this->team->id]); + $this->environment = $project->environments()->firstOrFail(); + + $this->application = Application::factory()->create([ + 'environment_id' => $this->environment->id, + 'destination_id' => $destination->id, + 'destination_type' => $destination->getMorphClass(), + ]); + $this->task = ScheduledTask::factory()->create([ + 'command' => 'backup --token stored-task-command-value', + 'application_id' => $this->application->id, + 'team_id' => $this->team->id, + ]); + $this->execution = ScheduledTaskExecution::create([ + 'scheduled_task_id' => $this->task->id, + 'status' => 'success', + 'message' => 'stored-task-output-value', + ]); +}); + +function outputVisibilityToken(User $user, Team $team, array $abilities): string +{ + session(['currentTeam' => $team]); + $token = $user->createToken('output-visibility', $abilities); + DB::table('personal_access_tokens')->where('id', $token->accessToken->id)->update(['team_id' => $team->id]); + + return $token->plainTextToken; +} + +function scheduledTaskComponents(): array +{ + Event::listen(RouteMatched::class, function (RouteMatched $event): void { + $event->route->setParameter('task_uuid', test()->task->uuid); + $event->route->setParameter('project_uuid', test()->environment->project->uuid); + $event->route->setParameter('environment_uuid', test()->environment->uuid); + $event->route->setParameter('application_uuid', test()->application->uuid); + }); + + return [ + Livewire::test(Show::class), + Livewire::test(Executions::class, ['taskId' => test()->task->id])->call('selectTask', test()->execution->id), + ]; +} + +it('hides task commands, task output, and logs from a read token', function () { + $headers = ['Authorization' => 'Bearer '.outputVisibilityToken($this->member, $this->team, ['read'])]; + + $tasks = $this->withHeaders($headers)->getJson("/api/v1/applications/{$this->application->uuid}/scheduled-tasks")->assertOk(); + $executions = $this->withHeaders($headers)->getJson("/api/v1/applications/{$this->application->uuid}/scheduled-tasks/{$this->task->uuid}/executions")->assertOk(); + + expect($tasks->getContent().$executions->getContent()) + ->not->toContain('stored-task-command-value') + ->not->toContain('stored-task-output-value') + ->and($tasks->json('0.name'))->toBe($this->task->name) + ->and($executions->json('0.status'))->toBe('success'); + + $this->withHeaders($headers)->getJson("/api/v1/applications/{$this->application->uuid}/logs")->assertForbidden(); +}); + +it('returns task commands and task output to a read sensitive token', function () { + $headers = ['Authorization' => 'Bearer '.outputVisibilityToken($this->owner, $this->team, ['read', 'read:sensitive'])]; + + $this->withHeaders($headers)->getJson("/api/v1/applications/{$this->application->uuid}/scheduled-tasks") + ->assertOk() + ->assertJsonPath('0.command', 'backup --token stored-task-command-value'); + $this->withHeaders($headers)->getJson("/api/v1/applications/{$this->application->uuid}/scheduled-tasks/{$this->task->uuid}/executions") + ->assertOk() + ->assertJsonPath('0.message', 'stored-task-output-value'); +}); + +it('hides task commands, task output, and logs from a member in the ui', function () { + $this->actingAs($this->member); + session(['currentTeam' => $this->team]); + + foreach (scheduledTaskComponents() as $component) { + expect(json_encode($component->snapshot).$component->html()) + ->not->toContain('stored-task-command-value') + ->not->toContain('stored-task-output-value'); + } + + Livewire::test(GetLogs::class, ['server' => $this->server, 'resource' => $this->application, 'container' => 'app-container']) + ->assertSee('Hidden (only admins can view)') + ->call('getLogs') + ->assertReturned('Unauthorized.'); + + Process::assertNothingRan(); +}); + +it('shows task commands and task output to an owner in the ui', function () { + $this->actingAs($this->owner); + session(['currentTeam' => $this->team]); + + [$show, $executions] = scheduledTaskComponents(); + + $show->assertSet('command', 'backup --token stored-task-command-value'); + $executions->assertSee('stored-task-output-value'); + + Livewire::test(GetLogs::class, ['server' => $this->server, 'resource' => $this->application, 'container' => 'app-container']) + ->assertDontSee('Hidden (only admins can view)'); +});