From 5b342fb1d30bc26b132f844770b55a62e49af930 Mon Sep 17 00:00:00 2001 From: bijanmmarkes Date: Wed, 4 Mar 2026 12:01:24 -0800 Subject: [PATCH 1/5] Add AWS Secrets Manager support as alternative to SSM Parameter Store Users can store environment variables as a single JSON secret in Secrets Manager, loaded in 1 API call per cold start instead of ceil(N/10) SSM batch calls. This reduces cold start latency and provides 10x TPS headroom (10,000 vs SSM's 1,000). Two modes: BREF_SECRETS_MANAGER env var for global JSON import, and bref-secret: prefix for individual secrets with optional JSON key extraction. Smart batching deduplicates API calls across both modes. Also fixes SSM error handling: HTTP 400 catch-all no longer mislabels throttling errors as permission issues. --- README.md | 85 +++++++- composer.json | 3 +- src/Secrets.php | 291 ++++++++++++++++++++++--- tests/SecretsTest.php | 479 ++++++++++++++++++++++++++++++++++++++++++ 4 files changed, 816 insertions(+), 42 deletions(-) diff --git a/README.md b/README.md index 863c68c..6936ad5 100644 --- a/README.md +++ b/README.md @@ -1,25 +1,90 @@ -Automatically load secrets from SSM into environment variables when running with Bref. +Automatically load secrets into environment variables at runtime when running with [Bref](https://bref.sh). -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: +Supports two AWS backends: + +- **AWS Secrets Manager** — store all env vars as a single JSON secret, loaded in **1 API call** per cold start +- **AWS SSM Parameter Store** — load individual parameters via the `bref-ssm:` prefix (existing behavior) + +This package is separate so that its dependencies are not installed for all Bref users. Install it only if you need runtime secret loading. + +## Installation + +``` +composer require bref/secrets-loader +``` + +## Usage + +Read the full Bref documentation: https://bref.sh/docs/environment/variables.html#secrets + +### Secrets Manager + +Create a JSON secret per environment: + +```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 with 1 API call: ```yaml provider: - # ... environment: - MY_PARAMETER: bref-ssm:/my-app/my-parameter + BREF_SECRETS_MANAGER: api/${sls:stage} + iam: + role: + statements: + - Effect: Allow + Action: secretsmanager:GetSecretValue + Resource: arn:aws:secretsmanager:${aws:region}:${aws:accountId}:secret:api/* ``` -In AWS Lambda, the `MY_PARAMETER` would be automatically replaced and would contain the value stored at `/my-app/my-parameter` in AWS SSM Parameters. +**Individual import** — load specific secrets per env var: -This feature is shipped as a separate package so that all its code and dependencies are not installed by default for all Bref users. Install this package if you want to use the feature. +```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 +``` -## Installation +Multiple references to the same secret name are deduplicated into a single API call. +### SSM Parameter Store + +```yaml +provider: + environment: + MY_PARAMETER: bref-ssm:/my-app/my-parameter ``` -composer require bref/secrets-loader + +`MY_PARAMETER` is replaced at runtime with the value stored at `/my-app/my-parameter` in SSM. + +### Combining both backends + +You can use all three sources together. Priority order (highest wins): + +1. `bref-ssm:` — explicit SSM per-variable mapping +2. `bref-secret:` — explicit Secrets Manager per-variable mapping +3. `BREF_SECRETS_MANAGER` — bulk Secrets Manager import + +```yaml +provider: + environment: + BREF_SECRETS_MANAGER: api/${sls:stage} + VENDOR_KEY: bref-secret:vendor/api-key + LEGACY_PARAM: bref-ssm:/old/ssm/parameter ``` -## Usage +### Why Secrets Manager? -Read the Bref documentation: https://bref.sh/docs/environment/variables.html#secrets +| | SSM Parameter Store | Secrets Manager | +|---|---|---| +| API calls per cold start | ceil(N/10) | **1** | +| TPS limit | 1,000 | **10,000** | +| Max value size | 4 KB | **64 KB** | 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); + } } From 10fdc28942421f2336064f6e04f0e666047bdf30 Mon Sep 17 00:00:00 2001 From: bijanmmarkes Date: Wed, 4 Mar 2026 12:11:30 -0800 Subject: [PATCH 2/5] Rewrite README to preserve original SSM docs and add practical cost comparison Keep the original SSM explanation and examples largely intact. Add Secrets Manager as an additional option rather than replacing SSM content. Replace the generic comparison table with a 'when to use' section explaining the 40 TPS threshold, per-parameter billing, and a cost breakdown at different cold start volumes. --- README.md | 74 ++++++++++++++++++++++++++++++++++++------------------- 1 file changed, 48 insertions(+), 26 deletions(-) diff --git a/README.md b/README.md index 6936ad5..dca2801 100644 --- a/README.md +++ b/README.md @@ -1,11 +1,17 @@ -Automatically load secrets into environment variables at runtime when running with [Bref](https://bref.sh). +Automatically load secrets from SSM or Secrets Manager into environment variables when running with Bref. -Supports two AWS backends: +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: -- **AWS Secrets Manager** — store all env vars as a single JSON secret, loaded in **1 API call** per cold start -- **AWS SSM Parameter Store** — load individual parameters via the `bref-ssm:` prefix (existing behavior) +```yaml +provider: + # ... + environment: + MY_PARAMETER: bref-ssm:/my-app/my-parameter +``` -This package is separate so that its dependencies are not installed for all Bref users. Install it only if you need runtime secret loading. +In AWS Lambda, the `MY_PARAMETER` would be automatically replaced and would contain the value stored at `/my-app/my-parameter` in AWS SSM Parameters. + +This feature is shipped as a separate package so that all its code and dependencies are not installed by default for all Bref users. Install this package if you want to use the feature. ## Installation @@ -15,11 +21,21 @@ composer require bref/secrets-loader ## Usage -Read the full Bref documentation: https://bref.sh/docs/environment/variables.html#secrets +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 -Create a JSON secret per environment: +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 \ @@ -27,7 +43,7 @@ aws secretsmanager create-secret \ --secret-string '{"DB_PASSWORD":"secret123","API_KEY":"abc-xyz"}' ``` -**Global import** — all JSON keys become env vars with 1 API call: +**Global import** — all JSON keys become env vars: ```yaml provider: @@ -54,19 +70,9 @@ provider: Multiple references to the same secret name are deduplicated into a single API call. -### 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. - ### Combining both backends -You can use all three sources together. Priority order (highest wins): +You can use both SSM and Secrets Manager together. Priority order (highest wins): 1. `bref-ssm:` — explicit SSM per-variable mapping 2. `bref-secret:` — explicit Secrets Manager per-variable mapping @@ -77,14 +83,30 @@ provider: environment: BREF_SECRETS_MANAGER: api/${sls:stage} VENDOR_KEY: bref-secret:vendor/api-key - LEGACY_PARAM: bref-ssm:/old/ssm/parameter + MY_PARAMETER: bref-ssm:/my-app/my-parameter +``` + +### 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, and each parameter in the batch counts as a separate API interaction for billing. If you have 50 parameters, that's 5 API calls and 50 billable interactions per cold start. + +Once you exceed the 40 TPS default limit, you must enable higher throughput or your requests get throttled. With higher throughput enabled, SSM charges $0.05 per 10,000 interactions — and those add up fast because of the per-parameter counting. + +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 ``` -### Why Secrets Manager? +\* Free but limited to 40 TPS shared — cold start spikes will cause throttling errors. -| | SSM Parameter Store | Secrets Manager | -|---|---|---| -| API calls per cold start | ceil(N/10) | **1** | -| TPS limit | 1,000 | **10,000** | -| Max value size | 4 KB | **64 KB** | +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. From c0180150fcacac1207e139c843c84c8e5203274a Mon Sep 17 00:00:00 2001 From: bijanmmarkes Date: Wed, 4 Mar 2026 12:20:06 -0800 Subject: [PATCH 3/5] README updates --- README.md | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/README.md b/README.md index dca2801..6ea1e5b 100644 --- a/README.md +++ b/README.md @@ -72,11 +72,11 @@ Multiple references to the same secret name are deduplicated into a single API c ### Combining both backends -You can use both SSM and Secrets Manager together. Priority order (highest wins): +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:` — explicit SSM per-variable mapping -2. `bref-secret:` — explicit Secrets Manager per-variable mapping -3. `BREF_SECRETS_MANAGER` — bulk Secrets Manager import +1. `bref-ssm:` overrides everything +2. `bref-secret:` overrides the global import +3. `BREF_SECRETS_MANAGER` global import sets the baseline ```yaml provider: @@ -88,9 +88,9 @@ provider: ### 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, and each parameter in the batch counts as a separate API interaction for billing. If you have 50 parameters, that's 5 API calls and 50 billable interactions per cold start. +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. -Once you exceed the 40 TPS default limit, you must enable higher throughput or your requests get throttled. With higher throughput enabled, SSM charges $0.05 per 10,000 interactions — and those add up fast because of the per-parameter counting. +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. From 4e7d68f1862bd135cc0a7bc143c00fe92a745ffc Mon Sep 17 00:00:00 2001 From: bijanmmarkes Date: Wed, 4 Mar 2026 12:38:29 -0800 Subject: [PATCH 4/5] README: Update resource path --- README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.md b/README.md index 6ea1e5b..04d9f73 100644 --- a/README.md +++ b/README.md @@ -54,7 +54,7 @@ provider: statements: - Effect: Allow Action: secretsmanager:GetSecretValue - Resource: arn:aws:secretsmanager:${aws:region}:${aws:accountId}:secret:api/* + Resource: arn:aws:secretsmanager:${aws:region}:${aws:accountId}:secret:api/${sls:stage} ``` **Individual import** — load specific secrets per env var: From 7ca024eb4134d3888546ac91009f85c55cbf64c5 Mon Sep 17 00:00:00 2001 From: bijanmmarkes Date: Wed, 4 Mar 2026 13:34:07 -0800 Subject: [PATCH 5/5] README: add IAM wildcard suffix and bref/bref compatibility note MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Secrets Manager ARNs include a random 6-character suffix, so IAM resource policies need a -* wildcard or requests are denied. Also document that bref/bref currently only triggers the secrets loader when bref-ssm: env vars are present — users need at least one SSM var until LazySecretsLoader is updated upstream. --- README.md | 10 +++++++++- 1 file changed, 9 insertions(+), 1 deletion(-) diff --git a/README.md b/README.md index 04d9f73..d15a1f1 100644 --- a/README.md +++ b/README.md @@ -54,9 +54,11 @@ provider: statements: - Effect: Allow Action: secretsmanager:GetSecretValue - Resource: arn:aws:secretsmanager:${aws:region}:${aws:accountId}:secret:api/${sls:stage} + 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 @@ -86,6 +88,12 @@ provider: 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.