fix(permissions): show logs and task output only to admins and read:sensitive tokens

Container logs and scheduled-task commands and output are now shown only to
users who can edit the resource. The API returns them only to tokens with
the read:sensitive or root ability, the same as the MCP tools.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Andras Bacsai
2026-09-28 13:20:59 +02:00
co-authored by Claude Opus 5.5
parent 1ecd927ca3
commit 739d9e9d23
14 changed files with 206 additions and 19 deletions
@@ -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: [
@@ -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: [
@@ -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);
});
@@ -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' => []]],
@@ -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' => []]],
@@ -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: [
+21 -1
View File
@@ -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(),
]);
}
}
@@ -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));
@@ -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;
@@ -1,3 +1,8 @@
@if (! $canReadLogs)
<x-callout type="info" title="Hidden (only admins can view)">
Container logs can contain secrets.
</x-callout>
@else
<div @class(['w-full min-w-0', 'runtime-log-shell' => $collapsible])>
<div id="screen" x-data="runtimeLogs(@js([
'collapsible' => $collapsible,
@@ -309,3 +314,4 @@
</div>
</div>
</div>
@endif
@@ -71,7 +71,7 @@
Load all
</x-forms.button>
@endif
@if (strlen($execution->message) > 0)
@if (strlen($execution->message) > 0 && $this->canReadOutput())
<x-forms.button wire:click.prevent="downloadLogs({{ data_get($execution, 'id') }})">
Download logs
</x-forms.button>
+6 -6
View File
@@ -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']);
@@ -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 * * *');
});
@@ -0,0 +1,138 @@
<?php
use App\Livewire\Project\Shared\GetLogs;
use App\Livewire\Project\Shared\ScheduledTask\Executions;
use App\Livewire\Project\Shared\ScheduledTask\Show;
use App\Models\Application;
use App\Models\InstanceSettings;
use App\Models\Project;
use App\Models\ScheduledTask;
use App\Models\ScheduledTaskExecution;
use App\Models\Server;
use App\Models\Team;
use App\Models\User;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Routing\Events\RouteMatched;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Event;
use Illuminate\Support\Facades\Process;
use Livewire\Livewire;
uses(RefreshDatabase::class);
afterEach(function () {
Event::forget(RouteMatched::class);
});
beforeEach(function () {
Process::fake();
InstanceSettings::forceCreate(['id' => 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)');
});