fix(compose): show compose content only to users who can edit the resource

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Andras Bacsai
2026-09-28 12:30:47 +02:00
co-authored by Claude Opus 5.5
parent 4ab0e89e24
commit 1e910394fd
7 changed files with 179 additions and 35 deletions
+8 -2
View File
@@ -298,6 +298,11 @@ class General extends Component
// Still sync data even on error, so form fields are populated
$this->syncData();
}
if (isset($this->parsedServices) && ! auth()->user()?->can('update', $this->application)) {
$this->parsedServices = collect([
'services' => collect(data_get($this->parsedServices, 'services', []))->map(fn () => []),
]);
}
if ($this->application->build_pack === 'dockercompose') {
// Only update if user has permission
try {
@@ -435,8 +440,9 @@ class General extends Component
$this->dockerRegistryImageName = $this->application->docker_registry_image_name;
$this->dockerRegistryImageTag = $this->application->docker_registry_image_tag;
$this->dockerComposeLocation = $this->application->docker_compose_location;
$this->dockerCompose = $this->application->docker_compose;
$this->dockerComposeRaw = $this->application->docker_compose_raw;
$canViewCompose = auth()->user()?->can('update', $this->application) ?? false;
$this->dockerCompose = $canViewCompose ? $this->application->docker_compose : null;
$this->dockerComposeRaw = $canViewCompose ? $this->application->docker_compose_raw : null;
$this->dockerComposeCustomStartCommand = $this->application->docker_compose_custom_start_command;
$this->dockerComposeCustomBuildCommand = $this->application->docker_compose_custom_build_command;
$this->customLabels = $this->application->parseContainerLabels();
+3 -2
View File
@@ -57,8 +57,9 @@ class EditCompose extends Component
$this->service->docker_compose = $this->dockerCompose;
$this->service->is_container_label_escape_enabled = $this->isContainerLabelEscapeEnabled;
} else {
$this->dockerComposeRaw = $this->service->docker_compose_raw;
$this->dockerCompose = $this->service->docker_compose;
$canViewCompose = auth()->user()?->can('update', $this->service) ?? false;
$this->dockerComposeRaw = $canViewCompose ? $this->service->docker_compose_raw : null;
$this->dockerCompose = $canViewCompose ? $this->service->docker_compose : null;
$this->isContainerLabelEscapeEnabled = $this->service->is_container_label_escape_enabled ?? false;
}
}
+4 -3
View File
@@ -26,7 +26,7 @@ class StackForm extends Component
public ?string $description = null;
public string $dockerComposeRaw;
public ?string $dockerComposeRaw = null;
public ?string $dockerCompose = null;
@@ -83,8 +83,9 @@ class StackForm extends Component
// Sync FROM model (on load/refresh)
$this->name = $this->service->name;
$this->description = $this->service->description;
$this->dockerComposeRaw = $this->service->docker_compose_raw;
$this->dockerCompose = $this->service->docker_compose;
$canViewCompose = auth()->user()?->can('update', $this->service) ?? false;
$this->dockerComposeRaw = $canViewCompose ? $this->service->docker_compose_raw : null;
$this->dockerCompose = $canViewCompose ? $this->service->docker_compose : null;
$this->connectToDockerNetwork = $this->service->connect_to_docker_network;
}
}
@@ -50,13 +50,13 @@ class ConfigurationChecker extends Component
}
/**
* Members must never see environment variable values, so redact every
* environment-section change before it is serialized to the browser.
* Members must never see environment variable values or Compose content, so redact
* those changes before they are serialized to the browser.
*
* @param array<int, array<string, mixed>> $changes
* @return array<int, array<string, mixed>>
*/
private function redactEnvironmentChanges(array $changes, bool $redact): array
private function redactHiddenChanges(array $changes, bool $redact): array
{
if (! $redact) {
return $changes;
@@ -64,7 +64,9 @@ class ConfigurationChecker extends Component
return collect($changes)
->map(function (array $change): array {
if (data_get($change, 'section') !== 'environment') {
$isHidden = data_get($change, 'section') === 'environment'
|| str((string) data_get($change, 'key'))->afterLast('.')->value() === 'docker_compose_raw';
if (! $isHidden) {
return $change;
}
@@ -103,7 +105,7 @@ class ConfigurationChecker extends Component
// Fail closed: only owners/admins may see unlocked env values.
$redactEnvironment = ! (bool) auth()->user()?->isAdmin();
$array['changes'] = $this->redactEnvironmentChanges($array['changes'] ?? [], $redactEnvironment);
$array['changes'] = $this->redactHiddenChanges($array['changes'] ?? [], $redactEnvironment);
$this->configurationDiff = $array;
return;
@@ -328,33 +328,40 @@
@endif
@if ($buildPack === 'dockercompose')
<div x-data="{ showRaw: true }" class="mt-5">
<div class="mb-2 flex items-center justify-between gap-4">
<h3>Docker Compose</h3>
<x-forms.button x-show="{{ $application->settings->is_raw_compose_deployment_enabled ? 'false' : 'true' }}"
@click.prevent="showRaw = !showRaw"
x-text="showRaw ? 'Show deployable compose' : 'Show raw compose'"></x-forms.button>
</div>
@if ($application->settings->is_raw_compose_deployment_enabled)
<x-forms.textarea rows="10" readonly id="dockerComposeRaw"
label="Docker compose content (applicationId: {{ $application->id }})"
helper="You need to modify the docker compose file in the git repository."
monacoEditorLanguage="yaml" useMonacoEditor />
@else
@if ((int) $application->compose_parsing_version >= 3)
<div x-show="showRaw">
<x-forms.textarea rows="10" readonly id="dockerComposeRaw"
label="Docker compose content (raw)"
@can('update', $application)
<div class="mb-2 flex items-center justify-between gap-4">
<h3>Docker Compose</h3>
<x-forms.button x-show="{{ $application->settings->is_raw_compose_deployment_enabled ? 'false' : 'true' }}"
@click.prevent="showRaw = !showRaw"
x-text="showRaw ? 'Show deployable compose' : 'Show raw compose'"></x-forms.button>
</div>
@if ($application->settings->is_raw_compose_deployment_enabled)
<x-forms.textarea rows="10" readonly id="dockerComposeRaw"
label="Docker compose content (applicationId: {{ $application->id }})"
helper="You need to modify the docker compose file in the git repository."
monacoEditorLanguage="yaml" useMonacoEditor />
@else
@if ((int) $application->compose_parsing_version >= 3)
<div x-show="showRaw">
<x-forms.textarea rows="10" readonly id="dockerComposeRaw"
label="Docker compose content (raw)"
helper="You need to modify the docker compose file in the git repository."
monacoEditorLanguage="yaml" useMonacoEditor />
</div>
@endif
<div x-show="showRaw === false">
<x-forms.textarea rows="10" readonly id="dockerCompose"
label="Docker compose content"
helper="You need to modify the docker compose file in the git repository."
monacoEditorLanguage="yaml" useMonacoEditor />
</div>
@endif
<div x-show="showRaw === false">
<x-forms.textarea rows="10" readonly id="dockerCompose"
label="Docker compose content"
helper="You need to modify the docker compose file in the git repository."
monacoEditorLanguage="yaml" useMonacoEditor />
</div>
@endif
@else
<h3 class="mb-2">Docker Compose</h3>
<x-callout type="info" title="Hidden (only admins can view)" class="mb-4">
The Docker Compose file can contain secrets.
</x-callout>
@endcan
<div class="w-full sm:w-96">
<x-forms.checkbox label="Escape special characters in labels?"
helper="By default, $ (and other chars) is escaped. So if you write $ in the labels, it will be saved as $$.<br><br>If you want to use env variables inside the labels, turn this off."
@@ -0,0 +1,88 @@
<?php
use App\Livewire\Project\Application\General as ApplicationGeneral;
use App\Livewire\Project\Service\EditCompose;
use App\Livewire\Project\Service\StackForm;
use App\Models\Application;
use App\Models\InstanceSettings;
use App\Models\Project;
use App\Models\Server;
use App\Models\Service;
use App\Models\Team;
use App\Models\User;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Livewire\Livewire;
uses(RefreshDatabase::class);
beforeEach(function () {
$this->withoutVite();
InstanceSettings::create(['id' => 0]);
$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']);
$server = Server::factory()->create(['team_id' => $this->team->id]);
$destination = $server->standaloneDockers()->firstOrFail();
$project = Project::factory()->create(['team_id' => $this->team->id]);
$environmentId = $project->environments()->firstOrFail()->id;
$this->composeRaw = "services:\n app:\n image: nginx:alpine\n environment:\n TOKEN: stored-raw-compose-value\n";
$this->composeGenerated = "services:\n app:\n image: nginx:alpine\n environment:\n TOKEN: stored-generated-compose-value\n";
$this->service = Service::factory()->create([
'docker_compose_raw' => $this->composeRaw,
'docker_compose' => $this->composeGenerated,
'server_id' => $server->id,
'destination_id' => $destination->id,
'destination_type' => $destination->getMorphClass(),
'environment_id' => $environmentId,
]);
$this->application = Application::factory()->create([
'build_pack' => 'dockercompose',
'docker_compose_raw' => $this->composeRaw,
'docker_compose' => $this->composeGenerated,
'environment_id' => $environmentId,
'destination_id' => $destination->id,
'destination_type' => $destination->getMorphClass(),
])->fresh();
});
function composeComponents(): array
{
return [
'application' => fn () => Livewire::test(ApplicationGeneral::class, ['application' => test()->application]),
'service' => fn () => Livewire::test(StackForm::class, ['service' => test()->service]),
'service editor' => fn () => Livewire::test(EditCompose::class, ['serviceId' => test()->service->id]),
];
}
it('keeps compose content out of member state', function () {
$this->actingAs($this->member);
session(['currentTeam' => $this->team]);
foreach (composeComponents() as $mountComponent) {
$component = $mountComponent()
->assertSet('dockerComposeRaw', null)
->assertSet('dockerCompose', null);
expect(json_encode($component->snapshot).$component->html())
->not->toContain('stored-raw-compose-value')
->not->toContain('stored-generated-compose-value');
}
});
it('keeps compose content available to an owner', function () {
$this->actingAs($this->owner);
session(['currentTeam' => $this->team]);
foreach (composeComponents() as $mountComponent) {
$component = $mountComponent()->assertSet('dockerComposeRaw', $this->composeRaw);
expect($component->get('dockerCompose'))->toContain('TOKEN:');
}
});
@@ -305,3 +305,42 @@ it('redacts newly added environment values for team members', function () {
->and(data_get($envChange, 'new_display_value'))->toBe('••••••••')
->and(data_get($envChange, 'type'))->toBe('added');
});
it('redacts compose content for team members in the change list', function () {
$member = User::factory()->create();
$this->team->members()->attach($member->id, ['role' => 'member']);
$this->actingAs($member);
session(['currentTeam' => $this->team]);
$application = configurationCheckerApplication($this->environment, [
'build_pack' => 'dockercompose',
'docker_compose_raw' => "services:\n app:\n environment:\n TOKEN: old-compose-value\n",
]);
markConfigurationCheckerApplicationDeployed($application);
$application->update(['docker_compose_raw' => "services:\n app:\n environment:\n TOKEN: new-compose-value\n"]);
$component = Livewire::test(ConfigurationChecker::class, ['resource' => $application->refresh()]);
$composeChange = collect(data_get($component->get('configurationDiff'), 'changes', []))
->first(fn (array $change): bool => str_ends_with((string) data_get($change, 'key'), 'docker_compose_raw'));
expect($composeChange)->not->toBeNull()
->and(json_encode($component->snapshot).$component->html())
->not->toContain('old-compose-value')
->not->toContain('new-compose-value');
});
it('keeps compose changes visible to an owner in the change list', function () {
$application = configurationCheckerApplication($this->environment, [
'build_pack' => 'dockercompose',
'docker_compose_raw' => "services:\n app:\n environment:\n TOKEN: old-compose-value\n",
]);
markConfigurationCheckerApplicationDeployed($application);
$application->update(['docker_compose_raw' => "services:\n app:\n environment:\n TOKEN: new-compose-value\n"]);
$component = Livewire::test(ConfigurationChecker::class, ['resource' => $application->refresh()]);
expect(json_encode($component->get('configurationDiff')))->toContain('new-compose-value');
});