Skip to content

Commit

Permalink
SMTP support (#7)
Browse files Browse the repository at this point in the history
* change var name and add support of setting python env var via terraform

* add smtp support for email notifications

* add smtp vars in terraform

* add plain mail body, custom key delete period per user and var name change

* update smtp logging and other bug fixes

* update var description
  • Loading branch information
paliwalvimal authored Nov 30, 2021
1 parent 2eb5d94 commit 15babf3
Show file tree
Hide file tree
Showing 10 changed files with 232 additions and 54 deletions.
3 changes: 2 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,7 @@ This tool is responsible for generating a new IAM access key pair every X days a
- Add following tags to the IAM user whose access keys needs to be automated. All the tags mentioned are case-insensitive:
- Required:
- `IKR:EMAIL`: Email address of IAM user where alerts related to access keys will be sent
- `IKR:ROTATE_AFTER_DAYS`: After how many days access key should be rotated
- Optional:
- `IKR:ROTATE_AFTER_DAYS`: After how many days new access key should be generated. **Note:** If you want to control key generation period per user add this tag to the user else environment variable `ROTATE_AFTER_DAYS` will be used
- `IKR:DELETE_AFTER_DAYS`: After how many days existing access key should be deleted. **Note:** If you want to control key deletion period per user add this tag to the user else environment variable `DELETE_AFTER_DAYS` will be used
- `IKR:INSTRUCTION_0`: Add help instruction related to updating access key. This instruction will be sent to IAM user whenever a new key pair is generated. **Note:** As AWS restricts [tag value](https://docs.aws.amazon.com/general/latest/gr/aws_tagging.html#tag-conventions) to 256 characters you can use multiple instruction tags by increasing the number (`IKR:INSTRUCTION_0`, `IKR:INSTRUCTION_1` , `IKR:INSTRUCTION_2` and so on). All the instruction tags value will be combined and sent as a single string to the user.
41 changes: 25 additions & 16 deletions src/creator.py
Original file line number Diff line number Diff line change
Expand Up @@ -11,10 +11,10 @@
IAM_KEY_ROTATOR_TABLE = os.environ.get('IAM_KEY_ROTATOR_TABLE', None)

# Days after which a new access key pair should be generated
ACCESS_KEY_AGE = os.environ.get('ACCESS_KEY_AGE', 85)
ROTATE_AFTER_DAYS = os.environ.get('ROTATE_AFTER_DAYS', 85)

# No. of days to wait before existing key pair is deleted once a new key pair is generated
DAYS_FOR_DELETION = os.environ.get('DAYS_FOR_DELETION', 5)
# No. of days to wait for deleting existing key pair after a new key pair is generated
DELETE_AFTER_DAYS = os.environ.get('DELETE_AFTER_DAYS', 5)

# Mail client to use for sending new key creation or existing key deletion mail
MAIL_CLIENT = os.environ.get('MAIL_CLIENT', 'ses')
Expand Down Expand Up @@ -49,6 +49,9 @@ def fetch_users_with_email(user):
if t['Key'].lower() == 'ikr:rotate_after_days':
userAttributes['rotate_after'] = t['Value']

if t['Key'].lower() == 'ikr:delete_after_days':
userAttributes['delete_after'] = t['Value']

if t['Key'].lower().startswith('ikr:instruction_'):
keyUpdateInstructions[int(t['Key'].split('_')[1])] = t['Value']

Expand Down Expand Up @@ -118,9 +121,11 @@ def fetch_user_details():

return users

def send_email(email, userName, accessKey, secretKey, instruction, existingAccessKey):
def send_email(email, userName, accessKey, secretKey, instruction, existingAccessKey, existingKeyDeleteAge):
try:
mailBody = '''
mailSubject = 'New Access Key Pair'
mailBodyPlain = 'Hey {},\n\nA new access key pair has been generated for you. Please update the same wherever necessary.\n\nAccess Key: {}\nSecret Access Key: {}\nInstruction: {}\n\nNote: Existing key pair {} will be deleted after {} days so please update the key pair wherever required.\n\nThanks,\nYour Security Team'.format(mailSubject, userName, accessKey, secretKey, instruction, existingAccessKey, existingKeyDeleteAge)
mailBodyHtml = '''
<!DOCTYPE html>
<html style="font-family: 'Helvetica Neue', Helvetica, Arial, sans-serif; box-sizing: border-box; font-size: 14px; margin: 0;">
<head>
Expand Down Expand Up @@ -152,21 +157,24 @@ def send_email(email, userName, accessKey, secretKey, instruction, existingAcces
<p>Thanks,<br/>
Your Security Team</p>
</body>
</html>'''.format('New Access Key Pair', userName, accessKey, secretKey, instruction, existingAccessKey, DAYS_FOR_DELETION)
</html>'''.format(mailSubject, userName, accessKey, secretKey, instruction, existingAccessKey, existingKeyDeleteAge)

logger.info('Using {} as mail client'.format(MAIL_CLIENT))
if MAIL_CLIENT == 'ses':
if MAIL_CLIENT == 'smtp':
import smtp_mailer
smtp_mailer.send_email(email, userName, mailSubject, MAIL_FROM, mailBodyPlain, mailBodyHtml)
elif MAIL_CLIENT == 'ses':
import ses_mailer
ses_mailer.send_email(email, userName, MAIL_FROM, mailBody)
ses_mailer.send_email(email, userName, mailSubject, MAIL_FROM, mailBodyPlain, mailBodyHtml)
elif MAIL_CLIENT == 'mailgun':
import mailgun_mailer
mailgun_mailer.send_email(email, userName, MAIL_FROM, mailBody)
mailgun_mailer.send_email(email, userName, mailSubject, MAIL_FROM, mailBodyPlain, mailBodyHtml)
else:
logger.error('{}: Invalid mail client. Supported mail clients: AWS SES and Mailgun'.format(MAIL_CLIENT))
except (Exception, ClientError) as ce:
logger.error('Failed to send mail to user {} ({}). Reason: {}'.format(userName, email, ce))

def mark_key_for_destroy(userName, ak, email):
def mark_key_for_destroy(userName, ak, existingKeyDeleteAge, email):
try:
today = date.today()
dynamodb.put_item(
Expand All @@ -182,7 +190,7 @@ def mark_key_for_destroy(userName, ak, email):
'S': email
},
'delete_on': {
'N': str(round(datetime(today.year, today.month, today.day, tzinfo=pytz.utc).timestamp()) + (DAYS_FOR_DELETION * 24 * 60 * 60))
'N': str(round(datetime(today.year, today.month, today.day, tzinfo=pytz.utc).timestamp()) + (existingKeyDeleteAge * 24 * 60 * 60))
}
}
)
Expand All @@ -198,9 +206,9 @@ def create_user_key(userName, user):
logger.warn('Skipping key creation for {} because 2 keys already exist. Please delete anyone to create new key'.format(userName))
else:
for k in user['keys']:
rotationAge = user['attributes']['rotate_after'] if 'rotate_after' in user['attributes'] else ACCESS_KEY_AGE
if k['ak_age_days'] <= int(rotationAge):
logger.info('Skipping key creation for {} because existing key is only {} day(s) old and the rotation is set for {} days'.format(userName, k['ak_age_days'], rotationAge))
keyRotationAge = user['attributes']['rotate_after'] if 'rotate_after' in user['attributes'] else ROTATE_AFTER_DAYS
if k['ak_age_days'] <= int(keyRotationAge):
logger.info('Skipping key creation for {} because existing key is only {} day(s) old and the rotation is set for {} days'.format(userName, k['ak_age_days'], keyRotationAge))
else:
logger.info('Creating new access key for {}'.format(userName))
resp = iam.create_access_key(
Expand All @@ -209,10 +217,11 @@ def create_user_key(userName, user):
logger.info('New key pair generated for user {}'.format(userName))

# Email keys to user
send_email(user['attributes']['email'], userName, resp['AccessKey']['AccessKeyId'], resp['AccessKey']['SecretAccessKey'], user['attributes']['instruction'], user['keys'][0]['ak'])
existingKeyDeleteAge = user['attributes']['delete_after'] if 'delete_after' in user['attributes'] else DELETE_AFTER_DAYS
send_email(user['attributes']['email'], userName, resp['AccessKey']['AccessKeyId'], resp['AccessKey']['SecretAccessKey'], user['attributes']['instruction'], user['keys'][0]['ak'], int(existingKeyDeleteAge))

# Mark exisiting key to destory after X days
mark_key_for_destroy(userName, user['keys'][0]['ak'], user['attributes']['email'])
mark_key_for_destroy(userName, user['keys'][0]['ak'], int(existingKeyDeleteAge), user['attributes']['email'])
except (Exception, ClientError) as ce:
logger.error('Failed to create new key pair. Reason: {}'.format(ce))

Expand Down
15 changes: 10 additions & 5 deletions src/destructor.py
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@
# Table name which holds existing access key pair details to be deleted
IAM_KEY_ROTATOR_TABLE = os.environ.get('IAM_KEY_ROTATOR_TABLE', None)

# In case lambda fails to delete the key, how long should it wait before next try
# In case lambda fails to delete the old key, how long should it wait before the next try
RETRY_AFTER_MINS = os.environ.get('RETRY_AFTER_MINS', 5)

# Mail client to use for sending new key creation or existing key deletion mail
Expand All @@ -26,15 +26,20 @@
logger.setLevel(logging.INFO)

def send_email(email, userName, existingAccessKey):
mailBody = '<html><head><title>{}</title></head><body>Hey &#x1F44B; {},<br/><br/>An existing access key pair associated to your username has been deleted because it reached End-Of-Life. <br/><br/>Access Key: <strong>{}</strong><br/><br/>Thanks,<br/>Your Security Team</body></html>'.format('Old Access Key Pair Deleted', userName, existingAccessKey)
mailSubject = 'Old Access Key Pair Deleted'
mailBodyPlain = 'Hey {},\nAn existing access key pair associated to your username has been deleted because it reached End-Of-Life.\n\nAccess Key: {}\n\nThanks,\nYour Security Team'.format(mailSubject, userName, existingAccessKey)
mailBodyHtml = '<html><head><title>{}</title></head><body>Hey &#x1F44B; {},<br/><br/>An existing access key pair associated to your username has been deleted because it reached End-Of-Life. <br/><br/>Access Key: <strong>{}</strong><br/><br/>Thanks,<br/>Your Security Team</body></html>'.format(mailSubject, userName, existingAccessKey)
try:
logger.info('Using {} as mail client'.format(MAIL_CLIENT))
if MAIL_CLIENT == 'ses':
if MAIL_CLIENT == 'smtp':
import smtp_mailer
smtp_mailer.send_email(email, userName, mailSubject, MAIL_FROM, mailBodyPlain, mailBodyHtml)
elif MAIL_CLIENT == 'ses':
import ses_mailer
ses_mailer.send_email(email, userName, MAIL_FROM, mailBody)
ses_mailer.send_email(email, userName, mailSubject, MAIL_FROM, mailBodyPlain, mailBodyHtml)
elif MAIL_CLIENT == 'mailgun':
import mailgun_mailer
mailgun_mailer.send_email(email, userName, MAIL_FROM, mailBody)
mailgun_mailer.send_email(email, userName, mailSubject, MAIL_FROM, mailBodyPlain, mailBodyHtml)
else:
logger.error('{}: Invalid mail client. Supported mail clients: AWS SES and Mailgun'.format(MAIL_CLIENT))
except (Exception, ClientError) as ce:
Expand Down
17 changes: 9 additions & 8 deletions src/mailgun_mailer.py
Original file line number Diff line number Diff line change
Expand Up @@ -14,27 +14,28 @@
logger = logging.getLogger('mailgun-mailer')
logger.setLevel(logging.INFO)

def send_email(email, userName, mailFrom, mailBody):
def send_email(mailTo, userName, mailSubject, mailFrom, mailBodyPlain, mailBodyHtml):
if MAILGUN_API_URL is None or MAILGUN_API_KEY_NAME is None:
logger.error('Both MAILGUN_API_URL and MAILGUN_API_KEY_NAME is required for sending mail via Mailgun. Current values: MAILGUN_API_URL = {} and MAILGUN_API_KEY_NAME = {}'.format(MAILGUN_API_URL, MAILGUN_API_KEY_NAME))
return False

logger.info('Fetching API key from SSM')
logger.info('Fetching Mailgun API key from SSM')
resp = ssm.get_parameter(
Name=MAILGUN_API_KEY_NAME,
WithDecryption=True
)
apiKey = resp['Parameter']['Value']

logger.info('Sending mail to {} ({}) via Mailgun'.format(userName, email))
logger.info('Sending mail to {} ({}) via Mailgun'.format(userName, mailTo))
resp = requests.post(MAILGUN_API_URL, auth=("api", apiKey),
data={"from": mailFrom,
"to": [email],
"subject": "New Access Key Pair",
"html": mailBody})
"to": [mailTo],
"subject": mailSubject,
"text": mailBodyPlain,
"html": mailBodyHtml})
respBody = resp.json()

