Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Add support for storing OAuth2.0 tokens in AWS Secrets Manager #114

Merged
merged 33 commits into from
Mar 9, 2023
Merged
Show file tree
Hide file tree
Changes from 14 commits
Commits
Show all changes
33 commits
Select commit Hold shift + click to select a range
f69c11f
Add feature to store OAuth tokens in AWS Secrets
michaelstepner Dec 5, 2022
c970040
Bugfix: deep copy of configparser before save()
michaelstepner Dec 5, 2022
6d791ff
Merge branch 'main' into aws-secrets
michaelstepner Dec 5, 2022
5cf666c
Merge branch 'main' into aws-secrets
michaelstepner Dec 17, 2022
b13be09
Add line breaks to README to match upstream format
michaelstepner Dec 17, 2022
024383d
Create AWS secret if it does not already exist
michaelstepner Dec 19, 2022
a3d8c10
Refactor save procedure for AWS secrets
michaelstepner Dec 19, 2022
2c37d87
Handle case where secret exists but has no value
michaelstepner Dec 19, 2022
39e6751
Use full package name for botocore.exceptions
michaelstepner Dec 22, 2022
e0b0925
Bugfix and refactor from simonrob PR feedback
michaelstepner Dec 22, 2022
21f6c71
Apply code review suggestions re requirements & help
michaelstepner Dec 24, 2022
5b7d63b
Refactor AWS Secrets in AppConfig; remove CLI argument
michaelstepner Dec 30, 2022
2c9c6be
Gracefully handle boto3 errors
michaelstepner Dec 31, 2022
0ca992a
Merge branch 'main' into aws-secrets
michaelstepner Dec 31, 2022
52d7d77
Add global list of keys that could be stored in a secrets manager
simonrob Jan 4, 2023
b244754
Stylistic changes to requirements-aws-secrets.txt
michaelstepner Jan 5, 2023
931cf2c
Apply @simonrob suggestions from 2nd code review
michaelstepner Jan 5, 2023
e0e5173
Add missing underscores to _aws_secrets methods
michaelstepner Jan 5, 2023
708a229
Simplify AWS client debug logging
simonrob Jan 5, 2023
e431325
Removing stale TODO comment
simonrob Jan 5, 2023
21beeab
Consistency in comment indentation
simonrob Jan 5, 2023
e3ced21
Log message case consistency
simonrob Jan 5, 2023
15f049c
Log boto3 error code and message to debug log
michaelstepner Jan 9, 2023
e11338d
Apply @simonrob's suggestions to handle missing keys
michaelstepner Jan 10, 2023
2e6b641
Refactor secrets manager approach for easier generalisability
simonrob Jan 23, 2023
3eb12fe
Merge branch 'main' into aws-secrets
simonrob Jan 30, 2023
ac1d111
Move caching into a separate class for easier future extension
simonrob Jan 30, 2023
7417977
Create secret on both load and save to try to fail early
simonrob Jan 31, 2023
cd53191
Merge branch 'main' into aws-secrets
simonrob Feb 9, 2023
66e0595
Merge branch 'main' into aws-secrets
simonrob Feb 20, 2023
714e969
Merge branch 'main' into aws-secrets
simonrob Mar 4, 2023
b6020fb
Allow AWS profile to be selected by prefixing the ARN/name
simonrob Mar 8, 2023
f727371
Merge branch 'main' into aws-secrets
simonrob Mar 8, 2023
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
138 changes: 137 additions & 1 deletion emailproxy.py
Original file line number Diff line number Diff line change
Expand Up @@ -292,6 +292,10 @@ def _load():
AppConfig._GLOBALS = configparser.SectionProxy(AppConfig._PARSER, APP_SHORT_NAME)
AppConfig._SERVERS = [s for s in config_sections if CONFIG_SERVER_MATCHER.match(s)]
AppConfig._ACCOUNTS = [s for s in config_sections if '@' in s]

if AppConfig.aws_secrets_accounts():
AppConfig.aws_secrets_fetch()

AppConfig._LOADED = True

@staticmethod
Expand Down Expand Up @@ -337,9 +341,141 @@ def add_account(username):
@staticmethod
def save():
if AppConfig._LOADED:
if AppConfig.aws_secrets_accounts():
config_to_write = AppConfig.aws_secrets_save()
michaelstepner marked this conversation as resolved.
Show resolved Hide resolved
else:
config_to_write = AppConfig._PARSER

