Skip to content

Commit

Permalink
add support for instruction and update html template (#3)
Browse files Browse the repository at this point in the history
* add support for instruction and update html template

* add logic for handling key update instruction

* update readme

* change function position

* update cron expression description

* update readme

* update description for mail_from var

* bug fixes
  • Loading branch information
paliwalvimal authored Nov 25, 2021
1 parent f154785 commit 2eb5d94
Show file tree
Hide file tree
Showing 5 changed files with 106 additions and 46 deletions.
20 changes: 15 additions & 5 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@

![License](https://img.shields.io/github/license/paliwalvimal/aws-iam-key-rotator?style=for-the-badge) ![CodeQL](https://img.shields.io/github/workflow/status/paliwalvimal/aws-iam-key-rotator/codeql/main?label=CodeQL&style=for-the-badge) ![Commit](https://img.shields.io/github/last-commit/paliwalvimal/aws-iam-key-rotator?style=for-the-badge) ![Release](https://img.shields.io/github/v/release/paliwalvimal/aws-iam-key-rotator?style=for-the-badge)

This tool is responsible for generating a new IAM access key pair every X days and mails it to the user via SES. It will also delete the existing key pair after a few days of new key generation to allow the user to update the new key wherever required.
This tool is responsible for generating a new IAM access key pair every X days and mails it to the user via any of the supported mailer. It will also delete the existing key pair after a few days of new key generation giving the user window to update the new key wherever required.

### Prerequisites:
- [Terraform](https://www.terraform.io/downloads.html)
Expand All @@ -15,14 +15,24 @@ This tool is responsible for generating a new IAM access key pair every X days a
- CloudWatch Event
- IAM

### Supported Mailers:
- AWS SES
- Mailgun

### Process:
![aws-iam-key-rotator](iam-key-rotator.jpeg "AWS IAM Key Rotator")

- CloudWatch triggers lambda function which checks the age of access key for all the IAM users who have **Email**(case-insensitive) tag attached.
- If existing access key age is greater than `ACCESS_KEY_AGE` environment variable or `ROTATE_AFTER_DAYS` tag associated to the IAM user and if the user ONLY has a single key pair associated a new key pair is generated and the same is mailed to the user via your selected mail service.
- CloudWatch triggers lambda function which checks the age of access key for all the IAM users who have **IKR:EMAIL**(case-insensitive) tag attached.
- If existing access key age is greater than `ACCESS_KEY_AGE` environment variable or `IKR:ROTATE_AFTER_DAYS` tag associated to the IAM user and if the user ONLY has a single key pair associated a new key pair is generated and the same is mailed to the user via your selected mail service.
- The existing access key is than stored in DynamoDB table with user details and an expiration timestamp.
- DynamoDB stream triggers destructor lambda function which is responsible for deleting the old access key associated to IAM user if the stream event is `delete`.
- In case it fails to delete the existing key pair the entry is added back to the DynamoDB table so that the same can be picked up later for retry.

### Install
- The [terraform module](terraform) included in this repo will setup everything required to automate IAM key rotation
### Setup:
- Use the [terraform module](terraform) included in this repo to create all the AWS resources required to automate IAM key rotation
- 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: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.
121 changes: 84 additions & 37 deletions src/creator.py
Original file line number Diff line number Diff line change
Expand Up @@ -7,15 +7,15 @@
from datetime import datetime, date
from botocore.exceptions import ClientError

# 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)

# Table name which holds existing access key pair details to be deleted
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)

# 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)

# Mail client to use for sending new key creation or existing key deletion mail
MAIL_CLIENT = os.environ.get('MAIL_CLIENT', 'ses')

Expand All @@ -29,20 +29,34 @@
logger = logging.getLogger('creator')
logger.setLevel(logging.INFO)

def prepare_instruction(keyUpdateInstructions):
sortedKeys = sorted(keyUpdateInstructions)
preparedInstruction = [keyUpdateInstructions[k] for k in sortedKeys]
return ' '.join(preparedInstruction)

def fetch_users_with_email(user):
logger.info('Fetching tags for {}'.format(user))
resp = iam.list_user_tags(
UserName=user
)

userAttributes = {}
keyUpdateInstructions = {}
for t in resp['Tags']:
if t['Key'].lower() == 'email':
if t['Key'].lower() == 'ikr:email':
userAttributes['email'] = t['Value']

if t['Key'].lower() == 'rotate_after_days':
if t['Key'].lower() == 'ikr:rotate_after_days':
userAttributes['rotate_after'] = t['Value']

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

if len(keyUpdateInstructions) > 0:
userAttributes['instruction'] = prepare_instruction(keyUpdateInstructions)
else:
userAttributes['instruction'] = ''

if 'email' in userAttributes:
return True, user, userAttributes

Expand Down Expand Up @@ -90,7 +104,7 @@ def fetch_user_details():
users.pop(userName)
else:
users[userName]['attributes'] = userAttributes
logger.info('User with email tag: {}'.format([user for user in users]))
logger.info('User(s) with email tag: {}'.format([user for user in users]))

logger.info('Fetching keys for users individually')
with concurrent.futures.ThreadPoolExecutor(10) as executor:
Expand All @@ -104,39 +118,42 @@ def fetch_user_details():

return users

def create_user_key(userName, user):
def send_email(email, userName, accessKey, secretKey, instruction, existingAccessKey):
try:
if len(user['keys']) == 0:
logger.info('Skipping key creation for {} because no existing key found'.format(userName))
elif len(user['keys']) == 2:
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))
else:
logger.info('Creating new access key for {}'.format(userName))
resp = iam.create_access_key(
UserName=userName
)
logger.info('New key pair generated for user {}'.format(userName))
mailBody = '''
<!DOCTYPE html>
<html style="font-family: 'Helvetica Neue', Helvetica, Arial, sans-serif; box-sizing: border-box; font-size: 14px; margin: 0;">
<head>
<meta name="viewport" content="width=device-width" />
<meta http-equiv="Content-Type" content="text/html charset=UTF-8" />
<title>{}</title>
<style type="text/css">
body {{
-webkit-font-smoothing: antialiased;
-webkit-text-size-adjust: none;
width: 100% !important;
height: 100%;
line-height: 1.6em;
}}
</style>
</head>
<body>
<p>Hey &#x1F44B; {},</p>
<p>A new access key pair has been generated for you. Please update the same wherever necessary.</p>
<p>
Access Key: <b>{}</b>
<br/>
Secret Access Key: <b>{}</b>
<br/>
Instruction: <b>{}</b>
</p>
<p><b>Note:</b> Existing key pair <b>{}</b> will be deleted after <b>{}</b> days so please update the key pair wherever required.</p>
<p>Thanks,<br/>
Your Security Team</p>
</body>
</html>'''.format('New Access Key Pair', userName, accessKey, secretKey, instruction, existingAccessKey, DAYS_FOR_DELETION)

