Skip to content

Commit 2c3342c

Browse files
authored
Merge pull request #23 from secretary/feat/aws-ssm-parameter-store-adapter
feat: add AWS SSM Parameter Store adapter
2 parents 4823b16 + cc25a0c commit 2c3342c

5 files changed

Lines changed: 353 additions & 0 deletions

File tree

Lines changed: 141 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,141 @@
1+
<?php
2+
3+
declare(strict_types=1);
4+
5+
/*
6+
* @author Aaron Scherer <aequasi@gmail.com>
7+
* @date 2019
8+
* @license https://opensource.org/licenses/MIT
9+
*/
10+
11+
namespace Secretary\Adapter\AWS\SSM;
12+
13+
use Aws\Ssm\Exception\SsmException;
14+
use Aws\Ssm\SsmClient;
15+
use Secretary\Adapter\AbstractAdapter;
16+
use Secretary\Exception\SecretNotFoundException;
17+
use Secretary\Secret;
18+
use Symfony\Component\OptionsResolver\OptionsResolver;
19+
20+
/**
21+
* Class AWSSSMParameterStoreAdapter.
22+
*
23+
* @package Secretary\Adapter\AWS\SSM
24+
*/
25+
class AWSSSMParameterStoreAdapter extends AbstractAdapter
26+
{
27+
private ?SsmClient $client = null;
28+
29+
private array $config;
30+
31+
/**
32+
* @throws \Exception
33+
*/
34+
public function __construct(array $config)
35+
{
36+
if (!class_exists(SsmClient::class)) {
37+
throw new \Exception('aws/aws-sdk-php is required to use the AWSSSMParameterStoreAdapter');
38+
}
39+
40+
$this->config = $config;
41+
}
42+
43+
/**
44+
* {@inheritdoc}
45+
*/
46+
public function getSecret(string $key, ?array $options = []): Secret
47+
{
48+
$options['Name'] = $key;
49+
$options['WithDecryption'] = $options['WithDecryption'] ?? true;
50+
51+
try {
52+
$data = $this->getClient()->getParameter($options);
53+
54+
/** @var string $value */
55+
$value = $data->search('Parameter.Value');
56+
57+
return new Secret(
58+
$key,
59+
static::isJson($value) ? json_decode($value, true) : $value
60+
);
61+
} catch (SsmException $exception) {
62+
if ($exception->getAwsErrorCode() === 'ParameterNotFound') {
63+
throw new SecretNotFoundException($key, $exception);
64+
}
65+
66+
throw $exception;
67+
}
68+
}
69+
70+
/**
71+
* {@inheritdoc}
72+
*/
73+
public function putSecret(Secret $secret, ?array $options = []): Secret
74+
{
75+
$options['Type'] = $options['Type'] ?? 'SecureString';
76+
$options['Overwrite'] = $options['Overwrite'] ?? true;
77+
$options['Value'] = is_array($secret->getValue())
78+
? json_encode($secret->getValue()) : $secret->getValue();
79+
$options['Name'] = $secret->getKey();
80+
81+
$this->getClient()->putParameter($options);
82+
83+
return $secret;
84+
}
85+
86+
/**
87+
* {@inheritdoc}
88+
*/
89+
public function deleteSecretByKey(string $key, ?array $options = []): void
90+
{
91+
$options['Name'] = $key;
92+
93+
$this->getClient()->deleteParameter($options);
94+
}
95+
96+
/**
97+
* {@inheritdoc}
98+
*/
99+
public function deleteSecret(Secret $secret, ?array $options = []): void
100+
{
101+
$this->deleteSecretByKey($secret->getKey(), $options);
102+
}
103+
104+
public function configureGetSecretOptions(OptionsResolver $resolver): void
105+
{
106+
parent::configureSharedOptions($resolver);
107+
$resolver->setDefined(['WithDecryption'])
108+
->setAllowedTypes('WithDecryption', 'bool');
109+
}
110+
111+
public function configurePutSecretOptions(OptionsResolver $resolver): void
112+
{
113+
parent::configureSharedOptions($resolver);
114+
$resolver->setDefined(['Type', 'KeyId', 'Tags', 'Description', 'Tier'])
115+
->setAllowedTypes('Type', 'string')
116+
->setAllowedTypes('KeyId', 'string')
117+
->setAllowedTypes('Description', 'string')
118+
->setAllowedTypes('Tier', 'string');
119+
}
120+
121+
public function configureDeleteSecretOptions(OptionsResolver $resolver): void
122+
{
123+
parent::configureDeleteSecretOptions($resolver);
124+
}
125+
126+
private function getClient(): SsmClient
127+
{
128+
if (!$this->client instanceof SsmClient) {
129+
$this->client = new SsmClient($this->config);
130+
}
131+
132+
return $this->client;
133+
}
134+
135+
private static function isJson(string $str): bool
136+
{
137+
$json = json_decode($str);
138+
139+
return $json && $str != $json;
140+
}
141+
}

