Skip to content

Commit

Permalink
Merge pull request #32 from NYPL/add-secrets-manager-client
Browse files Browse the repository at this point in the history
Add SecretsManager client
  • Loading branch information
aaronfriedman6 authored Jul 30, 2024
2 parents 6168651 + c79753b commit 5bd57aa
Showing 7 changed files with 136 additions and 6 deletions.
3 changes: 3 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,4 +1,7 @@
# Changelog
## v1.3.0 7/30/24
- Added SecretsManager client

## v1.2.1 7/25/24
- Add retry for fetching Avro schemas

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

## Developing locally
8 changes: 6 additions & 2 deletions pyproject.toml
Original file line number Diff line number Diff line change
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"

[project]
name = "nypl_py_utils"
version = "1.2.0"
version = "1.3.0"
authors = [
{ name="Aaron Friedman", email="aaronfriedman@nypl.org" },
]
@@ -56,6 +56,10 @@ s3-client = [
"boto3>=1.26.5",
"botocore>=1.29.5"
]
secrets-manager-client = [
"boto3>=1.26.5",
"botocore>=1.29.5"
]
config-helper = [
"nypl_py_utils[kms-client]",
"PyYAML>=6.0"
@@ -67,7 +71,7 @@ research-catalog-identifier-helper = [
"requests>=2.28.1"
]
development = [
"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]",
"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]",
"flake8>=6.0.0",
"freezegun>=1.2.2",
"mock>=4.0.3",
67 changes: 67 additions & 0 deletions src/nypl_py_utils/classes/secrets_manager_client.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,67 @@
import boto3
import json
import os

from botocore.exceptions import ClientError
from nypl_py_utils.functions.log_helper import create_log


class SecretsManagerClient:
"""Client for interacting with AWS Secrets Manager"""

def __init__(self):
self.logger = create_log('secrets_manager_client')

try:
self.secrets_manager_client = boto3.client(
'secretsmanager', region_name=os.environ.get('AWS_REGION',
'us-east-1'))
except ClientError as e:
self.logger.error(
'Could not create Secrets Manager client: {err}'.format(
err=e))
raise SecretsManagerClientError(
'Could not create Secrets Manager client: {err}'.format(
err=e)) from None

def close(self):
self.secrets_manager_client.close()

def get_secret(self, secret_name, is_json=True):
"""
Retrieves secret with the given name from the Secrets Manager.
Parameters
----------
secret_name: str
The name of the secret to retrieve
is_json: bool, optional
Whether the value of the secret is a JSON string that should be
returned as a dictionary
Returns
-------
dict or str
Dictionary if `is_json` is True; string if `is_json` is False
"""
self.logger.debug('Retrieving \'{}\' from Secrets Manager'.format(
secret_name))
try:
response = self.secrets_manager_client.get_secret_value(
SecretId=secret_name)
if is_json:
return json.loads(response['SecretString'])
else:
return response['SecretString']
except ClientError as e:
self.logger.error(
('Could not retrieve \'{secret}\' from Secrets Manager: {err}')
.format(secret=secret_name, err=e))
raise SecretsManagerClientError(
('Could not retrieve \'{secret}\' from Secrets Manager: {err}')
.format(secret=secret_name, err=e)) from None


class SecretsManagerClientError(Exception):
def __init__(self, message=None):
self.message = message
4 changes: 2 additions & 2 deletions tests/test_kms_client.py
Original file line number Diff line number Diff line change
@@ -21,10 +21,10 @@ def test_instance(self, mocker):

def test_decrypt(self, test_instance):
test_instance.kms_client.decrypt.return_value = _TEST_DECRYPTION
assert test_instance.kms_client.decrypt.called_once_with(
CiphertextBlob=b'test-encrypted-value')
assert test_instance.decrypt(
_TEST_ENCRYPTED_VALUE) == 'test-decrypted-value'
test_instance.kms_client.decrypt.assert_called_once_with(
CiphertextBlob=b'test-encrypted-value')

def test_base64_error(self, test_instance):
with pytest.raises(KmsClientError):
2 changes: 1 addition & 1 deletion tests/test_mysql_client.py
Original file line number Diff line number Diff line change
@@ -60,7 +60,7 @@ def test_execute_write_query_with_params(self, mock_mysql_conn,
'test query %s %s', query_params=('a', 1)) is None
mock_cursor.execute.assert_called_once_with('test query %s %s',
('a', 1))
test_instance.conn.commit.called_once()
test_instance.conn.commit.assert_called_once()
mock_cursor.close.assert_called_once()

def test_execute_query_with_exception(
55 changes: 55 additions & 0 deletions tests/test_secrets_manager_client.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,55 @@
import pytest

from botocore.exceptions import ClientError
from datetime import datetime
from nypl_py_utils.classes.secrets_manager_client import (
SecretsManagerClient, SecretsManagerClientError)

_TEST_RESPONSE = {
'ARN': 'test_arn',
'Name': 'test_secret',
'VersionId': 'test_version',
'SecretString': '{\n "key1": "value1",\n "key2": "value2"\n}',
'VersionStages': ['AWSCURRENT'],
'CreatedDate': datetime(2024, 1, 1, 1, 1, 1, 1),
'ResponseMetadata': {
'RequestId': 'test-request-id',
'HTTPStatusCode': 200,
'HTTPHeaders': {
'x-amzn-requestid': 'test-request-id',
'content-type': 'application/x-amz-json-1.1',
'content-length': '155',
'date': 'Mon, 1 Jan 2024 07:01:01 GMT'
},
'RetryAttempts': 0}
}


class TestSecretsManagerClient:

@pytest.fixture
def test_instance(self, mocker):
mocker.patch('boto3.client')
return SecretsManagerClient()

def test_get_secret(self, test_instance):
test_instance.secrets_manager_client.get_secret_value.return_value = \
_TEST_RESPONSE
assert test_instance.get_secret('test_secret') == {
'key1': 'value1', 'key2': 'value2'}
test_instance.secrets_manager_client.get_secret_value\
.assert_called_once_with(SecretId='test_secret')

def test_get_secret_non_json(self, test_instance):
test_instance.secrets_manager_client.get_secret_value.return_value = \
_TEST_RESPONSE
assert test_instance.get_secret('test_secret', is_json=False) == (
'{\n "key1": "value1",\n "key2": "value2"\n}')
test_instance.secrets_manager_client.get_secret_value\
.assert_called_once_with(SecretId='test_secret')

def test_get_secret_error(self, test_instance):
test_instance.secrets_manager_client.get_secret_value.side_effect = \
ClientError({}, 'GetSecretValue')
with pytest.raises(SecretsManagerClientError):
test_instance.get_secret('test_secret')

0 comments on commit 5bd57aa

Please sign in to comment.