if 'message' in respBody and respBody['message'] == 'Queued. Thank you.':
logger.info('Mail sent to {} ({}) via Mailgun'.format(userName, email))
logger.info('Mail sent to {} ({}) via Mailgun'.format(userName, mailTo))
else:
logger.error('Mailgun was unable to send mail to {} ({}). Reason: {}'.format(userName, email, respBody['message']))
logger.error('Mailgun was unable to send mail to {} ({}). Reason: {}'.format(userName, mailTo, respBody['message']))
16 changes: 10 additions & 6 deletions src/ses_mailer.py
Original file line number Diff line number Diff line change
Expand Up @@ -7,25 +7,29 @@
logger = logging.getLogger('ses-mailer')
logger.setLevel(logging.INFO)

def send_email(email, userName, mailFrom, mailBody):
logger.info('Sending mail to {} ({}) via AWS SES'.format(userName, email))
def send_email(mailTo, userName, mailSubject, mailFrom, mailBodyPlain, mailBodyHtml):
logger.info('Sending mail to {} ({}) via AWS SES'.format(userName, mailTo))
ses.send_email(
Source='{}'.format(mailFrom),
Destination={
'ToAddresses': [
email
mailTo
]
},
Message={
'Subject': {
'Data': 'New Access Key Pair'
'Data': mailSubject
},
'Body': {
'Text': {
'Data': mailBodyPlain,
'Charset': 'UTF-8'
},
'Html': {
'Data': mailBody,
'Data': mailBodyHtml,
'Charset': 'UTF-8'
}
}
}
)
logger.info('Mail sent to {} ({}) via AWS SES'.format(userName, email))
logger.info('Mail sent to {} ({}) via AWS SES'.format(userName, mailTo))
78 changes: 78 additions & 0 deletions src/smtp_mailer.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,78 @@
import boto3
import os
import smtplib
import ssl
import logging

