diff --git a/app/Services/InfisicalService.php b/app/Services/InfisicalService.php index 3be8de7728..86bf64bded 100644 --- a/app/Services/InfisicalService.php +++ b/app/Services/InfisicalService.php @@ -46,6 +46,7 @@ class InfisicalService 'projectId' => $projectId, 'environment' => $environment, 'secretPath' => $secretPath, + 'includeImports' => 'true', ...$valueOptions, ]); @@ -55,6 +56,7 @@ class InfisicalService 'workspaceId' => $projectId, 'environment' => $environment, 'secretPath' => $secretPath, + 'include_imports' => 'true', ...$valueOptions, ]); } @@ -63,7 +65,20 @@ class InfisicalService throw new \RuntimeException('Infisical API error: '.($response->json('message') ?? 'HTTP '.$response->status())); } - return collect($response->json('secrets', [])) + // Like `infisical export`: later imports override earlier ones, and the folder's own secrets override all imports. + return collect($response->json('imports', [])) + ->pluck('secrets') + ->push($response->json('secrets', [])) + ->reduce(fn (array $merged, ?array $secrets) => array_replace($merged, $this->keyValues($secrets ?? [])), []); + } + + /** + * @param array> $secrets + * @return array + */ + private function keyValues(array $secrets): array + { + return collect($secrets) ->mapWithKeys(fn ($secret) => [(string) data_get($secret, 'secretKey') => (string) data_get($secret, 'secretValue', '')]) ->all(); } diff --git a/tests/Feature/SecretManagers/SecretManagerServicesTest.php b/tests/Feature/SecretManagers/SecretManagerServicesTest.php index 7618c6cb96..9ab8c2c5f8 100644 --- a/tests/Feature/SecretManagers/SecretManagerServicesTest.php +++ b/tests/Feature/SecretManagers/SecretManagerServicesTest.php @@ -125,6 +125,67 @@ describe('InfisicalService', function () { && $request['expandSecretReferences'] === 'true'); }); + test('merges imported secrets, with own secrets and later imports taking precedence', function () { + Http::fake([ + 'https://example.com/infisical/api/v1/auth/universal-auth/login' => Http::response([ + 'accessToken' => 'short-lived-token', + ]), + 'https://example.com/infisical/api/v4/secrets*' => Http::response([ + 'secrets' => [ + ['secretKey' => 'APP_KEY', 'secretValue' => 'app'], + ['secretKey' => 'OVERRIDDEN', 'secretValue' => 'own'], + ], + 'imports' => [ + ['secretPath' => '/shared', 'environment' => 'prod', 'secrets' => [ + ['secretKey' => 'SHARED_KEY', 'secretValue' => 'shared'], + ['secretKey' => 'OVERRIDDEN', 'secretValue' => 'from-shared'], + ['secretKey' => 'IMPORT_CONFLICT', 'secretValue' => 'first-import'], + ]], + ['secretPath' => '/common', 'environment' => 'prod', 'secrets' => [ + ['secretKey' => 'IMPORT_CONFLICT', 'secretValue' => 'last-import'], + ]], + ], + ]), + ]); + + $service = new InfisicalService('https://example.com/infisical', 'client-id', 'client-secret'); + + expect($service->fetchSecrets('project-1', 'prod', '/apps/api'))->toBe([ + 'SHARED_KEY' => 'shared', + 'OVERRIDDEN' => 'own', + 'IMPORT_CONFLICT' => 'last-import', + 'APP_KEY' => 'app', + ]); + + Http::assertSent(fn ($request) => str_contains($request->url(), '/api/v4/secrets') + && $request['includeImports'] === 'true'); + }); + + test('requests imported secrets from the v3 raw endpoint', function () { + Http::fake([ + 'https://example.com/infisical/api/v1/auth/universal-auth/login' => Http::response([ + 'accessToken' => 'short-lived-token', + ]), + 'https://example.com/infisical/api/v4/secrets*' => Http::response([], 404), + 'https://example.com/infisical/api/v3/secrets/raw*' => Http::response([ + 'secrets' => [['secretKey' => 'APP_KEY', 'secretValue' => 'app']], + 'imports' => [['secretPath' => '/shared', 'secrets' => [ + ['secretKey' => 'SHARED_KEY', 'secretValue' => 'shared'], + ]]], + ]), + ]); + + $service = new InfisicalService('https://example.com/infisical', 'client-id', 'client-secret'); + + expect($service->fetchSecrets('project-1', 'prod', '/apps/api'))->toBe([ + 'SHARED_KEY' => 'shared', + 'APP_KEY' => 'app', + ]); + + Http::assertSent(fn ($request) => str_contains($request->url(), '/api/v3/secrets/raw') + && $request['include_imports'] === 'true'); + }); + test('uses a 5 second connect timeout and a 10 second request timeout by default', function (?int $timeout, array $expected) { $timeouts = []; Http::fake(function ($request, array $options) use (&$timeouts) {