diff --git a/README.md b/README.md index 863c68c..d15a1f1 100644 --- a/README.md +++ b/README.md @@ -1,6 +1,6 @@ -Automatically load secrets from SSM into environment variables when running with Bref. +Automatically load secrets from SSM or Secrets Manager into environment variables when running with Bref. -It replaces (at runtime) the variables whose value starts with `bref-ssm:`. For example, you could set such a variable in `serverless.yml` like this: +It replaces (at runtime) the variables whose value starts with `bref-ssm:` or `bref-secret:`. For example, you could set such a variable in `serverless.yml` like this: ```yaml provider: @@ -23,3 +23,98 @@ composer require bref/secrets-loader Read the Bref documentation: https://bref.sh/docs/environment/variables.html#secrets +### SSM Parameter Store + +```yaml +provider: + environment: + MY_PARAMETER: bref-ssm:/my-app/my-parameter +``` + +`MY_PARAMETER` is replaced at runtime with the value stored at `/my-app/my-parameter` in SSM. + +### Secrets Manager + +You can also use AWS Secrets Manager. Store all your env vars as a single JSON secret and load them in 1 API call: + +```bash +aws secretsmanager create-secret \ + --name api/production \ + --secret-string '{"DB_PASSWORD":"secret123","API_KEY":"abc-xyz"}' +``` + +**Global import** — all JSON keys become env vars: + +```yaml +provider: + environment: + BREF_SECRETS_MANAGER: api/${sls:stage} + iam: + role: + statements: + - Effect: Allow + Action: secretsmanager:GetSecretValue + Resource: arn:aws:secretsmanager:${aws:region}:${aws:accountId}:secret:api/${sls:stage}-* +``` + +> **Note:** The `-*` wildcard at the end of the ARN is required. Secrets Manager appends a random 6-character suffix to every secret ARN (e.g., `api/production-AbCdEf`). Without the wildcard, IAM will deny the request. + +**Individual import** — load specific secrets per env var: + +```yaml +provider: + environment: + # Plain string secret + VENDOR_KEY: bref-secret:vendor/api-key + # Extract a specific key from a JSON secret + SPECIFIC_VAR: bref-secret:api/${sls:stage}:SPECIFIC_KEY +``` + +Multiple references to the same secret name are deduplicated into a single API call. + +### Combining both backends + +You can use both SSM and Secrets Manager together. If the same env var name is set by multiple sources, the more explicit source wins: + +1. `bref-ssm:` overrides everything +2. `bref-secret:` overrides the global import +3. `BREF_SECRETS_MANAGER` global import sets the baseline + +```yaml +provider: + environment: + BREF_SECRETS_MANAGER: api/${sls:stage} + VENDOR_KEY: bref-secret:vendor/api-key + MY_PARAMETER: bref-ssm:/my-app/my-parameter +``` + +### Important: `bref/bref` compatibility + +The current version of `bref/bref` only triggers the secrets loader when it detects at least one `bref-ssm:` env var. If you're using Secrets Manager without any SSM parameters, the loader won't run and your env vars won't be set. + +Until `bref/bref` is updated, keep at least one `bref-ssm:` variable in your configuration alongside `BREF_SECRETS_MANAGER` or `bref-secret:` vars. + +### When to use Secrets Manager over SSM + +SSM Parameter Store works well at low concurrency. Standard parameters are free below 40 TPS (shared across all `GetParameter*` APIs). But SSM's `GetParameters` batches at most 10 parameters per call. If you have 50 parameters, that's 5 API calls (5 TPS) per cold start — so only 8 simultaneous cold starts will hit the 40 TPS default limit. Beyond that, requests get throttled unless you enable higher throughput. + +With higher throughput enabled, SSM charges $0.05 per 10,000 API interactions. Note that AWS counts each individual parameter returned as a separate interaction, not each API call. So those 5 batch calls returning 50 parameters count as 50 billable interactions per cold start. + +Secrets Manager takes a different approach: you store all your variables in one JSON secret and fetch them in 1 API call per cold start. It costs $0.40/secret/month plus $0.05 per 10,000 API calls, and handles up to 10,000 TPS. + +Here's how costs compare with 50 parameters at different cold start volumes (assuming 30 days): + +``` + SSM (free tier, SSM (higher Secrets Manager +Cold starts/day ≤40 TPS) throughput) (1 JSON secret) +───────────────────────────────────────────────────────────────────────────── +100 $0.00* $0.75/mo $0.42/mo +500 $0.00* $3.75/mo $0.48/mo +1,000 $0.00* $7.50/mo $0.55/mo +``` + +\* Free but limited to 40 TPS shared — cold start spikes will cause throttling errors. + +In short: SSM is effectively free at low scale but breaks under concurrency pressure. Secrets Manager has a small fixed cost but stays cheap and reliable as you scale, because 1 API call beats 50 interactions every time. + +Pricing as of March 2025 — see [SSM pricing](https://aws.amazon.com/systems-manager/pricing/) and [Secrets Manager pricing](https://aws.amazon.com/secrets-manager/pricing/) for current rates. diff --git a/composer.json b/composer.json index 814b011..2889444 100644 --- a/composer.json +++ b/composer.json @@ -16,7 +16,8 @@ }, "require": { "php": ">=8.0", - "async-aws/ssm": "^1.3 || ^2.0" + "async-aws/ssm": "^1.3 || ^2.0", + "async-aws/secrets-manager": "^1.0 || ^2.0" }, "require-dev": { "phpunit/phpunit": "^9.6.10", diff --git a/src/Secrets.php b/src/Secrets.php index 91cd76c..303ce0d 100644 --- a/src/Secrets.php +++ b/src/Secrets.php @@ -2,6 +2,9 @@ namespace Bref\Secrets; +use AsyncAws\Core\Exception\Http\ClientException; +use AsyncAws\SecretsManager\Exception\ResourceNotFoundException; +use AsyncAws\SecretsManager\SecretsManagerClient; use AsyncAws\Ssm\SsmClient; use Closure; use JsonException; @@ -9,13 +12,22 @@ class Secrets { + /** @var resource|null Override to capture log output in tests. */ + public static $outputStream = null; + /** - * Decrypt environment variables that are encrypted with AWS SSM. + * Load secret environment variables from AWS Secrets Manager and/or SSM Parameter Store. + * + * Processing order (later phases override earlier ones for same key): + * 1. BREF_SECRETS_MANAGER global import (all JSON keys become env vars) + * 2. Individual bref-secret: prefix env vars + * 3. Individual bref-ssm: prefix env vars (existing behavior) * * @param SsmClient|null $ssmClient To allow mocking in tests. + * @param SecretsManagerClient|null $smClient To allow mocking in tests. * @throws JsonException */ - public static function loadSecretEnvironmentVariables(?SsmClient $ssmClient = null): void + public static function loadSecretEnvironmentVariables(?SsmClient $ssmClient = null, ?SecretsManagerClient $smClient = null): void { /** @var array|string|false $envVars */ $envVars = getenv(local_only: true); @@ -23,6 +35,168 @@ public static function loadSecretEnvironmentVariables(?SsmClient $ssmClient = nu return; } + // Secrets Manager (global import + individual bref-secret: vars) + self::loadSecretsManager($smClient, $envVars); + + // SSM bref-ssm: prefix env vars + self::loadSsmParameters($ssmClient, $envVars); + } + + /** + * Load env vars from AWS Secrets Manager. + * + * Collects all needed secret names from both BREF_SECRETS_MANAGER and bref-secret: env vars, + * fetches them in a single cached batch, then sets env vars. + * + * @param array $envVars + */ + private static function loadSecretsManager(?SecretsManagerClient $smClient, array $envVars): void + { + $globalSecretName = $envVars['BREF_SECRETS_MANAGER'] ?? null; + if ($globalSecretName === '') { + $globalSecretName = null; + } + + // Find individual bref-secret: env vars and group by secret name + $envVarsToResolve = array_filter($envVars, function (string $value): bool { + return str_starts_with($value, 'bref-secret:'); + }); + + /** @var array> $secretNameToEnvVars */ + $secretNameToEnvVars = []; + foreach ($envVarsToResolve as $envVar => $prefixedValue) { + $withoutPrefix = substr($prefixedValue, strlen('bref-secret:')); + // Parse: secret-name or secret-name:json-key + // Secret names can contain / but not colons, so first colon is the separator + $colonPos = strpos($withoutPrefix, ':'); + if ($colonPos !== false) { + $secretName = substr($withoutPrefix, 0, $colonPos); + $jsonKey = substr($withoutPrefix, $colonPos + 1); + } else { + $secretName = $withoutPrefix; + $jsonKey = null; + } + $secretNameToEnvVars[$secretName][] = [ + 'envVar' => $envVar, + 'jsonKey' => $jsonKey, + ]; + } + + // Collect all unique secret names needed + $allSecretNames = array_keys($secretNameToEnvVars); + if ($globalSecretName !== null && ! in_array($globalSecretName, $allSecretNames, true)) { + $allSecretNames[] = $globalSecretName; + } + + if (empty($allSecretNames)) { + return; + } + + // Fetch all secrets in a single cached batch (1 API call per unique secret name) + $actuallyCalledApi = false; + $fetchedSecrets = self::readFromCacheOr( + sys_get_temp_dir() . '/bref-secrets-manager.php', + function () use ($smClient, $allSecretNames, &$actuallyCalledApi) { + $actuallyCalledApi = true; + $secrets = []; + foreach ($allSecretNames as $secretName) { + $fetched = self::retrieveFromSecretsManager($smClient, $secretName); + $secrets = array_merge($secrets, $fetched); + } + return $secrets; + } + ); + + // Global import: set all JSON keys as env vars + if ($globalSecretName !== null) { + $secretValue = $fetchedSecrets[$globalSecretName] ?? null; + if ($secretValue !== null) { + /** @var mixed $decoded */ + $decoded = json_decode($secretValue, true, 512, JSON_THROW_ON_ERROR); + if (! is_array($decoded)) { + throw new RuntimeException( + "Secret '$globalSecretName' is not valid JSON. When using BREF_SECRETS_MANAGER or bref-secret: with a JSON key, the secret value must be a valid JSON object." + ); + } + foreach ($decoded as $key => $value) { + if (! is_scalar($value) && $value !== null) { + throw new RuntimeException( + "Secret '$globalSecretName' contains non-scalar value for key '$key'. Only string, number, and boolean values are supported." + ); + } + $stringValue = (string) $value; + $_SERVER[$key] = $_ENV[$key] = $stringValue; + putenv("$key=$stringValue"); + } + } + + // Consume the BREF_SECRETS_MANAGER env var + unset($_SERVER['BREF_SECRETS_MANAGER'], $_ENV['BREF_SECRETS_MANAGER']); + putenv('BREF_SECRETS_MANAGER'); + } + + // Individual bref-secret: env vars override global import for the same key + $individualEnvVarsSet = []; + foreach ($secretNameToEnvVars as $secretName => $envVarMappings) { + $secretValue = $fetchedSecrets[$secretName] ?? null; + if ($secretValue === null) { + continue; + } + + foreach ($envVarMappings as $mapping) { + $envVar = $mapping['envVar']; + $jsonKey = $mapping['jsonKey']; + + if ($jsonKey !== null) { + $decoded = json_decode($secretValue, true, 512, JSON_THROW_ON_ERROR); + if (! is_array($decoded)) { + throw new RuntimeException( + "Secret '$secretName' is not valid JSON. When using bref-secret: with a JSON key, the secret value must be a valid JSON object." + ); + } + if (! array_key_exists($jsonKey, $decoded)) { + throw new RuntimeException( + "Key '$jsonKey' not found in secret '$secretName'. Available keys: " . implode(', ', array_keys($decoded)) + ); + } + $value = (string) $decoded[$jsonKey]; + } else { + $value = $secretValue; + } + + $_SERVER[$envVar] = $_ENV[$envVar] = $value; + putenv("$envVar=$value"); + $individualEnvVarsSet[] = $envVar; + } + } + + // Log loaded env vars (only on actual API call, not from cache) + if ($actuallyCalledApi) { + $allEnvVarsSet = []; + if ($globalSecretName !== null) { + $secretValue = $fetchedSecrets[$globalSecretName] ?? null; + if ($secretValue !== null) { + $decoded = json_decode($secretValue, true, 512, JSON_THROW_ON_ERROR); + if (is_array($decoded)) { + $allEnvVarsSet = array_keys($decoded); + } + } + } + $allEnvVarsSet = array_unique(array_merge($allEnvVarsSet, $individualEnvVarsSet)); + if (! empty($allEnvVarsSet)) { + $secretNameStr = implode("', '", $allSecretNames); + self::writeLog("[Bref] Loaded environment variables from Secrets Manager secret '$secretNameStr': " . implode(', ', $allEnvVarsSet) . PHP_EOL); + } + } + } + + /** + * Load SSM parameters. + * + * @param array $envVars + */ + private static function loadSsmParameters(?SsmClient $ssmClient, array $envVars): void + { // Only consider environment variables that start with "bref-ssm:" $envVarsToDecrypt = array_filter($envVars, function (string $value): bool { return str_starts_with($value, 'bref-ssm:'); @@ -37,10 +211,13 @@ public static function loadSecretEnvironmentVariables(?SsmClient $ssmClient = nu }, $envVarsToDecrypt); $actuallyCalledSsm = false; - $parameters = self::readParametersFromCacheOr(function () use ($ssmClient, $ssmNames, &$actuallyCalledSsm) { - $actuallyCalledSsm = true; - return self::retrieveParametersFromSsm($ssmClient, array_values($ssmNames)); - }); + $parameters = self::readFromCacheOr( + sys_get_temp_dir() . '/bref-ssm-parameters.php', + function () use ($ssmClient, $ssmNames, &$actuallyCalledSsm) { + $actuallyCalledSsm = true; + return self::retrieveParametersFromSsm($ssmClient, array_values($ssmNames)); + } + ); foreach ($envVarsToDecrypt as $envVar => $prefixedSsmRefName) { $parameterName = substr($prefixedSsmRefName, strlen('bref-ssm:')); @@ -52,39 +229,88 @@ public static function loadSecretEnvironmentVariables(?SsmClient $ssmClient = nu // Only log once (when the cache was empty) else it might spam the logs in the function runtime // (where the process restarts on every invocation) if ($actuallyCalledSsm) { - $stderr = fopen('php://stderr', 'ab'); - fwrite($stderr, '[Bref] Loaded these environment variables from SSM: ' . implode(', ', array_keys($envVarsToDecrypt)) . PHP_EOL); + self::writeLog('[Bref] Loaded these environment variables from SSM: ' . implode(', ', array_keys($envVarsToDecrypt)) . PHP_EOL); + } + } + + /** + * Write a log message to stderr (or to the override stream for testing). + */ + private static function writeLog(string $message): void + { + $stream = self::$outputStream ?? fopen('php://stderr', 'ab'); + fwrite($stream, $message); + if (self::$outputStream === null) { + fclose($stream); } } /** - * Cache the parameters in a temp file. - * Why? Because on the function runtime, the PHP process might - * restart on every invocation (or on error), so we don't want to - * call SSM every time. + * Read from a file cache, or resolve and write to cache. * - * @param Closure(): array $paramResolver - * @return array Map of parameter name -> value + * Why cache? On the function runtime the PHP process may restart on every + * invocation (or on error), so we avoid calling AWS APIs every time. + * + * @param Closure(): array $resolver + * @return array * @throws JsonException */ - private static function readParametersFromCacheOr(Closure $paramResolver): array + private static function readFromCacheOr(string $cacheFile, Closure $resolver): array { - // Check in cache first - $cacheFile = sys_get_temp_dir() . '/bref-ssm-parameters.php'; if (is_file($cacheFile)) { - $parameters = json_decode(file_get_contents($cacheFile), true, 512, JSON_THROW_ON_ERROR); - if (is_array($parameters)) { - return $parameters; + $data = json_decode(file_get_contents($cacheFile), true, 512, JSON_THROW_ON_ERROR); + if (is_array($data)) { + return $data; } } - // Not in cache yet: we resolve it - $parameters = $paramResolver(); + $data = $resolver(); - // Using json_encode instead of var_export due to possible security issues - file_put_contents($cacheFile, json_encode($parameters, JSON_THROW_ON_ERROR)); + file_put_contents($cacheFile, json_encode($data, JSON_THROW_ON_ERROR)); - return $parameters; + return $data; + } + + /** + * Fetch a secret from AWS Secrets Manager. + * + * @return array Map of secret name -> raw secret string value + */ + private static function retrieveFromSecretsManager(?SecretsManagerClient $smClient, string $secretName): array + { + $sm = $smClient ?? new SecretsManagerClient([ + 'region' => $_ENV['AWS_REGION'] ?? $_ENV['AWS_DEFAULT_REGION'], + ]); + + try { + $result = $sm->getSecretValue(['SecretId' => $secretName]); + $secretString = $result->getSecretString(); + } catch (ResourceNotFoundException $e) { + throw new RuntimeException( + "Secret '$secretName' not found in AWS Secrets Manager. Check that the secret exists and the name is correct.", + $e->getCode(), + $e, + ); + } catch (ClientException $e) { + $message = $e->getMessage(); + // Check if this is actually a permissions error vs. other errors (e.g., throttling) + if (str_contains($message, 'not authorized') || str_contains($message, 'AccessDenied') || str_contains($message, 'access denied')) { + throw new RuntimeException( + "Bref was not able to retrieve secret '$secretName' from AWS Secrets Manager because of a permissions issue. Did you add `secretsmanager:GetSecretValue` to your IAM role? (docs: https://bref.sh/docs/environment/variables.html)\nFull exception message: {$e->getMessage()}", + $e->getCode(), + $e, + ); + } + throw $e; + } + + if ($secretString === null) { + throw new RuntimeException( + "Secret '$secretName' does not contain a string value. Binary secrets are not supported." + ); + } + + return [$secretName => $secretString]; } /** @@ -113,12 +339,15 @@ private static function retrieveParametersFromSsm(?SsmClient $ssmClient, array $ } } catch (RuntimeException $e) { if ($e->getCode() === 400) { - // Extra descriptive error message for the most common error - throw new RuntimeException( - "Bref was not able to resolve secrets contained in environment variables from SSM because of a permissions issue with the SSM API. Did you add IAM permissions in serverless.yml to allow Lambda to access SSM? (docs: https://bref.sh/docs/environment/variables.html#at-deployment-time).\nFull exception message: {$e->getMessage()}", - $e->getCode(), - $e, - ); + $message = $e->getMessage(); + // Check if this is actually a permissions error vs. other 400 errors (e.g., throttling) + if (str_contains($message, 'not authorized') || str_contains($message, 'AccessDenied') || str_contains($message, 'access denied')) { + throw new RuntimeException( + "Bref was not able to resolve secrets contained in environment variables from SSM because of a permissions issue with the SSM API. Did you add IAM permissions in serverless.yml to allow Lambda to access SSM? (docs: https://bref.sh/docs/environment/variables.html#at-deployment-time).\nFull exception message: {$e->getMessage()}", + $e->getCode(), + $e, + ); + } } throw $e; } diff --git a/tests/SecretsTest.php b/tests/SecretsTest.php index 9c85a10..2f5c662 100644 --- a/tests/SecretsTest.php +++ b/tests/SecretsTest.php @@ -2,12 +2,19 @@ namespace Bref\Secrets\Test; +use AsyncAws\Core\AwsError\AwsError; +use AsyncAws\Core\Exception\Http\ClientException; use AsyncAws\Core\Test\ResultMockFactory; +use AsyncAws\SecretsManager\Exception\ResourceNotFoundException; +use AsyncAws\SecretsManager\Result\GetSecretValueResponse; +use AsyncAws\SecretsManager\SecretsManagerClient; use AsyncAws\Ssm\Result\GetParametersResult; use AsyncAws\Ssm\SsmClient; use AsyncAws\Ssm\ValueObject\Parameter; use Bref\Secrets\Secrets; use PHPUnit\Framework\TestCase; +use RuntimeException; +use Symfony\Contracts\HttpClient\ResponseInterface; class SecretsTest extends TestCase { @@ -21,6 +28,20 @@ public function setUp(): void if (file_exists(sys_get_temp_dir() . '/bref-ssm-parameters.php')) { unlink(sys_get_temp_dir() . '/bref-ssm-parameters.php'); } + if (file_exists(sys_get_temp_dir() . '/bref-secrets-manager.php')) { + unlink(sys_get_temp_dir() . '/bref-secrets-manager.php'); + } + } + + public function tearDown(): void + { + // Clean up any env vars that may have been left behind (e.g. after expected exceptions) + foreach (['SOME_VARIABLE', 'SOME_OTHER_VARIABLE', 'VAR1', 'VAR2', 'DB_PASSWORD', 'API_KEY', 'STRIPE_SECRET', 'BREF_SECRETS_MANAGER', 'VENDOR_KEY', 'SPECIFIC_VAR'] as $var) { + putenv($var); + unset($_SERVER[$var], $_ENV[$var]); + } + // Reset the output stream override + Secrets::$outputStream = null; } public function test decrypts env variables(): void @@ -103,6 +124,406 @@ public function test throws a clear error message on missing permissions putenv('SOME_VARIABLE'); } + public function test ssm throttling error is not labeled as permissions issue(): void + { + putenv('SOME_VARIABLE=bref-ssm:/app/test'); + + $ssmClient = $this->getMockBuilder(SsmClient::class) + ->disableOriginalConstructor() + ->getMock(); + $result = ResultMockFactory::createFailing(GetParametersResult::class, 400, 'Rate exceeded'); + $ssmClient->method('getParameters') + ->willReturn($result); + + $this->expectException(RuntimeException::class); + $this->expectExceptionMessageMatches('/Rate exceeded/'); + try { + Secrets::loadSecretEnvironmentVariables($ssmClient); + } catch (RuntimeException $e) { + $this->assertStringNotContainsString('permissions issue', $e->getMessage()); + throw $e; + } + } + + public function test secrets manager bulk import sets env vars from json secret(): void + { + putenv('BREF_SECRETS_MANAGER=api/production'); + + $secretJson = json_encode([ + 'DB_PASSWORD' => 'secret123', + 'API_KEY' => 'abc-xyz', + ]); + + $smClient = $this->mockSecretsManagerClient('api/production', $secretJson); + Secrets::loadSecretEnvironmentVariables(null, $smClient); + + $this->assertSame('secret123', getenv('DB_PASSWORD')); + $this->assertSame('secret123', $_SERVER['DB_PASSWORD']); + $this->assertSame('secret123', $_ENV['DB_PASSWORD']); + $this->assertSame('abc-xyz', getenv('API_KEY')); + $this->assertSame('abc-xyz', $_SERVER['API_KEY']); + $this->assertSame('abc-xyz', $_ENV['API_KEY']); + // BREF_SECRETS_MANAGER should be consumed (unset) after loading + $this->assertFalse(getenv('BREF_SECRETS_MANAGER')); + + // Cleanup + putenv('DB_PASSWORD'); + putenv('API_KEY'); + putenv('BREF_SECRETS_MANAGER'); + } + + public function test bref secret prefix resolves plain string secret(): void + { + putenv('VENDOR_KEY=bref-secret:vendor/api-key'); + + $smClient = $this->mockSecretsManagerClient('vendor/api-key', 'my-vendor-key-value'); + Secrets::loadSecretEnvironmentVariables(null, $smClient); + + $this->assertSame('my-vendor-key-value', getenv('VENDOR_KEY')); + $this->assertSame('my-vendor-key-value', $_SERVER['VENDOR_KEY']); + $this->assertSame('my-vendor-key-value', $_ENV['VENDOR_KEY']); + } + + public function test bref secret prefix extracts json key(): void + { + putenv('SPECIFIC_VAR=bref-secret:api/production:DB_PASSWORD'); + + $secretJson = json_encode(['DB_PASSWORD' => 'secret123', 'API_KEY' => 'abc-xyz']); + $smClient = $this->mockSecretsManagerClient('api/production', $secretJson); + Secrets::loadSecretEnvironmentVariables(null, $smClient); + + $this->assertSame('secret123', getenv('SPECIFIC_VAR')); + $this->assertSame('secret123', $_SERVER['SPECIFIC_VAR']); + $this->assertSame('secret123', $_ENV['SPECIFIC_VAR']); + } + + public function test smart batching fetches same secret only once(): void + { + putenv('DB_PASSWORD=bref-secret:api/production:DB_PASSWORD'); + putenv('API_KEY=bref-secret:api/production:API_KEY'); + + $secretJson = json_encode(['DB_PASSWORD' => 'secret123', 'API_KEY' => 'abc-xyz']); + + $smClient = $this->getMockBuilder(SecretsManagerClient::class) + ->disableOriginalConstructor() + ->onlyMethods(['getSecretValue']) + ->getMock(); + + $result = ResultMockFactory::create(GetSecretValueResponse::class, [ + 'SecretString' => $secretJson, + ]); + + $smClient->expects($this->once()) + ->method('getSecretValue') + ->with(['SecretId' => 'api/production']) + ->willReturn($result); + + Secrets::loadSecretEnvironmentVariables(null, $smClient); + + $this->assertSame('secret123', getenv('DB_PASSWORD')); + $this->assertSame('abc-xyz', getenv('API_KEY')); + } + + public function test smart batching deduplicates global and individual refs(): void + { + putenv('BREF_SECRETS_MANAGER=api/production'); + putenv('STRIPE_SECRET=bref-secret:api/production:STRIPE_SECRET'); + + $secretJson = json_encode([ + 'DB_PASSWORD' => 'secret123', + 'API_KEY' => 'abc-xyz', + 'STRIPE_SECRET' => 'sk_live_override', + ]); + + $smClient = $this->getMockBuilder(SecretsManagerClient::class) + ->disableOriginalConstructor() + ->onlyMethods(['getSecretValue']) + ->getMock(); + + $result = ResultMockFactory::create(GetSecretValueResponse::class, [ + 'SecretString' => $secretJson, + ]); + + $smClient->expects($this->once()) + ->method('getSecretValue') + ->with(['SecretId' => 'api/production']) + ->willReturn($result); + + Secrets::loadSecretEnvironmentVariables(null, $smClient); + + $this->assertSame('secret123', getenv('DB_PASSWORD')); + $this->assertSame('abc-xyz', getenv('API_KEY')); + $this->assertSame('sk_live_override', getenv('STRIPE_SECRET')); + } + + public function test individual bref secret overrides global import for same key(): void + { + putenv('BREF_SECRETS_MANAGER=api/production'); + putenv('DB_PASSWORD=bref-secret:override/db-password'); + + $globalJson = json_encode([ + 'DB_PASSWORD' => 'global-password', + 'API_KEY' => 'global-api-key', + ]); + + $smClient = $this->getMockBuilder(SecretsManagerClient::class) + ->disableOriginalConstructor() + ->onlyMethods(['getSecretValue']) + ->getMock(); + + $globalResult = ResultMockFactory::create(GetSecretValueResponse::class, [ + 'SecretString' => $globalJson, + ]); + $overrideResult = ResultMockFactory::create(GetSecretValueResponse::class, [ + 'SecretString' => 'individual-override-password', + ]); + + $smClient->expects($this->exactly(2)) + ->method('getSecretValue') + ->withConsecutive( + [['SecretId' => 'override/db-password']], + [['SecretId' => 'api/production']], + ) + ->willReturnOnConsecutiveCalls($overrideResult, $globalResult); + + Secrets::loadSecretEnvironmentVariables(null, $smClient); + + $this->assertSame('individual-override-password', getenv('DB_PASSWORD')); + $this->assertSame('global-api-key', getenv('API_KEY')); + } + + public function test secrets manager caches to avoid repeat api calls(): void + { + putenv('BREF_SECRETS_MANAGER=api/production'); + + $secretJson = json_encode(['DB_PASSWORD' => 'secret123']); + + $smClient = $this->mockSecretsManagerClient('api/production', $secretJson); + + Secrets::loadSecretEnvironmentVariables(null, $smClient); + $this->assertSame('secret123', getenv('DB_PASSWORD')); + + // Re-set the env var for the second call (it gets consumed) + putenv('BREF_SECRETS_MANAGER=api/production'); + + Secrets::loadSecretEnvironmentVariables(null, $smClient); + $this->assertSame('secret123', getenv('DB_PASSWORD')); + } + + public function test error secret not found(): void + { + putenv('DB_PASSWORD=bref-secret:nonexistent/secret'); + + $smClient = $this->getMockBuilder(SecretsManagerClient::class) + ->disableOriginalConstructor() + ->onlyMethods(['getSecretValue']) + ->getMock(); + + $smClient->method('getSecretValue') + ->willThrowException($this->createResourceNotFoundException('nonexistent/secret')); + + $this->expectException(RuntimeException::class); + $this->expectExceptionMessage("Secret 'nonexistent/secret' not found in AWS Secrets Manager. Check that the secret exists and the name is correct."); + Secrets::loadSecretEnvironmentVariables(null, $smClient); + } + + public function test error invalid json in global import(): void + { + putenv('BREF_SECRETS_MANAGER=api/production'); + + $smClient = $this->mockSecretsManagerClient('api/production', 'not-valid-json{{{'); + + $this->expectException(\JsonException::class); + Secrets::loadSecretEnvironmentVariables(null, $smClient); + } + + public function test error non object json in global import(): void + { + putenv('BREF_SECRETS_MANAGER=api/production'); + + $smClient = $this->mockSecretsManagerClient('api/production', '"just a string"'); + + $this->expectException(RuntimeException::class); + $this->expectExceptionMessage("Secret 'api/production' is not valid JSON"); + Secrets::loadSecretEnvironmentVariables(null, $smClient); + } + + public function test error nested json values in global import(): void + { + putenv('BREF_SECRETS_MANAGER=api/production'); + + $secretJson = json_encode(['DB_PASSWORD' => 'secret123', 'CONFIG' => ['nested' => true]]); + $smClient = $this->mockSecretsManagerClient('api/production', $secretJson); + + $this->expectException(RuntimeException::class); + $this->expectExceptionMessage("Secret 'api/production' contains non-scalar value for key 'CONFIG'"); + Secrets::loadSecretEnvironmentVariables(null, $smClient); + } + + public function test error json key not found(): void + { + putenv('DB_PASSWORD=bref-secret:api/production:NONEXISTENT_KEY'); + + $secretJson = json_encode(['DB_HOST' => 'localhost', 'DB_USER' => 'admin']); + $smClient = $this->mockSecretsManagerClient('api/production', $secretJson); + + $this->expectException(RuntimeException::class); + $this->expectExceptionMessage("Key 'NONEXISTENT_KEY' not found in secret 'api/production'. Available keys: DB_HOST, DB_USER"); + Secrets::loadSecretEnvironmentVariables(null, $smClient); + } + + public function test error sm throttling is not labeled as permissions(): void + { + putenv('DB_PASSWORD=bref-secret:api/production'); + + $smClient = $this->getMockBuilder(SecretsManagerClient::class) + ->disableOriginalConstructor() + ->onlyMethods(['getSecretValue']) + ->getMock(); + + $smClient->method('getSecretValue') + ->willThrowException($this->createClientException(400, 'ThrottlingException', 'Rate exceeded')); + + $this->expectException(RuntimeException::class); + $this->expectExceptionMessageMatches('/Rate exceeded/'); + try { + Secrets::loadSecretEnvironmentVariables(null, $smClient); + } catch (RuntimeException $e) { + $this->assertStringNotContainsString('permissions issue', $e->getMessage()); + throw $e; + } + } + + public function test error permission denied(): void + { + putenv('DB_PASSWORD=bref-secret:api/production'); + + $smClient = $this->getMockBuilder(SecretsManagerClient::class) + ->disableOriginalConstructor() + ->onlyMethods(['getSecretValue']) + ->getMock(); + + $smClient->method('getSecretValue') + ->willThrowException($this->createClientException(400, 'AccessDeniedException', 'User: arn:aws:sts::123456:assumed-role/app-dev is not authorized to perform: secretsmanager:GetSecretValue')); + + $this->expectException(RuntimeException::class); + $this->expectExceptionMessage("Bref was not able to retrieve secret 'api/production' from AWS Secrets Manager because of a permissions issue. Did you add `secretsmanager:GetSecretValue` to your IAM role?"); + Secrets::loadSecretEnvironmentVariables(null, $smClient); + } + + public function test secrets manager logs loaded env vars to stderr(): void + { + putenv('BREF_SECRETS_MANAGER=api/production'); + + $secretJson = json_encode([ + 'DB_PASSWORD' => 'secret123', + 'API_KEY' => 'abc-xyz', + ]); + + $smClient = $this->mockSecretsManagerClient('api/production', $secretJson); + + $capturedOutput = fopen('php://memory', 'r+b'); + Secrets::$outputStream = $capturedOutput; + try { + Secrets::loadSecretEnvironmentVariables(null, $smClient); + } finally { + Secrets::$outputStream = null; + } + + rewind($capturedOutput); + $output = stream_get_contents($capturedOutput); + fclose($capturedOutput); + + $this->assertStringContainsString("[Bref] Loaded environment variables from Secrets Manager secret 'api/production': ", $output); + $this->assertStringContainsString('DB_PASSWORD', $output); + $this->assertStringContainsString('API_KEY', $output); + } + + public function test secrets manager does not log when using cache(): void + { + putenv('BREF_SECRETS_MANAGER=api/production'); + + $secretJson = json_encode(['DB_PASSWORD' => 'secret123']); + $smClient = $this->mockSecretsManagerClient('api/production', $secretJson); + + // First call — triggers API + logging + Secrets::loadSecretEnvironmentVariables(null, $smClient); + + // Re-set env var (consumed after first call) + putenv('BREF_SECRETS_MANAGER=api/production'); + + // Second call — should use cache, no logging + $capturedOutput = fopen('php://memory', 'r+b'); + Secrets::$outputStream = $capturedOutput; + try { + Secrets::loadSecretEnvironmentVariables(null, $smClient); + } finally { + Secrets::$outputStream = null; + } + + rewind($capturedOutput); + $output = stream_get_contents($capturedOutput); + fclose($capturedOutput); + + $this->assertEmpty($output, 'No logging should occur when reading from cache'); + } + + public function test coexistence ssm and secrets manager and global import(): void + { + // Global import: sets DB_PASSWORD and API_KEY from Secrets Manager + putenv('BREF_SECRETS_MANAGER=api/production'); + // Individual bref-secret: overrides DB_PASSWORD (higher priority than global) + putenv('DB_PASSWORD=bref-secret:override/db-password'); + // bref-ssm: sets LEGACY_PARAM from SSM (highest priority of all) + putenv('LEGACY_PARAM=bref-ssm:/old/ssm/parameter'); + + $globalJson = json_encode([ + 'DB_PASSWORD' => 'global-password', + 'API_KEY' => 'global-api-key', + ]); + + $smClient = $this->getMockBuilder(SecretsManagerClient::class) + ->disableOriginalConstructor() + ->onlyMethods(['getSecretValue']) + ->getMock(); + + $globalResult = ResultMockFactory::create(GetSecretValueResponse::class, [ + 'SecretString' => $globalJson, + ]); + $overrideResult = ResultMockFactory::create(GetSecretValueResponse::class, [ + 'SecretString' => 'individual-db-password', + ]); + + $smClient->expects($this->exactly(2)) + ->method('getSecretValue') + ->withConsecutive( + [['SecretId' => 'override/db-password']], + [['SecretId' => 'api/production']], + ) + ->willReturnOnConsecutiveCalls($overrideResult, $globalResult); + + $ssmClient = $this->mockSsmClient([ + new Parameter([ + 'Name' => '/old/ssm/parameter', + 'Value' => 'ssm-legacy-value', + ]), + ]); + + Secrets::loadSecretEnvironmentVariables($ssmClient, $smClient); + + // API_KEY from global import + $this->assertSame('global-api-key', getenv('API_KEY')); + // DB_PASSWORD from individual bref-secret: (overrides global) + $this->assertSame('individual-db-password', getenv('DB_PASSWORD')); + // LEGACY_PARAM from SSM + $this->assertSame('ssm-legacy-value', getenv('LEGACY_PARAM')); + // BREF_SECRETS_MANAGER consumed + $this->assertFalse(getenv('BREF_SECRETS_MANAGER')); + + // Cleanup + putenv('LEGACY_PARAM'); + } + /** * @param array $resultParameters */ @@ -131,4 +552,62 @@ private function mockSsmClient(array $resultParameters): SsmClient return $ssmClient; } + + /** + * Creates a mock SecretsManagerClient that expects one getSecretValue call. + */ + private function mockSecretsManagerClient(string $expectedSecretId, string $secretString): SecretsManagerClient + { + $smClient = $this->getMockBuilder(SecretsManagerClient::class) + ->disableOriginalConstructor() + ->onlyMethods(['getSecretValue']) + ->getMock(); + + $result = ResultMockFactory::create(GetSecretValueResponse::class, [ + 'SecretString' => $secretString, + ]); + + $smClient->expects($this->once()) + ->method('getSecretValue') + ->with(['SecretId' => $expectedSecretId]) + ->willReturn($result); + + return $smClient; + } + + /** + * Creates a ResourceNotFoundException for testing. + */ + private function createResourceNotFoundException(string $secretName): ResourceNotFoundException + { + $response = $this->createMock(ResponseInterface::class); + $response->method('getInfo')->willReturnCallback(function (?string $type) { + if ($type === 'http_code') return 404; + if ($type === 'url') return 'https://secretsmanager.us-east-1.amazonaws.com'; + return null; + }); + $response->method('getHeaders')->willReturn([]); + $response->method('getContent')->willReturn(''); + + $awsError = new AwsError('ResourceNotFoundException', "Secrets Manager can't find the specified secret: $secretName", null, null); + return new ResourceNotFoundException($response, $awsError); + } + + /** + * Creates a ClientException for testing (e.g., permission denied). + */ + private function createClientException(int $httpCode, string $awsCode, string $message): ClientException + { + $response = $this->createMock(ResponseInterface::class); + $response->method('getInfo')->willReturnCallback(function (?string $type) use ($httpCode) { + if ($type === 'http_code') return $httpCode; + if ($type === 'url') return 'https://secretsmanager.us-east-1.amazonaws.com'; + return null; + }); + $response->method('getHeaders')->willReturn([]); + $response->method('getContent')->willReturn(''); + + $awsError = new AwsError($awsCode, $message, null, null); + return new ClientException($response, $awsError); + } }