-
-
Notifications
You must be signed in to change notification settings - Fork 368
Commit
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.
Merge pull request #1376 from brefphp/ssm-secrets
- Loading branch information
Showing
7 changed files
with
299 additions
and
7 deletions.
There are no files selected for viewing
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,130 @@ | ||
<?php declare(strict_types=1); | ||
|
||
namespace Bref\Secrets; | ||
|
||
use AsyncAws\Ssm\SsmClient; | ||
use Closure; | ||
use RuntimeException; | ||
|
||
class Secrets | ||
{ | ||
/** | ||
* Decrypt environment variables that are encrypted with AWS SSM. | ||
* | ||
* @param SsmClient $ssmClient To allow mocking in tests. | ||
*/ | ||
public static function decryptSecretEnvironmentVariables(?SsmClient $ssmClient = null): void | ||
{ | ||
/** @var array<string,string>|string|false $envVars */ | ||
$envVars = getenv(local_only: true); // @phpstan-ignore-line PHPStan is wrong | ||
if (! is_array($envVars)) { | ||
return; | ||
} | ||
|
||
// Only consider environment variables that start with "bref-ssm:" | ||
$envVarsToDecrypt = array_filter($envVars, function (string $value): bool { | ||
return str_starts_with($value, 'bref-ssm:'); | ||
}); | ||
if (empty($envVarsToDecrypt)) { | ||
return; | ||
} | ||
|
||
// Extract the SSM parameter names by removing the "bref-ssm:" prefix | ||
$ssmNames = array_map(function (string $value): string { | ||
return substr($value, strlen('bref-ssm:')); | ||
}, $envVarsToDecrypt); | ||
|
||
$actuallyCalledSsm = false; | ||
$parameters = self::readParametersFromCacheOr(function () use ($ssmClient, $ssmNames, &$actuallyCalledSsm) { | ||
$actuallyCalledSsm = true; | ||
return self::retrieveParametersfromSsm($ssmClient, array_values($ssmNames)); | ||
}); | ||
|
||
foreach ($parameters as $parameterName => $parameterValue) { | ||
$envVar = array_search($parameterName, $ssmNames, true); | ||
$_SERVER[$envVar] = $_ENV[$envVar] = $parameterValue; | ||
putenv("$envVar=$parameterValue"); | ||
} | ||
|
||
// 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); | ||
} | ||
} | ||
|
||
/** | ||
* 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. | ||
* | ||
* @param Closure(): array<string, string> $paramResolver | ||
* @return array<string, string> Map of parameter name -> value | ||
*/ | ||
private static function readParametersFromCacheOr(Closure $paramResolver): array | ||
{ | ||
// Check in cache first | ||
$cacheFile = sys_get_temp_dir() . '/bref-ssm-parameters.php'; | ||
if (file_exists($cacheFile)) { | ||
$parameters = require $cacheFile; | ||
if (is_array($parameters)) { | ||
return $parameters; | ||
} | ||
} | ||
|
||
// Not in cache yet: we resolve it | ||
$parameters = $paramResolver(); | ||
|
||
// Use var_export() because it is faster than json_encode() | ||
file_put_contents($cacheFile, '<?php return ' . var_export($parameters, true) . ';'); | ||
|
||
return $parameters; | ||
} | ||
|
||
/** | ||
* @param string[] $ssmNames | ||
* @return array<string, string> Map of parameter name -> value | ||
*/ | ||
private static function retrieveParametersfromSsm(?SsmClient $ssmClient, array $ssmNames): array | ||
{ | ||
$ssm = $ssmClient ?? new SsmClient([ | ||
'region' => $_ENV['AWS_REGION'] ?? $_ENV['AWS_DEFAULT_REGION'], | ||
]); | ||
|
||
/** @var array<string, string> $parameters Map of parameter name -> value */ | ||
$parameters = []; | ||
$parametersNotFound = []; | ||
|
||
// The API only accepts up to 10 parameters at a time, so we batch the calls | ||
foreach (array_chunk($ssmNames, 10) as $batchOfSsmNames) { | ||
try { | ||
$result = $ssm->getParameters([ | ||
'Names' => $batchOfSsmNames, | ||
'WithDecryption' => true, | ||
]); | ||
foreach ($result->getParameters() as $parameter) { | ||
$parameters[$parameter->getName()] = $parameter->getValue(); | ||
} | ||
} 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, | ||
); | ||
} | ||
throw $e; | ||
} | ||
$parametersNotFound = array_merge($parametersNotFound, $result->getInvalidParameters()); | ||
} | ||
|
||
if (count($parametersNotFound) > 0) { | ||
throw new RuntimeException('The following SSM parameters could not be found: ' . implode(', ', $parametersNotFound)); | ||
} | ||
|
||
return $parameters; | ||
} | ||
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,93 @@ | ||
<?php declare(strict_types=1); | ||
|
||
namespace Bref\Test\Secrets; | ||
|
||
use AsyncAws\Core\Test\ResultMockFactory; | ||
use AsyncAws\Ssm\Result\GetParametersResult; | ||
use AsyncAws\Ssm\SsmClient; | ||
use AsyncAws\Ssm\ValueObject\Parameter; | ||
use Bref\Secrets\Secrets; | ||
use PHPUnit\Framework\TestCase; | ||
|
||
class SecretsTest extends TestCase | ||
{ | ||
public function setUp(): void | ||
{ | ||
if (file_exists(sys_get_temp_dir() . '/bref-ssm-parameters.php')) { | ||
unlink(sys_get_temp_dir() . '/bref-ssm-parameters.php'); | ||
} | ||
} | ||
|
||
public function test decrypts env variables(): void | ||
{ | ||
putenv('SOME_VARIABLE=bref-ssm:/some/parameter'); | ||
putenv('SOME_OTHER_VARIABLE=helloworld'); | ||
|
||
// Sanity checks | ||
$this->assertSame('bref-ssm:/some/parameter', getenv('SOME_VARIABLE')); | ||
$this->assertSame('helloworld', getenv('SOME_OTHER_VARIABLE')); | ||
|
||
Secrets::decryptSecretEnvironmentVariables($this->mockSsmClient()); | ||
|
||
$this->assertSame('foobar', getenv('SOME_VARIABLE')); | ||
$this->assertSame('foobar', $_SERVER['SOME_VARIABLE']); | ||
$this->assertSame('foobar', $_ENV['SOME_VARIABLE']); | ||
// Check that the other variable was not modified | ||
$this->assertSame('helloworld', getenv('SOME_OTHER_VARIABLE')); | ||
} | ||
|
||
public function test caches parameters to call SSM only once(): void | ||
{ | ||
putenv('SOME_VARIABLE=bref-ssm:/some/parameter'); | ||
|
||
// Call twice, the mock will assert that SSM was only called once | ||
$ssmClient = $this->mockSsmClient(); | ||
Secrets::decryptSecretEnvironmentVariables($ssmClient); | ||
Secrets::decryptSecretEnvironmentVariables($ssmClient); | ||
|
||
$this->assertSame('foobar', getenv('SOME_VARIABLE')); | ||
} | ||
|
||
public function test throws a clear error message on missing permissions(): void | ||
{ | ||
putenv('SOME_VARIABLE=bref-ssm:/app/test'); | ||
|
||
$ssmClient = $this->getMockBuilder(SsmClient::class) | ||
->disableOriginalConstructor() | ||
->getMock(); | ||
$result = ResultMockFactory::createFailing(GetParametersResult::class, 400, 'User: arn:aws:sts::123456:assumed-role/app-dev-us-east-1-lambdaRole/app-dev-hello is not authorized to perform: ssm:GetParameters on resource: arn:aws:ssm:us-east-1:123456:parameter/app/test because no identity-based policy allows the ssm:GetParameters action'); | ||
$ssmClient->method('getParameters') | ||
->willReturn($result); | ||
|
||
$expected = preg_quote("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:", '/'); | ||
$this->expectExceptionMessageMatches("/$expected .+/"); | ||
Secrets::decryptSecretEnvironmentVariables($ssmClient); | ||
} | ||
|
||
private function mockSsmClient(): SsmClient | ||
{ | ||
$ssmClient = $this->getMockBuilder(SsmClient::class) | ||
->disableOriginalConstructor() | ||
->onlyMethods(['getParameters']) | ||
->getMock(); | ||
|
||
$result = ResultMockFactory::create(GetParametersResult::class, [ | ||
'Parameters' => [ | ||
new Parameter([ | ||
'Name' => '/some/parameter', | ||
'Value' => 'foobar', | ||
]), | ||
], | ||
]); | ||
|
||
$ssmClient->expects($this->once()) | ||
->method('getParameters') | ||
->with([ | ||
'Names' => ['/some/parameter'], | ||
'WithDecryption' => true, | ||
]) | ||
->willReturn($result); | ||
|
||
return $ssmClient; | ||
} | ||
} |