diff --git a/app/Http/Controllers/Api/ApplicationsController.php b/app/Http/Controllers/Api/ApplicationsController.php index 94b03c889f..f17e29cb4c 100644 --- a/app/Http/Controllers/Api/ApplicationsController.php +++ b/app/Http/Controllers/Api/ApplicationsController.php @@ -154,12 +154,7 @@ class ApplicationsController extends Controller $application->settings?->makeHidden(['id', 'application_id', 'created_at', 'updated_at']); } - $payload = serializeApiResponse($application); - if ($application instanceof Application && filled(data_get($payload, 'docker_compose'))) { - $payload['docker_compose'] = redactLockedValuesInGeneratedCompose($payload['docker_compose'], $application); - } - - return $payload; + return redactLockedApiResourceValues($application, serializeApiResponse($application)); } private function applicationSettingsFromRequest(Request $request): array diff --git a/app/Http/Controllers/Api/DatabasesController.php b/app/Http/Controllers/Api/DatabasesController.php index 5522994dd0..9990cd74af 100644 --- a/app/Http/Controllers/Api/DatabasesController.php +++ b/app/Http/Controllers/Api/DatabasesController.php @@ -258,19 +258,7 @@ class DatabasesController extends Controller $this->hideNestedServerSecrets($database, $loadNestedServerSecrets); } - $payload = serializeApiResponse($database); - - // A locked password variable is never shown again, also not inside the connection URLs. - if (request()->attributes->get('can_read_sensitive', false) === true - && method_exists($database, 'isCredentialLocked') && $database->isCredentialLocked()) { - foreach (['internal_db_url', 'external_db_url'] as $key) { - if ($payload->has($key)) { - $payload->put($key, null); - } - } - } - - return $payload; + return redactLockedApiResourceValues($database, serializeApiResponse($database)); } private function hideNestedServerSecrets(Model $model, bool $loadRelations = false): void diff --git a/app/Http/Controllers/Api/ProjectController.php b/app/Http/Controllers/Api/ProjectController.php index ad515f3fc7..a79dc542cd 100644 --- a/app/Http/Controllers/Api/ProjectController.php +++ b/app/Http/Controllers/Api/ProjectController.php @@ -7,6 +7,7 @@ use App\Models\Project; use App\Support\ValidationPatterns; use Illuminate\Http\JsonResponse; use Illuminate\Http\Request; +use Illuminate\Support\Arr; use Illuminate\Support\Facades\Validator; use OpenApi\Attributes as OA; @@ -168,13 +169,16 @@ class ProjectController extends Controller return response()->json(['message' => 'Environment not found.'], 404); } // Redis connection URLs read REDIS_PASSWORD and REDIS_USERNAME; load the variables once, not per database. - $environment = $environment->load(['applications', 'postgresqls', 'redis.runtime_environment_variables', 'mongodbs', 'mysqls', 'mariadbs', 'services']); - collect(['applications', 'postgresqls', 'redis', 'mongodbs', 'mysqls', 'mariadbs', 'services']) - ->flatMap(fn (string $relation) => $environment->{$relation}) - ->each(fn ($resource) => exposeSensitiveFields($resource)); - $environment->redis->each(fn ($redis) => $redis->makeHidden('runtime_environment_variables')); + $relations = ['applications', 'postgresqls', 'redis', 'mongodbs', 'mysqls', 'mariadbs', 'services']; + $environment = $environment->load([...$relations, 'redis.runtime_environment_variables']); + $payload = $environment->withoutRelations()->toArray(); + foreach ($relations as $relation) { + $payload[$relation] = $environment->{$relation} + ->map(fn ($resource) => Arr::except(serializeApiResource($resource), 'runtime_environment_variables')) + ->all(); + } - return response()->json(serializeApiResponse($environment)); + return response()->json(serializeApiResponse($payload)); } #[OA\Post( diff --git a/app/Http/Controllers/Api/ResourcesController.php b/app/Http/Controllers/Api/ResourcesController.php index 5b7923e19f..16e4e89c09 100644 --- a/app/Http/Controllers/Api/ResourcesController.php +++ b/app/Http/Controllers/Api/ResourcesController.php @@ -69,8 +69,7 @@ class ResourcesController extends Controller } $resources = $resources->flatten(); $resources = $resources->map(function ($resource) { - exposeSensitiveFields($resource); - $payload = $resource->toArray(); + $payload = serializeApiResource($resource); $payload['status'] = $resource->status; $payload['type'] = $resource->type(); diff --git a/app/Http/Controllers/Api/ServicesController.php b/app/Http/Controllers/Api/ServicesController.php index 30d13ac286..1408ce2897 100644 --- a/app/Http/Controllers/Api/ServicesController.php +++ b/app/Http/Controllers/Api/ServicesController.php @@ -78,12 +78,7 @@ class ServicesController extends Controller $service->makeHidden(['value', 'real_value']); } - $payload = serializeApiResponse($service); - if ($service instanceof Service && filled(data_get($payload, 'docker_compose'))) { - $payload['docker_compose'] = redactLockedValuesInGeneratedCompose($payload['docker_compose'], $service); - } - - return $payload; + return redactLockedApiResourceValues($service, serializeApiResponse($service)); } /** diff --git a/bootstrap/helpers/api.php b/bootstrap/helpers/api.php index c4c5eb7a27..226df2a33a 100644 --- a/bootstrap/helpers/api.php +++ b/bootstrap/helpers/api.php @@ -10,6 +10,7 @@ use App\Models\ApplicationSetting; use App\Models\Environment; use App\Models\EnvironmentVariable; use App\Models\ServerSetting; +use App\Models\Service; use App\Models\StandaloneDocker; use App\Models\SwarmDocker; use App\Rules\ManualWebhookSecret; @@ -178,6 +179,41 @@ function exposeSensitiveFields(Model $model): Model return $model; } +/** + * Hide the locked (shown once) values that a serialized resource still contains, also for tokens + * that can read sensitive data: the generated compose of an application or service, and the + * connection URLs of a database with a locked password. + * + * @template T of array|SupportCollection + * + * @param T $payload + * @return T + */ +function redactLockedApiResourceValues(Model $resource, array|SupportCollection $payload): array|SupportCollection +{ + if (($resource instanceof Application || $resource instanceof Service) && filled($payload['docker_compose'] ?? null)) { + $payload['docker_compose'] = redactLockedValuesInGeneratedCompose($payload['docker_compose'], $resource); + } + + $urlKeys = array_filter(['internal_db_url', 'external_db_url'], fn (string $key): bool => filled($payload[$key] ?? null)); + if ($urlKeys !== [] && method_exists($resource, 'isCredentialLocked') && $resource->isCredentialLocked()) { + foreach ($urlKeys as $key) { + $payload[$key] = null; + } + } + + return $payload; +} + +/** + * Serialize a resource for a list API response: sensitive fields when the token can read them, + * without locked values. + */ +function serializeApiResource(Model $resource): array +{ + return redactLockedApiResourceValues($resource, exposeSensitiveFields($resource)->toArray()); +} + /** * Shared validation rules for application create and update API endpoints. * diff --git a/tests/Feature/Authorization/DatabaseConfigAndLockedPasswordVisibilityTest.php b/tests/Feature/Authorization/DatabaseConfigAndLockedPasswordVisibilityTest.php index f53b5d9cd4..4bcb117274 100644 --- a/tests/Feature/Authorization/DatabaseConfigAndLockedPasswordVisibilityTest.php +++ b/tests/Feature/Authorization/DatabaseConfigAndLockedPasswordVisibilityTest.php @@ -228,6 +228,18 @@ it('redacts a locked redis password and its connection urls in the api', functio $list = $this->withHeaders($headers)->getJson('/api/v1/databases')->assertOk(); expect($list->getContent())->not->toContain('locked-redis-secret'); + + $resources = $this->withHeaders($headers)->getJson('/api/v1/resources')->assertOk(); + expect($resources->getContent())->not->toContain('locked-redis-secret') + ->and($resources->json('0.uuid'))->toBe($redis->uuid) + ->and($resources->json('0.internal_db_url'))->toBeNull(); + + $environment = $this->withHeaders($headers) + ->getJson("/api/v1/projects/{$this->environment->project->uuid}/{$this->environment->uuid}") + ->assertOk(); + expect($environment->getContent())->not->toContain('locked-redis-secret') + ->and($environment->json('redis.0.uuid'))->toBe($redis->uuid) + ->and($environment->json('redis.0.internal_db_url'))->toBeNull(); }); it('still returns the connection url of an unlocked redis password in the api', function () { diff --git a/tests/Feature/Authorization/RenderedComposeLockedValueRedactionTest.php b/tests/Feature/Authorization/RenderedComposeLockedValueRedactionTest.php index a4fb6566cc..9406a80c16 100644 --- a/tests/Feature/Authorization/RenderedComposeLockedValueRedactionTest.php +++ b/tests/Feature/Authorization/RenderedComposeLockedValueRedactionTest.php @@ -164,3 +164,24 @@ it('hides locked values in the generated compose returned by the API', function expectLockedValuesRedacted($response->json('docker_compose')); expect($response->json('docker_compose_raw'))->toBe(LOCKED_COMPOSE_RAW); })->with(['service', 'application']); + +it('hides locked values in the generated compose returned by the resource list API', function () { + $response = $this->withToken(lockedComposeApiToken())->getJson('/api/v1/resources')->assertOk(); + + foreach ([$this->service, $this->application] as $resource) { + expectLockedValuesRedacted(collect($response->json())->firstWhere('uuid', $resource->uuid)['docker_compose']); + } +}); + +it('hides locked values in the generated compose returned by the environment API', function (string $relation, string $resource) { + $response = $this->withToken(lockedComposeApiToken()) + ->getJson("/api/v1/projects/{$this->project->uuid}/{$this->environment->uuid}") + ->assertOk(); + + expectLockedValuesRedacted($response->json("{$relation}.0.docker_compose")); + expect($response->json("{$relation}.0.uuid"))->toBe($this->{$resource}->uuid) + ->and($response->json("{$relation}.0.docker_compose_raw"))->toBe(LOCKED_COMPOSE_RAW); +})->with([ + 'service' => ['services', 'service'], + 'application' => ['applications', 'application'], +]);