with open(CONFIG_FILE_PATH, mode='w', encoding='utf-8') as config_output:
AppConfig._PARSER.write(config_output)
config_to_write.write(config_output)

@staticmethod
def aws_secrets_accounts():
michaelstepner marked this conversation as resolved.
Show resolved Hide resolved
return [a for a in AppConfig._ACCOUNTS if 'aws_secret' in AppConfig._PARSER[a]]
michaelstepner marked this conversation as resolved.
Show resolved Hide resolved

@staticmethod
def aws_secrets_ids():
michaelstepner marked this conversation as resolved.
Show resolved Hide resolved
return dict.fromkeys([AppConfig._PARSER[account]['aws_secret'] for account in AppConfig.aws_secrets_accounts()], {})
michaelstepner marked this conversation as resolved.
Show resolved Hide resolved

@staticmethod
def aws_secrets_boto3client():
try:
# Load dependencies and create client
global boto3
import boto3
global botocore
import botocore.exceptions
aws_client = boto3.client('secretsmanager')
except Exception as e:
Log.error('Failed to load AWS SDK - "aws_secret" is specified in config file for some accounts, have you installed requirements-aws-secrets.txt?')
Log.debug(Log.error_string(e))
return
else:
return aws_client
michaelstepner marked this conversation as resolved.
Show resolved Hide resolved

@staticmethod
def aws_secrets_debuglog_boto3error(err):
michaelstepner marked this conversation as resolved.
Show resolved Hide resolved
Log.debug(err.response.get("Error", {}).get("Code") + ":",
err.response.get("Error", {}).get("Message"))
michaelstepner marked this conversation as resolved.
Show resolved Hide resolved
simonrob marked this conversation as resolved.
Show resolved Hide resolved

@staticmethod
def aws_secrets_fetch():
aws_client = AppConfig.aws_secrets_boto3client()
if aws_client:
# Create dict of AWS Secret IDs across all accounts
aws_secrets = AppConfig.aws_secrets_ids()

# Download AWS Secrets
for secret_id in aws_secrets:
try:
get_secret_value_response = aws_client.get_secret_value(SecretId=secret_id)
except botocore.exceptions.ClientError as err_getsecret:
if err_getsecret.response['Error']['Code'] == 'ResourceNotFoundException':
Log.info('Fetching AWS Secret "%s" - secret does not exist or does not contain a secret value' % (secret_id))
elif err_getsecret.response['Error']['Code'] == 'AccessDeniedException':
Log.error('Fetching AWS Secret "%s" - access denied, does IAM user have "secretsmanager:GetSecretValue" permissions?' % (secret_id))
else:
Log.error('Fetching AWS Secret "%s" - unexpected error, see debug logs for details' % (secret_id))
AppConfig.aws_secrets_debuglog_boto3error(err_getsecret)
else:
aws_secrets[secret_id] = json.loads(get_secret_value_response['SecretString'])

# Update local config in memory
for account in AppConfig.aws_secrets_accounts():
if account in aws_secrets[AppConfig._PARSER[account]['aws_secret']]:
for key in ['token_salt','access_token','access_token_expiry','refresh_token']:
AppConfig._PARSER.set(account, key, aws_secrets[AppConfig._PARSER[account]['aws_secret']][account][key])
else:
Log.error('Failed to fetch OAuth 2.0 tokens from AWS Secrets Manager')
michaelstepner marked this conversation as resolved.
Show resolved Hide resolved

@staticmethod
def aws_secrets_save():
# Create deep copy of config, which will not contain OAuth 2.0 tokens
appconfig_to_save = configparser.ConfigParser()
appconfig_to_save.read_dict(AppConfig._PARSER)

# Create dict of AWS Secret IDs across all accounts
aws_secrets = AppConfig.aws_secrets_ids()

# Populate dict with OAuth 2.0 tokens from each account, removing those tokens from config to be saved
TOKEN_KEYS = ['token_salt','access_token','access_token_expiry','refresh_token']
for account in AppConfig.aws_secrets_accounts():
aws_secrets[appconfig_to_save[account]['aws_secret']][account] = { key : appconfig_to_save[account][key] for key in TOKEN_KEYS }
for key in TOKEN_KEYS:
appconfig_to_save.remove_option(account, key)
michaelstepner marked this conversation as resolved.
Show resolved Hide resolved

