Skip to content

Commit

Permalink
Merge pull request #1376 from brefphp/ssm-secrets
Browse files Browse the repository at this point in the history
  • Loading branch information
mnapoli authored Jan 27, 2023
2 parents 948b41f + 920b4d3 commit a606631
Show file tree
Hide file tree
Showing 7 changed files with 299 additions and 7 deletions.
3 changes: 2 additions & 1 deletion composer.json
Original file line number Diff line number Diff line change
Expand Up @@ -29,7 +29,8 @@
"riverline/multipart-parser": "^2.0.6",
"psr/http-server-handler": "^1.0",
"nyholm/psr7": "^1.2",
"psr/container": "^1.0|^2.0"
"psr/container": "^1.0|^2.0",
"async-aws/ssm": "^1.3"
},
"require-dev": {
"aws/aws-sdk-php": "^3.172",
Expand Down
Binary file added docs/environment/variables-create-secret.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
74 changes: 68 additions & 6 deletions docs/environment/variables.md
Original file line number Diff line number Diff line change
Expand Up @@ -33,11 +33,15 @@ functions:

## Secrets

Secrets (API tokens, database passwords, etc.) should not be defined in `serverless.yml` and committed into your git repository.
Secrets (API tokens, database passwords, etc.) should not be defined in `serverless.yml` or committed into your git repository.

Instead you can use the [SSM parameter store](https://docs.aws.amazon.com/systems-manager/latest/userguide/systems-manager-paramstore.html), a free service provided by AWS.
Instead, you can use the [SSM parameter store](https://docs.aws.amazon.com/systems-manager/latest/userguide/systems-manager-paramstore.html), a free service provided by AWS.

To create a parameter, you can do it via the [AWS SSM console](https://console.aws.amazon.com/systems-manager/parameters) or the [Bref Dashboard](https://dashboard.bref.sh/).
### Creating secrets

To create a parameter, you can do it via the [AWS SSM console](https://console.aws.amazon.com/systems-manager/parameters) or the [Bref Dashboard](https://dashboard.bref.sh/):

![](./variables-create-secret.png)

You can also do it in the CLI via the following command:

Expand All @@ -52,23 +56,81 @@ aws ssm put-parameter --region us-east-1 --name '//my-app\my-parameter' --type S

It is recommended to prefix the parameter name with your application name, for example: `/my-app/my-parameter`.

To import the SSM parameter into an environment variable you can use the [`${ssm:<parameter>}` syntax](https://serverless.com/blog/serverless-secrets-api-keys/):
### Retrieving secrets

You can inject a secret in an environment variable:

- either at **deployment time** (simplest)
- or at **runtime** (more secure)

#### At deployment time

Use the [`${ssm:<parameter>}` syntax](https://serverless.com/blog/serverless-secrets-api-keys/) to have the variable be replaced by the secret value on deployment:

```yaml
provider:
# ...
environment:
MY_PARAMETER: ${ssm:/my-app/my-parameter}
# If you need to set a different value per stage:
OTHER_PARAMETER: ${ssm:/my-app/${sls:stage}/my-parameter}
```

Advantages:

- Simpler, it just works.

Disadvantages:

- The user deploying must be allowed to retrieve the secret value.
- The secret value will be set in clear text in the Lambda function configuration (anyone who can access the function can also view the value).

#### At runtime

Alternatively, Bref can fetch the secret values at runtime when the Lambda function starts (aka the "cold start").

To do so, the environment variable should contain the path to the SSM parameter prefixed with `bref-ssm:`. We also need to authorize Lambda to retrieve the parameter. For example:

```yaml
provider:
# ...
environment:
MY_PARAMETER: bref-ssm:/my-app/my-parameter
iam:
role:
statements:
# Allow our Lambda functions to retrieve the parameter from SSM
- Effect: Allow
Action: ssm:GetParameters
Resource: 'arn:aws:ssm:${aws:region}:${aws:accountId}:parameter/my-app/my-parameter'
# If you want to be more generic you can uncomment the line below instead.
# But it authorizes retrieving *any* SSM parameter, which is less secure.
#Resource: '*'
```

On a cold start, Bref automatically checks all environment variables that start with `bref-ssm:` and will resolve the values by calling the AWS SSM API. It adds a very small latency overhead for the first request (note: all SSM values are fetched in a single API call).

Advantages:

- The value doesn't have to be accessible by the user deploying.
- The value is not stored in plain text in the AWS console.

Disadvantages:

- More complex configuration.
- Small added latency to cold starts.

### An alternative: AWS Secrets Manager

As an alternative you can also store secrets in [AWS Secrets Manager](https://aws.amazon.com/secrets-manager/). This solution, while very similar to SSM, will provide:
As an alternative, you can store secrets in [AWS Secrets Manager](https://aws.amazon.com/secrets-manager/). This solution, while very similar to SSM, will provide:

- better permission management using IAM
- JSON values, allowing to store multiple values in one parameter

However Secrets Manager is not free: [pricing details](https://aws.amazon.com/secrets-manager/pricing/).
However, Secrets Manager is not free: [pricing details](https://aws.amazon.com/secrets-manager/pricing/).

SSM is good enough for most projects.

## Local development

Expand Down
3 changes: 3 additions & 0 deletions src/ConsoleRuntime/Main.php
Original file line number Diff line number Diff line change
Expand Up @@ -4,13 +4,16 @@

use Bref\Context\Context;
use Bref\Runtime\LambdaRuntime;
use Bref\Secrets\Secrets;
use Exception;
use Symfony\Component\Process\Process;

class Main
{
public static function run(): void
{
Secrets::decryptSecretEnvironmentVariables();

$lambdaRuntime = LambdaRuntime::fromEnvironmentVariable('console');

$appRoot = getenv('LAMBDA_TASK_ROOT');
Expand Down
3 changes: 3 additions & 0 deletions src/FunctionRuntime/Main.php
Original file line number Diff line number Diff line change
Expand Up @@ -4,12 +4,15 @@

use Bref\Bref;
use Bref\Runtime\LambdaRuntime;
use Bref\Secrets\Secrets;
use Throwable;

class Main
{
public static function run(): void
{
Secrets::decryptSecretEnvironmentVariables();

$lambdaRuntime = LambdaRuntime::fromEnvironmentVariable('function');

$container = Bref::getContainer();
Expand Down
130 changes: 130 additions & 0 deletions src/Secrets/Secrets.php
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;
}
}
93 changes: 93 additions & 0 deletions tests/Secrets/SecretsTest.php
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;
}
}

0 comments on commit a606631

Please sign in to comment.