Skip to content

Commit 5bd57aa

Browse files
Merge pull request #32 from NYPL/add-secrets-manager-client
Add SecretsManager client
2 parents 6168651 + c79753b commit 5bd57aa

7 files changed

Lines changed: 136 additions & 6 deletions

File tree

CHANGELOG.md

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,7 @@
11
# Changelog
2+
## v1.3.0 7/30/24
3+
- Added SecretsManager client
4+
25
## v1.2.1 7/25/24
36
- Add retry for fetching Avro schemas
47

README.md

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@ This package contains common Python utility classes and functions.
77
* Setting and retrieving a resource in S3
88
* Decrypting values with KMS
99
* Encoding and decoding records using a given Avro schema
10+
* Retrieving secrets from AWS Secrets Manager
1011
* Connecting to and querying a MySQL database
1112
* Connecting to and querying a PostgreSQL database
1213
* Connecting to and querying a PostgreSQL database using a connection pool
@@ -35,7 +36,7 @@ kinesis_client = KinesisClient(...)
3536
# Do not use any version below 1.0.0
3637
# All available optional dependencies can be found in pyproject.toml.
3738
# See the "Managing dependencies" section below for more details.
38-
nypl-py-utils[kinesis-client,config-helper]==1.1.2
39+
nypl-py-utils[kinesis-client,config-helper]==1.3.0
3940
```
4041

4142
## Developing locally

pyproject.toml

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
44

55
[project]
66
name = "nypl_py_utils"
7-
version = "1.2.0"
7+
version = "1.3.0"
88
authors = [
99
{ name="Aaron Friedman", email="aaronfriedman@nypl.org" },
1010
]
@@ -56,6 +56,10 @@ s3-client = [
5656
"boto3>=1.26.5",
5757
"botocore>=1.29.5"
5858
]
59+
secrets-manager-client = [
60+
"boto3>=1.26.5",
61+
"botocore>=1.29.5"
62+
]
5963
config-helper = [
6064
"nypl_py_utils[kms-client]",
6165
"PyYAML>=6.0"
@@ -67,7 +71,7 @@ research-catalog-identifier-helper = [
6771
"requests>=2.28.1"
6872
]
6973
development = [
70-
"nypl_py_utils[avro-client,kinesis-client,kms-client,mysql-client,oauth2-api-client,postgresql-client,postgresql-pool-client,redshift-client,s3-client,config-helper,obfuscation-helper,research-catalog-identifier-helper]",
74+
"nypl_py_utils[avro-client,kinesis-client,kms-client,mysql-client,oauth2-api-client,postgresql-client,postgresql-pool-client,redshift-client,s3-client,secrets-manager-client,config-helper,obfuscation-helper,research-catalog-identifier-helper]",
7175
"flake8>=6.0.0",
7276
"freezegun>=1.2.2",
7377
"mock>=4.0.3",
Lines changed: 67 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,67 @@
1+
import boto3
2+
import json
3+
import os
4+
5+
from botocore.exceptions import ClientError
6+
from nypl_py_utils.functions.log_helper import create_log
7+
8+
9+
class SecretsManagerClient:
10+
"""Client for interacting with AWS Secrets Manager"""
11+
12+
def __init__(self):
13+
self.logger = create_log('secrets_manager_client')
14+
15+
try:
16+
self.secrets_manager_client = boto3.client(
17+
'secretsmanager', region_name=os.environ.get('AWS_REGION',
18+
'us-east-1'))
19+
except ClientError as e:
20+
self.logger.error(
21+
'Could not create Secrets Manager client: {err}'.format(
22+
err=e))
23+
raise SecretsManagerClientError(
24+
'Could not create Secrets Manager client: {err}'.format(
25+
err=e)) from None
26+
27+
def close(self):
28+
self.secrets_manager_client.close()
29+
30+
def get_secret(self, secret_name, is_json=True):
31+
"""
32+
Retrieves secret with the given name from the Secrets Manager.
33+
34+
Parameters
35+
----------
36+
secret_name: str
37+
The name of the secret to retrieve
38+
is_json: bool, optional
39+
Whether the value of the secret is a JSON string that should be
40+
returned as a dictionary
41+
42+
Returns
43+
-------
44+
dict or str
45+
Dictionary if `is_json` is True; string if `is_json` is False
46+
"""
47+
self.logger.debug('Retrieving \'{}\' from Secrets Manager'.format(
48+
secret_name))
49+
try:
50+
response = self.secrets_manager_client.get_secret_value(
51+
SecretId=secret_name)
52+
if is_json:
53+
return json.loads(response['SecretString'])
54+
else:
55+
return response['SecretString']
56+
except ClientError as e:
57+
self.logger.error(
58+
('Could not retrieve \'{secret}\' from Secrets Manager: {err}')
59+
.format(secret=secret_name, err=e))
60+
raise SecretsManagerClientError(
61+
('Could not retrieve \'{secret}\' from Secrets Manager: {err}')
62+
.format(secret=secret_name, err=e)) from None
63+
64+
65+
class SecretsManagerClientError(Exception):
66+
def __init__(self, message=None):
67+
self.message = message

tests/test_kms_client.py

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -21,10 +21,10 @@ def test_instance(self, mocker):
2121

2222
def test_decrypt(self, test_instance):
2323
test_instance.kms_client.decrypt.return_value = _TEST_DECRYPTION
24-
assert test_instance.kms_client.decrypt.called_once_with(
25-
CiphertextBlob=b'test-encrypted-value')
2624
assert test_instance.decrypt(
2725
_TEST_ENCRYPTED_VALUE) == 'test-decrypted-value'
26+
test_instance.kms_client.decrypt.assert_called_once_with(
27+
CiphertextBlob=b'test-encrypted-value')
2828

2929
def test_base64_error(self, test_instance):
3030
with pytest.raises(KmsClientError):

tests/test_mysql_client.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -60,7 +60,7 @@ def test_execute_write_query_with_params(self, mock_mysql_conn,
6060
'test query %s %s', query_params=('a', 1)) is None
6161
mock_cursor.execute.assert_called_once_with('test query %s %s',
6262
('a', 1))
63-
test_instance.conn.commit.called_once()
63+
test_instance.conn.commit.assert_called_once()
6464
mock_cursor.close.assert_called_once()
6565

6666
def test_execute_query_with_exception(
Lines changed: 55 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,55 @@
1+
import pytest
2+
3+
from botocore.exceptions import ClientError
4+
from datetime import datetime
5+
from nypl_py_utils.classes.secrets_manager_client import (
6+
SecretsManagerClient, SecretsManagerClientError)
7+
8+
_TEST_RESPONSE = {
9+
'ARN': 'test_arn',
10+
'Name': 'test_secret',
11+
'VersionId': 'test_version',
12+
'SecretString': '{\n "key1": "value1",\n "key2": "value2"\n}',
13+
'VersionStages': ['AWSCURRENT'],
14+
'CreatedDate': datetime(2024, 1, 1, 1, 1, 1, 1),
15+
'ResponseMetadata': {
16+
'RequestId': 'test-request-id',
17+
'HTTPStatusCode': 200,
18+
'HTTPHeaders': {
19+
'x-amzn-requestid': 'test-request-id',
20+
'content-type': 'application/x-amz-json-1.1',
21+
'content-length': '155',
22+
'date': 'Mon, 1 Jan 2024 07:01:01 GMT'
23+
},
24+
'RetryAttempts': 0}
25+
}
26+
27+
28+
class TestSecretsManagerClient:
29+
30+
@pytest.fixture
31+
def test_instance(self, mocker):
32+
mocker.patch('boto3.client')
33+
return SecretsManagerClient()
34+
35+
def test_get_secret(self, test_instance):
36+
test_instance.secrets_manager_client.get_secret_value.return_value = \
37+
_TEST_RESPONSE
38+
assert test_instance.get_secret('test_secret') == {
39+
'key1': 'value1', 'key2': 'value2'}
40+
test_instance.secrets_manager_client.get_secret_value\
41+
.assert_called_once_with(SecretId='test_secret')
42+
43+
def test_get_secret_non_json(self, test_instance):
44+
test_instance.secrets_manager_client.get_secret_value.return_value = \
45+
_TEST_RESPONSE
46+
assert test_instance.get_secret('test_secret', is_json=False) == (
47+
'{\n "key1": "value1",\n "key2": "value2"\n}')
48+
test_instance.secrets_manager_client.get_secret_value\
49+
.assert_called_once_with(SecretId='test_secret')
50+
51+
def test_get_secret_error(self, test_instance):
52+
test_instance.secrets_manager_client.get_secret_value.side_effect = \
53+
ClientError({}, 'GetSecretValue')
54+
with pytest.raises(SecretsManagerClientError):
55+
test_instance.get_secret('test_secret')

0 commit comments

Comments
 (0)