# Email keys to user
send_email(user['attributes']['email'], userName, resp['AccessKey']['AccessKeyId'], resp['AccessKey']['SecretAccessKey'], user['keys'][0]['ak'])

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

def create_user_keys(users):
with concurrent.futures.ThreadPoolExecutor(10) as executor:
[executor.submit(create_user_key, user, users[user]) for user in users]

def send_email(email, userName, accessKey, secretKey, existingAccessKey):
mailBody = '<html><head><title>{}</title></head><body>Hey &#x1F44B; {},<br/><br/>A new access key pair has been generated for you. Please update the same wherever necessary.<br/><br/>Access Key: <strong>{}</strong><br/>Secret Access Key: <strong>{}</strong><br/><br/><strong>Note:</strong> Existing key pair <strong>{}</strong> will be deleted after {} days so please update the new key pair wherever required.<br/><br/>Thanks,<br/>Your Security Team</body></html>'.format('New Access Key Pair', userName, accessKey, secretKey, existingAccessKey, DAYS_FOR_DELETION)
try:
logger.info('Using {} as mail client'.format(MAIL_CLIENT))
if MAIL_CLIENT == 'ses':
import ses_mailer
Expand Down Expand Up @@ -173,6 +190,36 @@ def mark_key_for_destroy(userName, ak, email):
except (Exception, ClientError) as ce:
logger.error('Failed to mark key {} for deletion. Reason: {}'.format(ak, ce))

def create_user_key(userName, user):
try:
if len(user['keys']) == 0:
logger.info('Skipping key creation for {} because no existing key found'.format(userName))
elif len(user['keys']) == 2:
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))
else:
logger.info('Creating new access key for {}'.format(userName))
resp = iam.create_access_key(
UserName=userName
)
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'])

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

def create_user_keys(users):
with concurrent.futures.ThreadPoolExecutor(10) as executor:
[executor.submit(create_user_key, user, users[user]) for user in users]

