diff --git a/src/Adapter/AWS/SSM/AWSSSMParameterStoreAdapter.php b/src/Adapter/AWS/SSM/AWSSSMParameterStoreAdapter.php new file mode 100644 index 0000000..0edb34d --- /dev/null +++ b/src/Adapter/AWS/SSM/AWSSSMParameterStoreAdapter.php @@ -0,0 +1,141 @@ + + * @date 2019 + * @license https://opensource.org/licenses/MIT + */ + +namespace Secretary\Adapter\AWS\SSM; + +use Aws\Ssm\Exception\SsmException; +use Aws\Ssm\SsmClient; +use Secretary\Adapter\AbstractAdapter; +use Secretary\Exception\SecretNotFoundException; +use Secretary\Secret; +use Symfony\Component\OptionsResolver\OptionsResolver; + +/** + * Class AWSSSMParameterStoreAdapter. + * + * @package Secretary\Adapter\AWS\SSM + */ +class AWSSSMParameterStoreAdapter extends AbstractAdapter +{ + private ?SsmClient $client = null; + + private array $config; + + /** + * @throws \Exception + */ + public function __construct(array $config) + { + if (!class_exists(SsmClient::class)) { + throw new \Exception('aws/aws-sdk-php is required to use the AWSSSMParameterStoreAdapter'); + } + + $this->config = $config; + } + + /** + * {@inheritdoc} + */ + public function getSecret(string $key, ?array $options = []): Secret + { + $options['Name'] = $key; + $options['WithDecryption'] = $options['WithDecryption'] ?? true; + + try { + $data = $this->getClient()->getParameter($options); + + /** @var string $value */ + $value = $data->search('Parameter.Value'); + + return new Secret( + $key, + static::isJson($value) ? json_decode($value, true) : $value + ); + } catch (SsmException $exception) { + if ($exception->getAwsErrorCode() === 'ParameterNotFound') { + throw new SecretNotFoundException($key, $exception); + } + + throw $exception; + } + } + + /** + * {@inheritdoc} + */ + public function putSecret(Secret $secret, ?array $options = []): Secret + { + $options['Type'] = $options['Type'] ?? 'SecureString'; + $options['Overwrite'] = $options['Overwrite'] ?? true; + $options['Value'] = is_array($secret->getValue()) + ? json_encode($secret->getValue()) : $secret->getValue(); + $options['Name'] = $secret->getKey(); + + $this->getClient()->putParameter($options); + + return $secret; + } + + /** + * {@inheritdoc} + */ + public function deleteSecretByKey(string $key, ?array $options = []): void + { + $options['Name'] = $key; + + $this->getClient()->deleteParameter($options); + } + + /** + * {@inheritdoc} + */ + public function deleteSecret(Secret $secret, ?array $options = []): void + { + $this->deleteSecretByKey($secret->getKey(), $options); + } + + public function configureGetSecretOptions(OptionsResolver $resolver): void + { + parent::configureSharedOptions($resolver); + $resolver->setDefined(['WithDecryption']) + ->setAllowedTypes('WithDecryption', 'bool'); + } + + public function configurePutSecretOptions(OptionsResolver $resolver): void + { + parent::configureSharedOptions($resolver); + $resolver->setDefined(['Type', 'KeyId', 'Tags', 'Description', 'Tier']) + ->setAllowedTypes('Type', 'string') + ->setAllowedTypes('KeyId', 'string') + ->setAllowedTypes('Description', 'string') + ->setAllowedTypes('Tier', 'string'); + } + + public function configureDeleteSecretOptions(OptionsResolver $resolver): void + { + parent::configureDeleteSecretOptions($resolver); + } + + private function getClient(): SsmClient + { + if (!$this->client instanceof SsmClient) { + $this->client = new SsmClient($this->config); + } + + return $this->client; + } + + private static function isJson(string $str): bool + { + $json = json_decode($str); + + return $json && $str != $json; + } +} diff --git a/src/Adapter/AWS/SSM/LICENSE b/src/Adapter/AWS/SSM/LICENSE new file mode 100644 index 0000000..903c15e --- /dev/null +++ b/src/Adapter/AWS/SSM/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2019 Aaron Scherer + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. \ No newline at end of file diff --git a/src/Adapter/AWS/SSM/README.md b/src/Adapter/AWS/SSM/README.md new file mode 100644 index 0000000..72627cf --- /dev/null +++ b/src/Adapter/AWS/SSM/README.md @@ -0,0 +1,22 @@ +# Secretary - AWS SSM Parameter Store Adapter + +AWS SSM Parameter Store Adapter for [Secretary](https://github.com/secretary/php) + +## Table of Contents + +1. [Installation](#installation) +2. [Options](#options) + +### Installation + +```bash +$ composer require secretary/core secretary/aws-ssm-parameter-store-adapter +``` + +### Options + +Client options (the `config` passed to the constructor) match the +[AWS PHP SDK client options](https://docs.aws.amazon.com/aws-sdk-php/v3/api/class-Aws.AwsClient.html#___construct). + +Secrets are written as `SecureString` parameters by default. Pass `Type => 'String'` (or a +custom `KeyId`) through the put options to change that. diff --git a/src/Adapter/AWS/SSM/Tests/AWSSSMParameterStoreAdapterTest.php b/src/Adapter/AWS/SSM/Tests/AWSSSMParameterStoreAdapterTest.php new file mode 100644 index 0000000..b64f523 --- /dev/null +++ b/src/Adapter/AWS/SSM/Tests/AWSSSMParameterStoreAdapterTest.php @@ -0,0 +1,130 @@ + + * @date 2019 + * @license https://opensource.org/licenses/MIT + */ + +namespace Secretary\Adapter\AWS\SSM\Tests; + +use Aws\Result; +use Aws\Ssm\Exception\SsmException; +use Aws\Ssm\SsmClient; +use Mockery; +use Mockery\Adapter\Phpunit\MockeryTestCase; +use Secretary\Adapter\AWS\SSM\AWSSSMParameterStoreAdapter; +use Secretary\Exception\SecretNotFoundException; +use Secretary\Secret; + +final class AWSSSMParameterStoreAdapterTest extends MockeryTestCase +{ + private function adapterWithClient(SsmClient $client): AWSSSMParameterStoreAdapter + { + $adapter = new AWSSSMParameterStoreAdapter([]); + + $ref = new \ReflectionObject($adapter); + $prop = $ref->getProperty('client'); + $prop->setAccessible(true); + $prop->setValue($adapter, $client); + + return $adapter; + } + + private function notFound(): SsmException + { + $exception = Mockery::mock(SsmException::class); + $exception->shouldReceive('getAwsErrorCode')->andReturn('ParameterNotFound'); + + return $exception; + } + + public function testGetSecretReturnsPlainString(): void + { + $client = Mockery::mock(SsmClient::class); + $client->shouldReceive('getParameter') + ->once() + ->with(['Name' => 'foo', 'WithDecryption' => true]) + ->andReturn(new Result(['Parameter' => ['Value' => 'bar']])); + + $secret = $this->adapterWithClient($client)->getSecret('foo'); + + $this->assertSame('foo', $secret->getKey()); + $this->assertSame('bar', $secret->getValue()); + } + + public function testGetSecretDecodesJson(): void + { + $client = Mockery::mock(SsmClient::class); + $client->shouldReceive('getParameter') + ->once() + ->with(['Name' => 'baz', 'WithDecryption' => true]) + ->andReturn(new Result(['Parameter' => ['Value' => json_encode(['foobar' => 'baz'])]])); + + $secret = $this->adapterWithClient($client)->getSecret('baz'); + + $this->assertSame(['foobar' => 'baz'], $secret->getValue()); + } + + public function testGetSecretThrowsWhenMissing(): void + { + $client = Mockery::mock(SsmClient::class); + $client->shouldReceive('getParameter') + ->once() + ->andThrow($this->notFound()); + + $this->expectException(SecretNotFoundException::class); + $this->adapterWithClient($client)->getSecret('bar'); + } + + public function testPutSecretWritesSecureStringByDefault(): void + { + $client = Mockery::mock(SsmClient::class); + $client->shouldReceive('putParameter') + ->once() + ->with([ + 'Type' => 'SecureString', + 'Overwrite' => true, + 'Value' => 'foobar', + 'Name' => 'test', + ]) + ->andReturn(new Result([])); + + $secret = new Secret('test', 'foobar'); + $result = $this->adapterWithClient($client)->putSecret($secret); + + $this->assertSame($secret->getKey(), $result->getKey()); + } + + public function testPutSecretEncodesArrayValue(): void + { + $client = Mockery::mock(SsmClient::class); + $client->shouldReceive('putParameter') + ->once() + ->with([ + 'Type' => 'SecureString', + 'Overwrite' => true, + 'Value' => json_encode(['a' => 'b']), + 'Name' => 'obj', + ]) + ->andReturn(new Result([])); + + $result = $this->adapterWithClient($client)->putSecret(new Secret('obj', ['a' => 'b'])); + + $this->assertSame('obj', $result->getKey()); + } + + public function testDeleteSecretCallsDeleteParameter(): void + { + $client = Mockery::mock(SsmClient::class); + $client->shouldReceive('deleteParameter') + ->once() + ->with(['Name' => 'test']) + ->andReturn(new Result([])); + + $this->adapterWithClient($client)->deleteSecret(new Secret('test', 'foobar')); + $this->assertTrue(true); + } +} diff --git a/src/Adapter/AWS/SSM/composer.json b/src/Adapter/AWS/SSM/composer.json new file mode 100644 index 0000000..4fc54fe --- /dev/null +++ b/src/Adapter/AWS/SSM/composer.json @@ -0,0 +1,39 @@ +{ + "name": "secretary/aws-ssm-parameter-store-adapter", + "description": "AWS SSM Parameter Store Adapter for Secretary", + "type": "library", + "license": "MIT", + "keywords": [ + "secrets", + "aws", + "aws ssm parameter store", + "secretary" + ], + "authors": [ + { + "name": "Aaron Scherer", + "email": "aequasi@gmail.com" + } + ], + "minimum-stability": "stable", + "require": { + "php": "^8.2", + "ext-json": "*", + "aws/aws-sdk-php": "^3.0", + "secretary/core": "self.version" + }, + "require-dev": { + "phpunit/phpunit": "^10.5 || ^11.0", + "mockery/mockery": "^1.6.12" + }, + "autoload": { + "psr-4": { + "Secretary\\Adapter\\AWS\\SSM\\": "" + } + }, + "autoload-dev": { + "psr-4": { + "Secretary\\Adapter\\AWS\\SSM\\Tests\\": "Tests/" + } + } +}