from email.mime.text import MIMEText
from email.mime.multipart import MIMEMultipart

ssm = boto3.client('ssm', region_name=os.environ.get('AWS_REGION'))

logger = logging.getLogger('smtp-mailer')
logger.setLevel(logging.INFO)

# Whether to use SSL or TLS for sending mail
SMTP_PROTOCOL = os.environ.get('SMTP_PROTOCOL', 'ssl')

# Port number to use for connecting to SMTP server
SMTP_PORT = os.environ.get('SMTP_PORT', 465)

# Host name of server to connect
SMTP_SERVER = os.environ.get('SMTP_SERVER', None)

# SSM Parameter name which holds SMTP server password
SMTP_PASSWORD_PARAMETER = os.environ.get('SMTP_PASSWORD_PARAMETER')

def send_via_ssl(userName, mailFrom, smtpPassword, mailTo, mailBody):
try:
logger.info('Sending mail to {} ({}) via SMTP over SSL'.format(userName, mailTo))
context = ssl.create_default_context()
with smtplib.SMTP_SSL(SMTP_SERVER, SMTP_PORT, context=context) as server:
server.login(mailFrom, smtpPassword)
server.sendmail(mailFrom, mailTo, mailBody)
logger.info('Mail sent to {} ({}) via SMTP over SSL'.format(userName, mailTo))
except Exception as e:
logger.error('Unable to send mail via SSL to {}. Reason: {}'.format(mailTo, e))