def handler(event, context):
if IAM_KEY_ROTATOR_TABLE is None:
logger.error('IAM_KEY_ROTATOR_TABLE is required. Current value: {}'.format(IAM_KEY_ROTATOR_TABLE))
Expand Down
4 changes: 2 additions & 2 deletions terraform/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -36,15 +36,15 @@ This terraform module will deploy the following services:
| key_creator_function_name | Name for lambda function responsible for creating new access key pair | `string` | `"iam-key-creator"` | no |
| key_destructor_role_name | Name for IAM role to assocaite with key destructor lambda function | `string` | `"iam-key-destructor"` | no |
| key_destructor_function_name | Name for lambda function responsible for deleting existing access key pair | `string` | `"iam-key-destructor"` | no |
| cron_expression | [CRON expression](https://docs.aws.amazon.com/eventbridge/latest/userguide/eb-schedule-expressions.html) to determine how frequently `key creator` function will be invoked | `string` | `"0 12 * * ? *"` | no |
| cron_expression | [CRON expression](https://docs.aws.amazon.com/eventbridge/latest/userguide/eb-schedule-expressions.html) to determine how frequently `key creator` function will be invoked to check if new key pair needs to be generated for an IAM user | `string` | `"0 12 * * ? *"` | no |
| lambda_runtime | Lambda runtime to use for code execution for both creator and destructor function | `string` | `"python3.8"` | no |
| function_memory_size | Amount of memory to allocate to both creator and destructor function | `number` | `128` | no |
| function_timeout | Timeout to set for both creator and destructor function | `number` | `10` | no |
| 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 |
| mail_from | Email address which should be used for sending mails. **Note:** Prior setup of SES is required to use this feature | `string` | n/a | yes |
| 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 |

Expand Down
3 changes: 3 additions & 0 deletions terraform/main.tf
Original file line number Diff line number Diff line change
Expand Up @@ -162,11 +162,13 @@ resource "aws_ssm_parameter" "mailgun" {
type = "SecureString"
tags = var.tags
}

resource "aws_lambda_function" "iam_key_creator" {
# checkov:skip=CKV_AWS_50: Enabling X-Ray tracing depends on user
# checkov:skip=CKV_AWS_115: Setting reserved concurrent execution depends on user
# checkov:skip=CKV_AWS_116: DLQ not required
# checkov:skip=CKV_AWS_117: VPC deployment not required
# checkov:skip=CKV_AWS_173: By default environment variables are encrypted at rest
function_name = var.key_creator_function_name
description = "Create new access key pair for IAM user"
role = aws_iam_role.iam_key_creator.arn
Expand Down Expand Up @@ -278,6 +280,7 @@ resource "aws_lambda_function" "iam_key_destructor" {
# checkov:skip=CKV_AWS_115: Setting reserved concurrent execution depends on user
# checkov:skip=CKV_AWS_116: DLQ not required
# checkov:skip=CKV_AWS_117: VPC deployment not required
# checkov:skip=CKV_AWS_173: By default environment variables are encrypted at rest
function_name = var.key_destructor_function_name
description = "Delete existing access key pair for IAM user"
role = aws_iam_role.iam_key_destructor.arn
Expand Down
4 changes: 2 additions & 2 deletions terraform/vars.tf
Original file line number Diff line number Diff line change
Expand Up @@ -79,7 +79,7 @@ variable "key_destructor_function_name" {
variable "cron_expression" {
type = string
default = "0 12 * * ? *"
description = "[CRON expression](https://docs.aws.amazon.com/eventbridge/latest/userguide/eb-schedule-expressions.html) to determine how frequently `key creator` function will be invoked"
description = "[CRON expression](https://docs.aws.amazon.com/eventbridge/latest/userguide/eb-schedule-expressions.html) to determine how frequently `key creator` function will be invoked to check if new key pair needs to be generated for an IAM user"
}

variable "lambda_runtime" {
Expand Down Expand Up @@ -126,7 +126,7 @@ variable "mail_client" {

variable "mail_from" {
type = string
description = "Email address which should be used for sending mails. **Note:** Prior setup of SES is required to use this feature"
description = "Email address which should be used for sending mails. **Note:** Prior setup of mail client is required"
}

variable "mailgun_api_url" {
Expand Down

0 comments on commit 2eb5d94

Please sign in to comment.