diff --git a/app/Livewire/Project/Application/General.php b/app/Livewire/Project/Application/General.php index 950bfc79d3..8a336b659b 100644 --- a/app/Livewire/Project/Application/General.php +++ b/app/Livewire/Project/Application/General.php @@ -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(); diff --git a/app/Livewire/Project/Service/EditCompose.php b/app/Livewire/Project/Service/EditCompose.php index 7a95354357..b52d5a63dc 100644 --- a/app/Livewire/Project/Service/EditCompose.php +++ b/app/Livewire/Project/Service/EditCompose.php @@ -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; } } diff --git a/app/Livewire/Project/Service/StackForm.php b/app/Livewire/Project/Service/StackForm.php index c27b739e8b..94a0d06c9c 100644 --- a/app/Livewire/Project/Service/StackForm.php +++ b/app/Livewire/Project/Service/StackForm.php @@ -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; } } diff --git a/app/Livewire/Project/Shared/ConfigurationChecker.php b/app/Livewire/Project/Shared/ConfigurationChecker.php index fd6e7f618e..1a6015042c 100644 --- a/app/Livewire/Project/Shared/ConfigurationChecker.php +++ b/app/Livewire/Project/Shared/ConfigurationChecker.php @@ -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> $changes * @return array> */ - 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; diff --git a/resources/views/livewire/project/application/general.blade.php b/resources/views/livewire/project/application/general.blade.php index 8057209805..1df2bedfb2 100644 --- a/resources/views/livewire/project/application/general.blade.php +++ b/resources/views/livewire/project/application/general.blade.php @@ -328,33 +328,40 @@ @endif @if ($buildPack === 'dockercompose')
-
-

Docker Compose

- -
- @if ($application->settings->is_raw_compose_deployment_enabled) - - @else - @if ((int) $application->compose_parsing_version >= 3) -
- +

Docker Compose

+ +
+ @if ($application->settings->is_raw_compose_deployment_enabled) + + @else + @if ((int) $application->compose_parsing_version >= 3) +
+ +
+ @endif +
+
@endif -
- -
- @endif + @else +

Docker Compose

+ + The Docker Compose file can contain secrets. + + @endcan
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:'); + } +}); diff --git a/tests/Feature/Livewire/ConfigurationCheckerTest.php b/tests/Feature/Livewire/ConfigurationCheckerTest.php index 1a2875e56c..4adf86fff0 100644 --- a/tests/Feature/Livewire/ConfigurationCheckerTest.php +++ b/tests/Feature/Livewire/ConfigurationCheckerTest.php @@ -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'); +});