diff --git a/modules/budget/variables.tf b/modules/budget/variables.tf index b2fd326..13a7fb5 100644 --- a/modules/budget/variables.tf +++ b/modules/budget/variables.tf @@ -22,5 +22,5 @@ variable "email_recipients" { variable "threshold_percentages" { type = list(number) description = "A list of percentages to trigger notifications." - default = [80, 100] + default = [50, 80, 100] } diff --git a/modules/iam-users/lambda/lambda.py b/modules/iam-users/lambda/lambda.py new file mode 100644 index 0000000..0282ede --- /dev/null +++ b/modules/iam-users/lambda/lambda.py @@ -0,0 +1,221 @@ +import json +import boto3 +from datetime import datetime, timezone +import logging +import time +import os + + +# Set up logging +logger = logging.getLogger() +logger.setLevel(logging.INFO) + +# Set up AWS clients +iam_client = boto3.client('iam') +dynamodb_client = boto3.client('dynamodb') +ssm_client = boto3.client('ssm') + +# DynamoDB Table Name +TABLE_NAME = os.environ.get('DYNAMODB_TABLE_NAME') + +def lambda_handler(event, context): + try: + # Check if the Lambda is triggered by DynamoDB Stream + if 'Records' in event: + for record in event['Records']: + user_name = record['dynamodb']['Keys']['UserName']['S'] + if record['eventName'] in ['INSERT', 'MODIFY']: + logger.info(f"New user with username {user_name} added to the table") + handle_iam_user(user_name) + elif record['eventName'] == 'REMOVE': + logger.info(f"User with username {user_name} removed from the table") + handle_iam_user_removal(user_name) + return # Returning Function here so that we do not want cron-based trigger to run every time + # Cron-based trigger + dynamodb_users = [] + paginator = dynamodb_client.get_paginator('scan') + for page in paginator.paginate(TableName=TABLE_NAME): + for item in page['Items']: + dynamodb_users.append(item['UserName']['S']) + existing_iam_users = [] + paginator = iam_client.get_paginator('list_users') + for page in paginator.paginate(): + for user in page['Users']: + existing_iam_users.append(user['UserName']) + + if dynamodb_users: + for user_name in dynamodb_users: + logger.info(f"Handling user from DynamoDB: {user_name}") + handle_iam_user(user_name) + else: + logger.info("DynamoDB table is empty. No users to be created or managed.") + + # Check and delete users not in the DynamoDB table + delete_extra_iam_users(existing_iam_users, dynamodb_users) # This is run regardless of how the lambda is triggerd + + except Exception as e: + logger.error(f"Error in lambda_handler: {str(e)}") + raise + +def handle_iam_user(user_name): + try: + # If IAM user doesn't exist, it'll throw an exception. + iam_client.get_user(UserName=user_name) + logger.info(f"IAM user {user_name} already exists.") + current_key, pending_deletion_key = get_key_details(user_name) + + # Decision logic on how the keys should rotate + if not current_key: + logger.info("No key present, Creating current key") + create_and_manage_key(user_name, 'current') + elif current_key and not pending_deletion_key and current_key['age'] >= 15: + logger.info("Current key age is older than 15 days, Creating New key and renaming keys") + rotate_keys_and_update_tags(user_name, current_key, pending_deletion_key) + elif current_key and pending_deletion_key and pending_deletion_key['age'] >= 30: + logger.info("Key age expired, Rotationg keys") + rotate_keys_and_update_tags(user_name, current_key, pending_deletion_key) + else: + logger.info(f"Keys are not old enough to rotate for the user {user_name}") + + except iam_client.exceptions.NoSuchEntityException: # User doesn't exist + try: + logger.info(f"IAM user {user_name} doesn't exist. Creating...") + # Create IAM user + iam_client.create_user(UserName=user_name) + + # Attach permission boundary to the user + account_id = boto3.client('sts').get_caller_identity().get('Account') + permissions_boundary_arn = f"arn:aws:iam::{account_id}:policy/BCGOV_IAM_USER_BOUNDARY_POLICY" + logger.info(f"Attaching permission boundary: {permissions_boundary_arn} to user: {user_name}") + iam_client.put_user_permissions_boundary(UserName=user_name, PermissionsBoundary=permissions_boundary_arn) + + create_and_manage_key(user_name, 'current') + except Exception as ex: + logger.error(f"Error while creating user and attaching permission boundary: {str(ex)}") + raise + + +def get_key_details(user_name): + logger.info(f"Getting Key Details for the user: {user_name}") + existing_keys = iam_client.list_access_keys(UserName=user_name)['AccessKeyMetadata'] + pending_deletion_key = None + current_key = None + + for key in existing_keys: + key_id = key['AccessKeyId'] + create_date = key['CreateDate'] + age = (datetime.now(timezone.utc) - create_date).days # age in days + + tags = iam_client.list_user_tags(UserName=user_name) + for tag in tags['Tags']: + if tag['Key'] == key_id and tag['Value'] == 'pending_deletion': + pending_deletion_key = {'id': key_id, 'age': age} + elif tag['Key'] == key_id and tag['Value'] == 'current': + current_key = {'id': key_id, 'age': age} + + return current_key, pending_deletion_key + +def rotate_keys_and_update_tags(user_name, current_key, pending_deletion_key): + logger.info(f"Rotating keys for user: {user_name}") + + # If there is a 'Pending deletion key' to delete + if pending_deletion_key: + # Untag and delete the 'Pending deletion key' + iam_client.untag_user(UserName=user_name, TagKeys=[pending_deletion_key['id']]) + iam_client.delete_access_key(UserName=user_name, AccessKeyId=pending_deletion_key['id']) + + # Move 'current' key to 'pending_deletion' in Parameter Store + param_name = f'/iam_users/{user_name}_keys' + try: + existing_param = ssm_client.get_parameter(Name=param_name, WithDecryption=True)['Parameter']['Value'] + existing_param = json.loads(existing_param) + if 'current' in existing_param: + existing_param['pending_deletion'] = existing_param['current'] + del existing_param['current'] + ssm_client.put_parameter(Name=param_name, Value=json.dumps(existing_param), Type='SecureString', Overwrite=True) + except ssm_client.exceptions.ParameterNotFound: + # No existing parameter to update + pass + + # Untag the 'Current key' and tag as 'Pending deletion' + iam_client.untag_user(UserName=user_name, TagKeys=[current_key['id']]) + iam_client.tag_user(UserName=user_name, Tags=[{'Key': current_key['id'], 'Value': 'pending_deletion'}]) + + # Create a new 'Current key' + create_and_manage_key(user_name, 'current') + +def create_and_manage_key(user_name, key_status): + logger.info(f"Creating a new {key_status} key for user: {user_name}") + + retries = 3 + for i in range(retries): + try: + new_key = iam_client.create_access_key(UserName=user_name) + new_key_id = new_key['AccessKey']['AccessKeyId'] + break + except iam_client.exceptions.LimitExceededException: + if i < retries - 1: # i is zero indexed + logger.warning(f"Rate limit exceeded when creating key for {user_name}. Retrying...") + time.sleep(2 ** i) # Exponential back-off + else: + logger.error(f"Failed to create key for {user_name} after {retries} retries.") + return + + tag_value = 'current' if key_status == 'current' else 'pending_deletion' + iam_client.tag_user(UserName=user_name, Tags=[{'Key': new_key_id, 'Value': tag_value}]) + + param_name = f'/iam_users/{user_name}_keys' + param_value = { + tag_value: { + 'AccessKeyID': new_key_id, + 'SecretAccessKey': new_key['AccessKey']['SecretAccessKey'] + } + } + try: + existing_param = ssm_client.get_parameter(Name=param_name, WithDecryption=True)['Parameter']['Value'] + existing_param = json.loads(existing_param) + existing_param.update(param_value) + ssm_client.put_parameter(Name=param_name, Value=json.dumps(existing_param), Type='SecureString', Overwrite=True) + except ssm_client.exceptions.ParameterNotFound: + ssm_client.put_parameter(Name=param_name, Value=json.dumps(param_value), Type='SecureString') + + +def handle_iam_user_removal(user_name): + try: + # Detach permissions boundary from the user + try: + iam_client.delete_user_permissions_boundary(UserName=user_name) + logger.info(f"Detached permissions boundary from IAM user: {user_name}") + except iam_client.exceptions.NoSuchEntityException: + # No permissions boundary was attached, so just move on + logger.info(f"No permissions boundary attached to IAM user: {user_name}") + + # List and delete access keys associated with the user + keys = iam_client.list_access_keys(UserName=user_name) + for key in keys['AccessKeyMetadata']: + iam_client.delete_access_key(UserName=user_name, AccessKeyId=key['AccessKeyId']) + logger.info(f"Deleting IAM user: {user_name}") + iam_client.delete_user(UserName=user_name) + + except iam_client.exceptions.DeleteConflictException: + logger.warning(f"IAM user {user_name} has attached resources. Manual cleanup required.") + return + except iam_client.exceptions.NoSuchEntityException: + logger.info(f"IAM user {user_name} doesn't exist, skipping...") + + param_name = f'/iam_users/{user_name}_keys' + try: + logger.info(f"Deleting parameter for the IAM user: {user_name}") + ssm_client.delete_parameter(Name=param_name) + except ssm_client.exceptions.ParameterNotFound: + logger.info(f"Parameter not found for the IAM user: {user_name}, skipping...") + + +# Saftey Net to handle potential drift between the IAM users in your AWS account and the users listed in the DynamoDB table. +def delete_extra_iam_users(existing_users, iam_users_from_db): + """Delete IAM users which are not in the DynamoDB.""" + + for user_name in existing_users: + if user_name not in iam_users_from_db: + logger.info(f"Extra IAM user found: {user_name}. Starting cleanup process...") + handle_iam_user_removal(user_name) diff --git a/modules/iam-users/main.tf b/modules/iam-users/main.tf new file mode 100644 index 0000000..15021e1 --- /dev/null +++ b/modules/iam-users/main.tf @@ -0,0 +1,195 @@ +# Create DynamoDB table for clients to add entries +resource "aws_dynamodb_table" "service_account_table" { + name = var.table_name # Update with your preferred table name + read_capacity = 5 + write_capacity = 5 + hash_key = "UserName" + + attribute { + name = "UserName" + type = "S" + } + + # Enabling Stream for New Image (captures new entries) + stream_enabled = true + stream_view_type = "NEW_IMAGE" +} + +# DynamoDB Stream to Lambda Event Source Mapping +resource "aws_lambda_event_source_mapping" "example" { + event_source_arn = aws_dynamodb_table.service_account_table.stream_arn + function_name = aws_lambda_function.key_rotation.function_name + starting_position = "TRIM_HORIZON" +} + + +# Create an IAM role for the lambda function that rotates keys +resource "aws_iam_role" "lambda_exec_role" { + name = var.role_name + + assume_role_policy = jsonencode({ + Version = "2012-10-17", + Statement = [ + { + Action = "sts:AssumeRole", + Effect = "Allow", + Principal = { + Service = "lambda.amazonaws.com" + } + } + ] + }) +} + +# Create IAM Policy with required permissions to run the lambda function +resource "aws_iam_policy" "lambda_permissions" { + name = var.policy_name + description = "Permissions for Lambda to manage IAM keys, Secrets in Secrets Manager, and IAM user tags." + + policy = jsonencode({ + Version = "2012-10-17", + Statement = [ + { + Effect = "Allow", + Action = [ + "iam:ListUsers", + "iam:GetUser", + "iam:CreateUser", + "iam:DeleteUser", + "iam:ListAccessKeys", + "iam:CreateAccessKey", + "iam:DeleteAccessKey", + "iam:TagUser", + "iam:UntagUser", + "iam:ListUserTags", + "iam:PutUserPermissionsBoundary", + "iam:DeleteUserPermissionsBoundary" + ], + Resource = "*" + }, + { + "Effect" : "Allow", + "Action" : [ + "dynamodb:Scan", + "dynamodb:GetItem", + "dynamodb:GetRecords", + "dynamodb:GetShardIterator", + "dynamodb:DescribeStream", + "dynamodb:ListStreams" + ], + "Resource" : "*" + }, + { + "Effect" : "Allow", + "Action" : [ + "ssm:PutParameter", + "ssm:GetParameter", + "ssm:DeleteParameter" + ], + "Resource" : "*" + }, + { + "Effect" : "Allow", + "Action" : [ + "kms:Encrypt", + "kms:Decrypt", + "kms:ReEncrypt*", + "kms:GenerateDataKey*", + "kms:DescribeKey" + ], + "Resource" : "*" + }, + { + Effect = "Allow", + Action = [ + "logs:CreateLogGroup", + "logs:CreateLogStream", + "logs:PutLogEvents" + ], + Resource = "arn:aws:logs:*:*:*" + } + ] + }) +} + +# Attach the policy to the Lambda execution role +resource "aws_iam_role_policy_attachment" "lambda_permissions_attach" { + role = aws_iam_role.lambda_exec_role.name + policy_arn = aws_iam_policy.lambda_permissions.arn +} + +data "archive_file" "lambda_zip" { + type = "zip" + source_file = "${path.module}/lambda/lambda.py" + output_path = "${path.module}/lambda/lambda.zip" +} +# Create a Lambda function +resource "aws_lambda_function" "key_rotation" { + function_name = var.function_name + filename = data.archive_file.lambda_zip.output_path + handler = "lambda.lambda_handler" # Modify this based on your specific handler configuration + runtime = "python3.9" + timeout = 300 + source_code_hash = data.archive_file.lambda_zip.output_base64sha256 + role = aws_iam_role.lambda_exec_role.arn + environment { + variables = { + DYNAMODB_TABLE_NAME = aws_dynamodb_table.service_account_table.name + } + } +} + +# Create a Cloud watch event rule for every 5 minutes +resource "aws_cloudwatch_event_rule" "every_hour" { + name = "every-hour" + schedule_expression = "rate(1 hour)" +} + +# Set the Lambda function to run every 5 minutes +resource "aws_cloudwatch_event_target" "every_hour_target" { + rule = aws_cloudwatch_event_rule.every_hour.name + target_id = "LambdaFunction" + arn = aws_lambda_function.key_rotation.arn +} + +# Grant permission for the every-five-minutes event to invoke the Lambda function +resource "aws_lambda_permission" "allow_cloudwatch_every_five_minutes" { + statement_id = "AllowExecutionFromCloudWatchEveryFiveMinutes" + action = "lambda:InvokeFunction" + function_name = aws_lambda_function.key_rotation.function_name + principal = "events.amazonaws.com" + source_arn = aws_cloudwatch_event_rule.every_hour.arn +} + +# Permission boundary +resource "aws_iam_policy" "s3_full_access_boundary" { + name = "BCGOV_IAM_USER_BOUNDARY_POLICY" + path = "/" + description = "Permission boundary policy for full S3 access" + + policy = jsonencode({ + Version = "2012-10-17", + Statement = [ + { + Sid = "S3FullAccess", + Effect = "Allow", + Action = "s3:*", + Resource = "*" + }, + { + Sid = "SSMandKMSAccess", + Effect = "Allow", + Action = [ + "ssm:GetParameter", + "ssm:GetParameters", + "ssm:GetParametersByPath", + "kms:Decrypt" + ], + Resource = [ + "arn:aws:ssm:*:*:parameter/iam_users/*", + "arn:aws:kms:*:*:key/*" + ] + } + ] + }) +} \ No newline at end of file diff --git a/modules/iam-users/variables.tf b/modules/iam-users/variables.tf new file mode 100644 index 0000000..3bb1deb --- /dev/null +++ b/modules/iam-users/variables.tf @@ -0,0 +1,15 @@ +variable "function_name" { + description = "Name of the lambda function" +} + +variable "table_name" { + description = "Name of the DynamoDB table created in the account for creating IAM Users" +} + +variable "role_name" { + description = "Name of the role created for the lambda function" +} + +variable "policy_name" { + description = "Name of the policy created for the lambda function" +} diff --git a/modules/iam-users/versions.tf b/modules/iam-users/versions.tf new file mode 100644 index 0000000..bdd81f0 --- /dev/null +++ b/modules/iam-users/versions.tf @@ -0,0 +1,10 @@ +terraform { + required_version = ">= 1.3" + + required_providers { + aws = { + source = "hashicorp/aws" + version = "~> 4.0" + } + } +} \ No newline at end of file