def send_via_tls(userName, mailFrom, smtpPassword, mailTo, mailBody):
try:
logger.info('Sending mail to {} ({}) via SMTP over TLS'.format(userName, mailTo))
context = ssl.create_default_context()
with smtplib.SMTP(SMTP_SERVER, SMTP_PORT) as server:
server.starttls(context=context)
server.login(mailFrom, smtpPassword)
server.sendmail(mailFrom, mailTo, mailBody)
logger.info('Mail sent to {} ({}) via SMTP over TLS'.format(userName, mailTo))
except Exception as e:
logger.error('Unable to send mail via TLS to {}. Reason: {}'.format(mailTo, e))

def send_email(mailTo, userName, mailSubject, mailFrom, mailBodyPlain, mailBodyHtml):
if SMTP_SERVER is None:
logger.error('SMTP_SERVER value cannot be blank')
return False

logger.info('Fetching SMTP password from SSM')
resp = ssm.get_parameter(
Name=SMTP_PASSWORD_PARAMETER,
WithDecryption=True
)
smtpPassword = resp['Parameter']['Value']

message = MIMEMultipart("alternative")
message["Subject"] = mailSubject
message["From"] = mailFrom
message["To"] = mailTo

mailBodyPlain = MIMEText(mailBodyPlain, 'plain')
mailBodyHtml = MIMEText(mailBodyHtml, 'html')