src/Adapter/AWS/SSM/LICENSE

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,21 @@
1+
MIT License
2+
3+
Copyright (c) 2019 Aaron Scherer
4+
5+
Permission is hereby granted, free of charge, to any person obtaining a copy
6+
of this software and associated documentation files (the "Software"), to deal
7+
in the Software without restriction, including without limitation the rights
8+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9+
copies of the Software, and to permit persons to whom the Software is
10+
furnished to do so, subject to the following conditions:
11+
12+
The above copyright notice and this permission notice shall be included in all
13+
copies or substantial portions of the Software.
14+
15+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21+
SOFTWARE.

src/Adapter/AWS/SSM/README.md

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,22 @@
1+
# Secretary - AWS SSM Parameter Store Adapter
2+
3+
AWS SSM Parameter Store Adapter for [Secretary](https://github.com/secretary/php)
4+
5+
## Table of Contents
6+
7+
1. [Installation](#installation)
8+
2. [Options](#options)
9+
10+
### Installation
11+
12+
```bash
13+
$ composer require secretary/core secretary/aws-ssm-parameter-store-adapter
14+
```
15+
16+
### Options
17+
18+
Client options (the `config` passed to the constructor) match the
19+
[AWS PHP SDK client options](https://docs.aws.amazon.com/aws-sdk-php/v3/api/class-Aws.AwsClient.html#___construct).
20+
21+
Secrets are written as `SecureString` parameters by default. Pass `Type => 'String'` (or a
22+
custom `KeyId`) through the put options to change that.
Lines changed: 130 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,130 @@
1+
<?php
2+
3+
declare(strict_types=1);
4+
5+
/*
6+
* @author Aaron Scherer <aequasi@gmail.com>
7+
* @date 2019
8+
* @license https://opensource.org/licenses/MIT
9+
*/
10+
11+
namespace Secretary\Adapter\AWS\SSM\Tests;
12+
13+
use Aws\Result;
14+
use Aws\Ssm\Exception\SsmException;
15+
use Aws\Ssm\SsmClient;
16+
use Mockery;
17+
use Mockery\Adapter\Phpunit\MockeryTestCase;
18+
use Secretary\Adapter\AWS\SSM\AWSSSMParameterStoreAdapter;
19+
use Secretary\Exception\SecretNotFoundException;
20+
use Secretary\Secret;
21+
22+
final class AWSSSMParameterStoreAdapterTest extends MockeryTestCase
23+
{
24+
private function adapterWithClient(SsmClient $client): AWSSSMParameterStoreAdapter
25+
{
26+
$adapter = new AWSSSMParameterStoreAdapter([]);
27+
28+
$ref = new \ReflectionObject($adapter);
29+
$prop = $ref->getProperty('client');
30+
$prop->setAccessible(true);
31+
$prop->setValue($adapter, $client);
32+
33+
return $adapter;
34+
}
35+
36+
private function notFound(): SsmException
37+
{
38+
$exception = Mockery::mock(SsmException::class);
39+
$exception->shouldReceive('getAwsErrorCode')->andReturn('ParameterNotFound');
40+
41+
return $exception;
42+
}
43+
44+
public function testGetSecretReturnsPlainString(): void
45+
{
46+
$client = Mockery::mock(SsmClient::class);
47+
$client->shouldReceive('getParameter')
48+
->once()
49+
->with(['Name' => 'foo', 'WithDecryption' => true])
50+
->andReturn(new Result(['Parameter' => ['Value' => 'bar']]));
51+
52+
$secret = $this->adapterWithClient($client)->getSecret('foo');
53+
54+
$this->assertSame('foo', $secret->getKey());
55+
$this->assertSame('bar', $secret->getValue());
56+
}
57+
58+
public function testGetSecretDecodesJson(): void
59+
{
60+
$client = Mockery::mock(SsmClient::class);
61+
$client->shouldReceive('getParameter')
62+
->once()
63+
->with(['Name' => 'baz', 'WithDecryption' => true])
64+
->andReturn(new Result(['Parameter' => ['Value' => json_encode(['foobar' => 'baz'])]]));
65+
66+
$secret = $this->adapterWithClient($client)->getSecret('baz');
67+
68+
$this->assertSame(['foobar' => 'baz'], $secret->getValue());
69+
}
70+
71+
public function testGetSecretThrowsWhenMissing(): void
72+
{
73+
$client = Mockery::mock(SsmClient::class);
74+
$client->shouldReceive('getParameter')
75+
->once()
76+
->andThrow($this->notFound());
77+
78+
$this->expectException(SecretNotFoundException::class);
79+
$this->adapterWithClient($client)->getSecret('bar');
80+
}
81+
82+
public function testPutSecretWritesSecureStringByDefault(): void
83+
{
84+
$client = Mockery::mock(SsmClient::class);
85+
$client->shouldReceive('putParameter')
86+
->once()
87+
->with([
88+
'Type' => 'SecureString',
89+
'Overwrite' => true,
90+
'Value' => 'foobar',
91+
'Name' => 'test',
92+
])
93+
->andReturn(new Result([]));
94+
95+
$secret = new Secret('test', 'foobar');
96+
$result = $this->adapterWithClient($client)->putSecret($secret);
97+
98+
$this->assertSame($secret->getKey(), $result->getKey());
99+
}
100+
101+
public function testPutSecretEncodesArrayValue(): void
102+
{
103+
$client = Mockery::mock(SsmClient::class);
104+
$client->shouldReceive('putParameter')
105+
->once()
106+
->with([
107+
'Type' => 'SecureString',
108+
'Overwrite' => true,
109+
'Value' => json_encode(['a' => 'b']),
110+
'Name' => 'obj',
111+
])
112+
->andReturn(new Result([]));
113+
114+
$result = $this->adapterWithClient($client)->putSecret(new Secret('obj', ['a' => 'b']));
115+
116+
$this->assertSame('obj', $result->getKey());
117+
}
118+
119+
public function testDeleteSecretCallsDeleteParameter(): void
120+
{
121+
$client = Mockery::mock(SsmClient::class);
122+
$client->shouldReceive('deleteParameter')
123+
->once()
124+
->with(['Name' => 'test'])
125+
->andReturn(new Result([]));
126+
127+
$this->adapterWithClient($client)->deleteSecret(new Secret('test', 'foobar'));
128+
$this->assertTrue(true);
129+
}
130+
}

src/Adapter/AWS/SSM/composer.json

Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,39 @@
1+
{
2+
"name": "secretary/aws-ssm-parameter-store-adapter",
3+
"description": "AWS SSM Parameter Store Adapter for Secretary",
4+
"type": "library",
5+
"license": "MIT",
6+
"keywords": [
7+
"secrets",
8+
"aws",
9+
"aws ssm parameter store",
10+
"secretary"
11+
],
12+
"authors": [
13+
{
14+
"name": "Aaron Scherer",
15+
"email": "aequasi@gmail.com"
16+
}
17+
],
18+
"minimum-stability": "stable",
19+
"require": {
20+
"php": "^8.2",
21+
"ext-json": "*",
22+
"aws/aws-sdk-php": "^3.0",
23+
"secretary/core": "self.version"
24+
},
25+
"require-dev": {
26+
"phpunit/phpunit": "^10.5 || ^11.0",
27+
"mockery/mockery": "^1.6.12"
28+
},
29+
"autoload": {
30+
"psr-4": {
31+
"Secretary\\Adapter\\AWS\\SSM\\": ""
32+
}
33+
},
34+
"autoload-dev": {
35+
"psr-4": {
36+
"Secretary\\Adapter\\AWS\\SSM\\Tests\\": "Tests/"
37+
}
38+
}
39+
}

0 commit comments

Comments
 (0)