From ba179d50ac30e9f092b3282151ad26dcec2a6622 Mon Sep 17 00:00:00 2001 From: Andras Bacsai <5845193+andrasbacsai@users.noreply.github.com> Date: Sun, 23 Aug 2026 20:42:20 +0200 Subject: [PATCH] feat(secrets): add integration token and application manager APIs Add API endpoints for creating validated secret manager tokens and configuring application secret manager settings. Suppress secret-bearing deployment command logs and redact resolved remote secrets. --- .../ApplicationSecretManagerController.php | 123 ++++++++++++++++++ .../Api/IntegrationTokensController.php | 108 +++++++++++++++ app/Jobs/ApplicationDeploymentJob.php | 8 +- app/Traits/ExecuteRemoteCommand.php | 4 + routes/api.php | 4 + ...ationDeploymentControlVarFilteringTest.php | 28 +++- tests/Feature/SecretManagerApiTest.php | 87 +++++++++++++ 7 files changed, 355 insertions(+), 7 deletions(-) create mode 100644 app/Http/Controllers/Api/ApplicationSecretManagerController.php create mode 100644 app/Http/Controllers/Api/IntegrationTokensController.php create mode 100644 tests/Feature/SecretManagerApiTest.php diff --git a/app/Http/Controllers/Api/ApplicationSecretManagerController.php b/app/Http/Controllers/Api/ApplicationSecretManagerController.php new file mode 100644 index 0000000000..c8c311766d --- /dev/null +++ b/app/Http/Controllers/Api/ApplicationSecretManagerController.php @@ -0,0 +1,123 @@ + []]], + tags: ['Secret Managers'], + parameters: [new OA\Parameter(name: 'uuid', in: 'path', required: true, schema: new OA\Schema(type: 'string'))], + requestBody: new OA\RequestBody( + required: true, + content: new OA\JsonContent( + required: ['integration_token_uuid'], + properties: [ + new OA\Property(property: 'integration_token_uuid', type: 'string'), + new OA\Property(property: 'settings', type: 'object'), + ], + ), + ), + responses: [ + new OA\Response(response: 200, description: 'Secret manager configured.'), + new OA\Response(response: 401, ref: '#/components/responses/401'), + new OA\Response(response: 404, ref: '#/components/responses/404'), + new OA\Response(response: 422, ref: '#/components/responses/422'), + ], + )] + public function update(Request $request): JsonResponse + { + $teamId = getTeamIdFromToken(); + if (is_null($teamId)) { + return invalidTokenResponse(); + } + + $return = validateIncomingRequest($request); + if ($return instanceof JsonResponse) { + return $return; + } + + $application = Application::ownedByCurrentTeamAPI($teamId) + ->where('uuid', $request->route('uuid')) + ->first(); + + if (! $application) { + return response()->json(['message' => 'Application not found.'], 404); + } + + $this->authorize('update', $application); + + $body = $request->json()->all(); + $token = IntegrationToken::query() + ->where('team_id', $teamId) + ->where('uuid', $body['integration_token_uuid'] ?? '') + ->whereIn('provider', IntegrationToken::SECRET_MANAGER_PROVIDERS) + ->first(); + + if (! $token || ! in_array('secrets', $token->capabilities ?? [], true)) { + return response()->json(['message' => 'Secret manager integration token not found.'], 404); + } + + $rules = [ + 'integration_token_uuid' => ['required', 'string'], + 'settings' => ['sometimes', 'array'], + ]; + $rules += match ($token->provider) { + 'doppler' => $token->dopplerTokenType() === 'service_account' ? [ + 'settings.project' => ['required', 'string'], + 'settings.config' => ['required', 'string'], + ] : [], + 'infisical' => [ + 'settings.project_id' => ['required', 'string'], + 'settings.environment' => ['required', 'string'], + 'settings.secret_path' => ['nullable', 'string'], + ], + 'vault' => [ + 'settings.mount' => ['required', 'string'], + 'settings.path' => ['required', 'string'], + ], + default => [], + }; + + $validator = customApiValidator($body, $rules); + $extraFields = array_diff(array_keys($body), ['integration_token_uuid', 'settings']); + + if ($validator->fails() || $extraFields !== []) { + $errors = $validator->errors(); + foreach ($extraFields as $field) { + $errors->add($field, 'This field is not allowed.'); + } + + return response()->json(['message' => 'Validation failed.', 'errors' => $errors], 422); + } + + $settings = array_filter($validator->validated()['settings'] ?? [], fn ($value) => filled($value)); + $application->secretManagerLink()->updateOrCreate([], [ + 'integration_token_id' => $token->id, + 'settings' => $settings ?: null, + ]); + + auditLog('api.application.secret_manager.updated', [ + 'team_id' => $teamId, + 'application_uuid' => $application->uuid, + 'integration_token_uuid' => $token->uuid, + ]); + + return response()->json([ + 'integration_token_uuid' => $token->uuid, + 'provider' => $token->provider, + 'settings' => $settings ?: null, + ]); + } +} diff --git a/app/Http/Controllers/Api/IntegrationTokensController.php b/app/Http/Controllers/Api/IntegrationTokensController.php new file mode 100644 index 0000000000..b5e6cee945 --- /dev/null +++ b/app/Http/Controllers/Api/IntegrationTokensController.php @@ -0,0 +1,108 @@ + []]], + tags: ['Secret Managers'], + requestBody: new OA\RequestBody( + required: true, + content: new OA\JsonContent( + required: ['provider', 'name', 'token'], + properties: [ + new OA\Property(property: 'provider', type: 'string', enum: ['doppler', 'infisical', 'vault']), + new OA\Property(property: 'name', type: 'string'), + new OA\Property(property: 'token', type: 'string'), + new OA\Property(property: 'metadata', type: 'object'), + ], + ), + ), + responses: [ + new OA\Response(response: 201, description: 'Integration token created.'), + new OA\Response(response: 400, ref: '#/components/responses/400'), + new OA\Response(response: 401, ref: '#/components/responses/401'), + new OA\Response(response: 422, ref: '#/components/responses/422'), + ], + )] + public function store(Request $request, IntegrationTokenValidator $tokenValidator): JsonResponse + { + $teamId = getTeamIdFromToken(); + if (is_null($teamId)) { + return invalidTokenResponse(); + } + + $this->authorize('create', IntegrationToken::class); + + $return = validateIncomingRequest($request); + if ($return instanceof JsonResponse) { + return $return; + } + + $body = $request->json()->all(); + $rules = [ + 'provider' => ['required', 'string', 'in:'.implode(',', IntegrationToken::SECRET_MANAGER_PROVIDERS)], + 'name' => ['required', 'string', 'max:255'], + 'token' => ['required', 'string'], + 'metadata' => ['sometimes', 'array'], + ]; + + if (($body['provider'] ?? null) === 'doppler') { + $rules['token'][] = 'regex:/^dp\.(st|sa)\./'; + } elseif (($body['provider'] ?? null) === 'infisical') { + $rules['metadata.base_url'] = ['required', 'url']; + $rules['metadata.client_id'] = ['required', 'string']; + } elseif (($body['provider'] ?? null) === 'vault') { + $rules['metadata.base_url'] = ['required', 'url']; + $rules['metadata.namespace'] = ['nullable', 'string']; + } + + $validator = customApiValidator($body, $rules); + $extraFields = array_diff(array_keys($body), ['provider', 'name', 'token', 'metadata']); + + if ($validator->fails() || $extraFields !== []) { + $errors = $validator->errors(); + foreach ($extraFields as $field) { + $errors->add($field, 'This field is not allowed.'); + } + + return response()->json(['message' => 'Validation failed.', 'errors' => $errors], 422); + } + + $validated = $validator->validated(); + $metadata = array_filter($validated['metadata'] ?? [], fn ($value) => filled($value)); + + if (! $tokenValidator->validate($validated['provider'], $validated['token'], ['secrets'], $metadata)) { + return response()->json(['message' => $tokenValidator->errorMessage($validated['provider'])], 400); + } + + $integrationToken = IntegrationToken::query()->create([ + 'team_id' => $teamId, + 'provider' => $validated['provider'], + 'name' => $validated['name'], + 'token' => $validated['token'], + 'capabilities' => ['secrets'], + 'metadata' => $metadata ?: null, + ]); + + auditLog('api.integration_token.created', [ + 'team_id' => $teamId, + 'integration_token_uuid' => $integrationToken->uuid, + 'provider' => $integrationToken->provider, + ]); + + return response()->json(['uuid' => $integrationToken->uuid], 201); + } +} diff --git a/app/Jobs/ApplicationDeploymentJob.php b/app/Jobs/ApplicationDeploymentJob.php index 92af1c4921..c0cf6a11a9 100644 --- a/app/Jobs/ApplicationDeploymentJob.php +++ b/app/Jobs/ApplicationDeploymentJob.php @@ -1701,6 +1701,7 @@ class ApplicationDeploymentJob implements ShouldBeEncrypted, ShouldQueue $this->execute_remote_command( [ executeInDocker($this->deployment_uuid, "echo '$envs_base64' | base64 -d | tee $this->workdir/.env > /dev/null"), + 'skip_command_log' => true, ] ); @@ -1986,6 +1987,7 @@ class ApplicationDeploymentJob implements ShouldBeEncrypted, ShouldQueue $this->execute_remote_command( [ executeInDocker($this->deployment_uuid, "echo '$envs_base64' | base64 -d | tee ".self::BUILD_TIME_ENV_PATH.' > /dev/null'), + 'skip_command_log' => true, ] ); @@ -4550,11 +4552,7 @@ COPY ./nginx.conf /etc/nginx/conf.d/default.conf"); [ executeInDocker($this->deployment_uuid, "echo '{$dockerfile_base64}' | base64 -d | tee {$this->workdir}{$this->dockerfile_location} > /dev/null"), 'hidden' => true, - ], - [ - executeInDocker($this->deployment_uuid, "cat {$this->workdir}{$this->dockerfile_location}"), - 'hidden' => true, - 'ignore_errors' => true, + 'skip_command_log' => true, ]); } diff --git a/app/Traits/ExecuteRemoteCommand.php b/app/Traits/ExecuteRemoteCommand.php index a2c3d06da9..3dbfc80bc2 100644 --- a/app/Traits/ExecuteRemoteCommand.php +++ b/app/Traits/ExecuteRemoteCommand.php @@ -46,6 +46,10 @@ trait ExecuteRemoteCommand ); } + if (isset($this->remote_secrets_cache)) { + $lockedVars = $lockedVars->merge(array_values($this->remote_secrets_cache)); + } + foreach ($lockedVars as $key => $value) { $escapedValue = preg_quote($value, '/'); $text = preg_replace( diff --git a/routes/api.php b/routes/api.php index 2d19ae96dc..c0ffbd3bb4 100644 --- a/routes/api.php +++ b/routes/api.php @@ -1,6 +1,7 @@ middleware(['api.ability:read']); Route::patch('/security/keys/{uuid}', [SecurityController::class, 'update_key'])->middleware(['api.ability:write']); Route::delete('/security/keys/{uuid}', [SecurityController::class, 'delete_key'])->middleware(['api.ability:write']); + Route::post('/security/integration-tokens', [IntegrationTokensController::class, 'store'])->middleware(['api.ability:write']); Route::get('/cloud-tokens', [CloudProviderTokensController::class, 'index'])->middleware(['api.ability:read']); Route::post('/cloud-tokens', [CloudProviderTokensController::class, 'store'])->middleware(['api.ability:write']); @@ -237,6 +240,7 @@ Route::group([ Route::get('/applications/{uuid}', [ApplicationsController::class, 'application_by_uuid'])->middleware(['api.ability:read']); Route::patch('/applications/{uuid}', [ApplicationsController::class, 'update_by_uuid'])->middleware(['api.ability:write']); Route::delete('/applications/{uuid}', [ApplicationsController::class, 'delete_by_uuid'])->middleware(['api.ability:write']); + Route::patch('/applications/{uuid}/secret-manager', [ApplicationSecretManagerController::class, 'update'])->middleware(['api.ability:write']); Route::get('/applications/{uuid}/envs', [ApplicationsController::class, 'envs'])->middleware(['api.ability:read']); Route::post('/applications/{uuid}/envs', [ApplicationsController::class, 'create_env'])->middleware(['api.ability:write']); diff --git a/tests/Feature/ApplicationDeploymentControlVarFilteringTest.php b/tests/Feature/ApplicationDeploymentControlVarFilteringTest.php index 8f2b3573a8..5b4b09d602 100644 --- a/tests/Feature/ApplicationDeploymentControlVarFilteringTest.php +++ b/tests/Feature/ApplicationDeploymentControlVarFilteringTest.php @@ -14,6 +14,30 @@ use Illuminate\Support\Collection; uses(RefreshDatabase::class); +it('does not persist environment write commands or generated Dockerfiles in deployment logs', function () { + $source = file_get_contents(app_path('Jobs/ApplicationDeploymentJob.php')); + $finalDockerfileWrite = str($source) + ->after("addLogEntry('Final Dockerfile:'") + ->before('private function modify_dockerfile_for_secrets') + ->toString(); + + expect($source) + ->and(substr_count($source, "'skip_command_log' => true"))->toBeGreaterThanOrEqual(4); + + expect($finalDockerfileWrite) + ->not->toContain('executeInDocker($this->deployment_uuid, "cat {$this->workdir}{$this->dockerfile_location}")'); +}); + +it('redacts resolved remote secrets from command output', function () { + [$application, $server] = makeDeploymentControlVarFixture(); + [$job, $reflection] = makeControlVarFilteringJob($application, $server, [ + 'remote_secrets_cache' => ['API_TOKEN' => 'remote-secret-value'], + ]); + + expect(invokeDeploymentJobMethod($job, $reflection, 'redact_sensitive_info', 'token=remote-secret-value')) + ->toBe('token='.REDACTED); +}); + class TestableControlVarFilteringDeploymentJob extends ApplicationDeploymentJob { public array $recordedCommands = []; @@ -130,12 +154,12 @@ function makeControlVarFilteringJob(Application $application, Server $server, ar return [$job, $reflection]; } -function invokeDeploymentJobMethod(object $job, ReflectionClass $reflection, string $method): mixed +function invokeDeploymentJobMethod(object $job, ReflectionClass $reflection, string $method, mixed ...$arguments): mixed { $reflectionMethod = $reflection->getMethod($method); $reflectionMethod->setAccessible(true); - return $reflectionMethod->invoke($job); + return $reflectionMethod->invoke($job, ...$arguments); } function readDeploymentJobProperty(object $job, ReflectionClass $reflection, string $property): mixed diff --git a/tests/Feature/SecretManagerApiTest.php b/tests/Feature/SecretManagerApiTest.php new file mode 100644 index 0000000000..ed42526402 --- /dev/null +++ b/tests/Feature/SecretManagerApiTest.php @@ -0,0 +1,87 @@ + 'file']); + InstanceSettings::unguarded(fn () => InstanceSettings::firstOrCreate(['id' => 0, 'is_api_enabled' => true])); + + $this->team = Team::factory()->create(); + $this->user = User::factory()->create(); + $this->team->members()->attach($this->user, ['role' => 'owner']); + session(['currentTeam' => $this->team]); + $this->bearerToken = $this->user->createToken('secret-manager-api-test', ['*'])->plainTextToken; + + $server = Server::factory()->create(['team_id' => $this->team->id]); + $destination = StandaloneDocker::query()->where('server_id', $server->id)->firstOrFail(); + $project = Project::factory()->create(['team_id' => $this->team->id]); + $environment = Environment::factory()->create(['project_id' => $project->id]); + $this->application = Application::factory()->create([ + 'environment_id' => $environment->id, + 'destination_id' => $destination->id, + 'destination_type' => $destination->getMorphClass(), + ]); +}); + +function secretManagerApiHeaders(string $token): array +{ + return ['Authorization' => 'Bearer '.$token]; +} + +test('a secret manager integration token can be created through the api', function () { + Http::fake(['https://api.doppler.com/v3/me' => Http::response([], 200)]); + + $response = $this->withHeaders(secretManagerApiHeaders($this->bearerToken)) + ->postJson('/api/v1/security/integration-tokens', [ + 'provider' => 'doppler', + 'name' => 'Production secrets', + 'token' => 'dp.st.secret', + ]) + ->assertCreated() + ->assertJsonStructure(['uuid']); + + $token = IntegrationToken::query()->whereUuid($response->json('uuid'))->firstOrFail(); + + expect($token->team_id)->toBe($this->team->id) + ->and($token->capabilities)->toBe(['secrets']); +}); + +test('an application can be configured to use a secret manager through the api', function () { + $token = IntegrationToken::query()->create([ + 'team_id' => $this->team->id, + 'provider' => 'doppler', + 'name' => 'Production secrets', + 'token' => 'dp.sa.secret', + 'capabilities' => ['secrets'], + ]); + + $this->withHeaders(secretManagerApiHeaders($this->bearerToken)) + ->patchJson("/api/v1/applications/{$this->application->uuid}/secret-manager", [ + 'integration_token_uuid' => $token->uuid, + 'settings' => [ + 'project' => 'website', + 'config' => 'production', + ], + ]) + ->assertOk() + ->assertJsonPath('integration_token_uuid', $token->uuid) + ->assertJsonPath('provider', 'doppler') + ->assertJsonPath('settings.project', 'website'); + + $link = $this->application->secretManagerLink()->firstOrFail(); + + expect($link->integration_token_id)->toBe($token->id) + ->and($link->settings)->toBe(['project' => 'website', 'config' => 'production']); +});