message.attach(mailBodyPlain)
message.attach(mailBodyHtml)

if SMTP_PROTOCOL.lower() == 'ssl':
send_via_ssl(userName, mailFrom, smtpPassword, mailTo, message.as_string())
elif SMTP_PROTOCOL.lower() == 'tls':
send_via_tls(userName, mailFrom, smtpPassword, mailTo, message.as_string())
else:
logger.error('{} is not a supported SMTP protocol'.format(SMTP_PROTOCOL))
13 changes: 10 additions & 3 deletions terraform/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -43,10 +43,17 @@ This terraform module will deploy the following services:
| reserved_concurrent_executions | Amount of reserved concurrent executions for this lambda function. A value of `0` disables lambda from being triggered and `-1` removes any concurrency limitations | `number` | `-1` | no |
| xray_tracing_mode | Whether to sample and trace a subset of incoming requests with AWS X-Ray. **Possible values:** `PassThrough` and `Active` | `string` | `"PassThrough"` | no |
| tags | Key value pair to assign to resources | `map(string)` | `{}` | no |
| mail_client | Mail client to use. **Supported Clients:** ses and mailgun | `string` | `"ses"` | no |
| rotate_after_days | Days after which a new access key pair should be generated. **Note:** If `IKR:ROTATE_AFTER_DAYS` tag is set for the IAM user, this is ignored | `number` | `85` | no |
| delete_after_days | No. of days to wait for deleting existing key pair after a new key pair is generated. **Note:** If `IKR:DELETE_AFTER_DAYS` tag is set for the IAM user, this is ignored | `number` | `5` | no |
| retry_after_mins | In case lambda fails to delete the old key, how long should it wait before the next try | `number` | `5` | no |
| mail_client | Mail client to use. **Supported Clients:** smtp, ses and mailgun | `string` | `"ses"` | no |
| mail_from | Email address which should be used for sending mails. **Note:** Prior setup of mail client is required | `string` | n/a | yes |
| mailgun_api_url | Mailgun API url for sending email. **Note:** Required if you want to use Mailgun as mail client | `string` | `null` | no |
| mailgun_api_key | API key for authenticating requests to Mailgun API. **Note:** Required if you want to use Mailgun as mail client | `string` | `""` | no |
| smtp_protocol | Security protocol to use for SMTP connection. **Supported values:** ssl and tls. **Note:** Required if mail client is set to smtp | `string` | `null` | no |
| smtp_port | Secure port number to use for SMTP connection. **Note:** Required if mail client is set to smtp | `number` | `null` | no |
| smtp_server | Host name of SMTP server. **Note:** Required if mail client is set to smtp | `string` | `null` | no |
| smtp_password | Password to use with `mail_from` address for SMTP authentication. **Note:** Required if mail client is set to smtp | `string` | `null` | no |
| mailgun_api_url | Mailgun API url for sending email. **Note:** Required if mail client is set to mailgun | `string` | `null` | no |
| mailgun_api_key | API key for authenticating requests to Mailgun API. **Note:** Required if mail client is set to mailgun | `string` | `null` | no |

## Outputs

Expand Down
12 changes: 12 additions & 0 deletions terraform/archive.tf
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,10 @@ data "template_file" "mailgun_mailer" {
template = file("../src/mailgun_mailer.py")
}

data "template_file" "smtp_mailer" {
template = file("../src/smtp_mailer.py")
}

data "archive_file" "creator" {
type = "zip"
output_path = "${path.module}/creator.zip"
Expand All @@ -29,6 +33,10 @@ data "archive_file" "creator" {
content = data.template_file.mailgun_mailer.rendered
filename = "mailgun_mailer.py"
}
source {
content = data.template_file.smtp_mailer.rendered
filename = "smtp_mailer.py"
}
}

data "archive_file" "destructor" {
Expand All @@ -46,4 +54,8 @@ data "archive_file" "destructor" {
content = data.template_file.mailgun_mailer.rendered
filename = "mailgun_mailer.py"
}
source {
content = data.template_file.smtp_mailer.rendered
filename = "smtp_mailer.py"
}
}
Loading

0 comments on commit 15babf3

Please sign in to comment.