# Update AWS Secrets
aws_client = AppConfig.aws_secrets_boto3client()
if aws_client:
for secret_id in aws_secrets:
try:
aws_client.put_secret_value(
SecretId=secret_id,
SecretString=json.dumps(aws_secrets[secret_id]),
)
except botocore.exceptions.ClientError as err_putsecret:
if err_putsecret.response['Error']['Code'] == 'ResourceNotFoundException':
if secret_id.startswith('arn:'):
Log.error('Failed to store OAuth 2.0 tokens in AWS Secret "%s" - secret does not exist, cannot create a secret with a specific ARN' % (secret_id))
AppConfig.aws_secrets_debuglog_boto3error(err_putsecret)
else:
Log.error('AWS Secret "%s" does not exist - attempting to create it' % (secret_id))
AppConfig.aws_secrets_debuglog_boto3error(err_putsecret)
try:
aws_client.create_secret(
Name=secret_id,
ForceOverwriteReplicaSecret=False
)
except botocore.exceptions.ClientError as err_createsecret:
if err_createsecret.response['Error']['Code'] == 'AccessDeniedException':
Log.error('Failed to store OAuth 2.0 tokens in AWS Secret "%s" - access denied in attempt to create it. does your IAM user have "secretsmanager:CreateSecret" permissions?' % (secret_id))
else:
Log.error('Failed to store OAuth 2.0 tokens in AWS Secret "%s" - attempt to create it failed with unexpected error, see debug logs for details')
AppConfig.aws_secrets_debuglog_boto3error(err_createsecret)
else:
Log.info('Created AWS Secret "%s"' % (secret_id))
try:
aws_client.put_secret_value(
SecretId=secret_id,
SecretString=json.dumps(aws_secrets[secret_id]),
)
except botocore.exceptions.ClientError as err_putsecret_aftercreate:
if err_putsecret_aftercreate.response['Error']['Code'] == 'AccessDeniedException':
Log.error('Failed to store OAuth 2.0 tokens in AWS Secret "%s" - access denied, does IAM user have "secretsmanager:PutSecretValue" permissions?' % (secret_id))
else:
Log.error('Failed to store OAuth 2.0 tokens in AWS Secret "%s" - unexpected error, see debug logs for details')
AppConfig.aws_secrets_debuglog_boto3error(err_putsecret_aftercreate)
elif err_putsecret.response['Error']['Code'] == 'AccessDeniedException':
Log.error('Failed to store OAuth tokens in AWS Secret "%s" - access denied, does IAM user have "secretsmanager:PutSecretValue" permissions?' % (secret_id))
AppConfig.aws_secrets_debuglog_boto3error(err_putsecret)
else:
Log.error('Failed to store OAuth tokens in AWS Secret "%s" - unexpected error, see debug logs for details' % (secret_id))
AppConfig.aws_secrets_debuglog_boto3error(err_putsecret)
else:
Log.error('Failed to store OAuth 2.0 tokens in AWS Secrets Manager')

# Return copy of config without OAuth 2.0 tokens to write to disk
return appconfig_to_save
michaelstepner marked this conversation as resolved.
Show resolved Hide resolved

class OAuth2Helper:
@staticmethod
Expand Down
22 changes: 22 additions & 0 deletions requirements-aws-secrets.txt
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
# This file contains the requirements to store OAuth 2.0 tokens remotely in
# [AWS Secrets Manager](https://aws.amazon.com/secrets-manager/), rather than
# storing them in the local configuration file.
#
# - This will only be applied to accounts which have an `aws_secret` parameter
# configured in the local configuration file, containing the ARN or name of
# the secret.
#
# - To use AWS Secrets Manager you must also set up authentication credentials for your AWS account:
# https://boto3.amazonaws.com/v1/documentation/api/latest/guide/quickstart.html#configuration
#
# - The minimal required permissions for each AWS Secret used are:
# "secretsmanager:GetSecretValue"
# "secretsmanager:PutSecretValue"
#
# - If an AWS Secret does not yet exist, the following permissions are also required:
# "secretsmanager:CreateSecret"

boto3

# include core requirements (separated in order to support usage without GUI-only dependencies)
-r requirements-no-gui.txt
michaelstepner marked this conversation as resolved.
Show resolved Hide resolved