from datetime import datetime, timezone # These imports may show as linting errors in local IDE but will work # in the AWS Lambda environment where boto3 and jinja2 are installed # pylint: disable=import-error import boto3 from botocore.exceptions import ClientError from jinja2 import Environment, PackageLoader, select_autoescape # pylint: enable=import-error from config import Config, DEFAULT_EMAIL_SENDER, DEFAULT_SENDER_ARN from lambdacommon.common_config import logger # pylint: disable=import-error from src.utils import days_diff, get_tag_value_by_key env = Environment(loader=PackageLoader('src'), autoescape=select_autoescape()) email_subject = env.get_template('email_subject.txt') email_body_txt = env.get_template('email_body.txt') email_body_html = env.get_template('email_body.html') def get_tags_for_user(client: boto3.session.Session.client, username: str) -> list: """Return list of tags for the specified user. Args: client: The boto3 IAM client username: The IAM username to get tags for Returns: List of tag dictionaries. Each tag has 'Key' and 'Value' entries. """ response = client.list_user_tags(UserName=username) tags = response.get('Tags', []) if not tags: logger.warning( f"User '{username}' has no tags. " "This may cause issues with email notifications and key management." ) return tags def get_user_access_keys(client: boto3.session.Session.client, username: str) -> list: """get user's access keys""" response = client.list_access_keys(UserName=username) return response['AccessKeyMetadata'] if 'AccessKeyMetadata' in response else [] def get_all_users(client: boto3.session.Session.client, config: Config = None): """get list of all users""" if config is None: config = Config() users = [] paginator = client.get_paginator('list_users') page_iterator = paginator.paginate() for page in page_iterator: for user in page.get('Users'): username = user.get('UserName') tags = get_tags_for_user(client, username) role = get_tag_value_by_key(tags, config.TAG_NAME_ROLE) users.append( { 'name': username, 'email': get_tag_value_by_key(tags, config.TAG_NAME_EMAIL), 'FullName': get_tag_value_by_key(tags, config.TAG_NAME_FULLNAME), 'CreateDate': user.get('CreateDate'), 'PasswordLastUsed': user.get('PasswordLastUsed', datetime.today()), 'Tags': tags, 'Role': role, 'IsServiceUser': role.lower() == config.SERVICE_USER_VALUE.lower() if role else False, 'AccessKeys': get_user_access_keys(client, username), 'WatchDogEnabled': get_tag_value_by_key(tags, config.TAG_NAME_WATCHDOG_ENABLED).lower() == 'always', 'NotificationEnabled': get_tag_value_by_key(tags, config.TAG_NAME_NOTIFICATION_ENABLED).lower() == 'always', } ) return users def is_key_in_use(client: boto3.session.Session.client, key_id: str) -> bool: """Check whether access key was used at all""" last_used = client.get_access_key_last_used(AccessKeyId=key_id) if 'LastUsedDate' in last_used['AccessKeyLastUsed']: return True else: return False def email_user( client: boto3.session.Session.client, recipient: str = 'WRONG E-MAIL ADDR', subject: str = 'AWS Watchdog dummy letter', body_html: str = 'No text', body_text: str = 'No text', charset: str = 'UTF-8', sender: str = DEFAULT_EMAIL_SENDER, senderArn: str = DEFAULT_SENDER_ARN ): """Notify AWS User By e-mail Args: client: boto3.session.Session.client instance logger: Logger instance recipient: E-mail (Default: WRONG E-MAIL ADDR) subject: E-mail subject (Default: AWS Watchdog dummy letter ) body_html: E-mail html body (Default: No text ) body_text: E-mail text body (Default: No text ) charset: E-mail Charset (Default: utf8 ) sender: E-mail FROM (Default: AWS Watchdog ) senderArn: ARN of identity """ email_body_dict = {} if body_html != 'No text': email_body_dict['Html'] = { 'Charset': charset, 'Data': body_html } if body_text != 'No text': email_body_dict['Text'] = { 'Charset': charset, 'Data': body_text } # If both body_html and body_text are 'No text' (their defaults), # then email_body_dict will be empty. # This check ensures we don't try to send an email with no body. if not email_body_dict: logger.warning( f"Email body (HTML and Text) is effectively empty for recipient {recipient}. Not sending." ) return # Try to send the email. try: response = client.send_email( Destination={ 'ToAddresses': [ recipient, ], }, Message={ 'Body': email_body_dict, 'Subject': { 'Charset': charset, 'Data': subject, }, }, Source=sender, SourceArn=senderArn, ReplyToAddresses=['devops@sonymusic-pde.com'], ) except ClientError as e: error_response = e.response['Error']['Message'] logger.error("Email sent to %s failed - %s.", recipient, error_response) else: message_id = response['MessageId'] logger.info("Email sent to %s MessageId %s.", recipient, message_id) return message_id def lambda_handler(event, context, payload=None, lambda_context=None, log_context=None): # noqa: C901 """AWS Lambda handler function that monitors IAM users' access keys and takes action based on their age. The function checks all IAM users with WatchDog enabled, identifies keys that are: 1. Expired (older than the ALERT_THRESHOLD) 2. About to expire (older than WARNING_THRESHOLD but younger than ALERT_THRESHOLD) Then takes appropriate actions based on key status and dry run mode setting. Args: event: AWS Lambda event object (unused in this implementation) context: AWS Lambda context object (unused in this implementation) payload: Optional payload data (unused in this implementation) lambda_context: Optional duplicate of context for Datadog wrapper compatibility log_context: Optional logging context data (unused in this implementation) Returns: Empty dictionary (JSON serializable response) """ # Initialize config and logger config = Config() iam_client = boto3.client('iam') ses_client = boto3.client('ses') is_dry_run = config.DRY_RUN aws_account_alias = iam_client.list_account_aliases().get('AccountAliases')[0] current_date = datetime.now(timezone.utc) for user in get_all_users(iam_client, config=config): is_notification_enabled = user.get('NotificationEnabled') if user.get('WatchDogEnabled'): for key_info in user.get('AccessKeys'): status = key_info.get('Status') access_key_id = key_info.get('AccessKeyId') access_key_id_masked = f"{access_key_id[:5]}**********{access_key_id[15:]}" created_date = key_info.get('CreateDate') if status != 'Inactive': key_age = days_diff(current_date, created_date) logger.debug( "User access key status", extra={ "user_name": user.get('name'), "access_key_id": access_key_id_masked, "created_date": str(created_date), # Ensure created_date is string for JSON serialization "key_age_days": key_age, "status": "active" } ) expiring_soon = ((key_age > config.WARNING_THRESHOLD) and (key_age < config.ALERT_THRESHOLD)) expired = key_age > config.ALERT_THRESHOLD in_use = is_key_in_use(iam_client, access_key_id) if expired and not in_use: if is_dry_run: logger.info( "[DRY RUN] Would delete unused key %s for user %s (age: %d days > threshold: %d days)", access_key_id_masked, user.get('name'), key_age, config.ALERT_THRESHOLD, extra={ "dry_run": True, "action": "delete_key", "user_name": user.get('name'), "access_key_id": access_key_id_masked, "key_age_days": key_age, "threshold_days": config.ALERT_THRESHOLD, "reason": "unused_expired_key" } ) else: logger.info( "%s's key %s unused > %s days. Deleting..", user.get('name'), access_key_id_masked, config.ALERT_THRESHOLD ) iam_client.delete_access_key( UserName=user.get('name'), AccessKeyId=access_key_id ) if not is_dry_run and is_notification_enabled: email_user( ses_client, user.get('email'), email_subject.render(access_key_deleted=True, days_old=key_age), email_body_html.render( access_key_deleted=True, full_name=user.get('FullName'), user_name=user.get('name'), access_key_id_masked=access_key_id_masked, aws_account_alias=aws_account_alias, threshold=config.ALERT_THRESHOLD, days_old=key_age, ), email_body_txt.render( access_key_deleted=True, full_name=user.get('FullName'), user_name=user.get('name'), access_key_id_masked=access_key_id_masked, aws_account_alias=aws_account_alias, threshold=config.ALERT_THRESHOLD, days_old=key_age, ), ) elif expired and in_use: # Check if the user is a service user if user.get('IsServiceUser'): logger.info( "Service user %s key %s: expired, in use, NOT deactivated (policy).", user.get('name'), access_key_id_masked ) continue # Skip deactivation for service users; continue to the next key # Proceed with deactivation for non-service (human) users if is_dry_run: logger.info( "[DRY RUN] Would deactivate key %s for user %s (age: %d days > threshold: %d days)", access_key_id_masked, user.get('name'), key_age, config.ALERT_THRESHOLD, extra={ "dry_run": True, "action": "deactivate_key", "user_name": user.get('name'), "access_key_id": access_key_id_masked, "key_age_days": key_age, "threshold_days": config.ALERT_THRESHOLD, "reason": "expired_key_in_use", "user_type": "human" } ) else: logger.info( "Human user %s key %s: in use, older than %s days. Attempting disable...", user.get('name'), access_key_id_masked, config.ALERT_THRESHOLD ) key_deactivated_successfully = False if not is_dry_run: try: iam_client.update_access_key( UserName=user.get('name'), AccessKeyId=access_key_id, Status='Inactive' ) key_deactivated_successfully = True logger.info( "Successfully deactivated access key %s for human user %s.", access_key_id_masked, user.get('name') ) except ClientError as e: logger.error( "Failed to deactivate access key %s for human user %s: %s", access_key_id_masked, user.get('name'), str(e) ) # Send notification ONLY if key was # actually deactivated (not dry_run and successful) # and notifications are enabled for the user. if key_deactivated_successfully and is_notification_enabled: logger.info( "Sending deactivation notification for human user %s, key %s.", user.get('name'), access_key_id_masked ) email_user( ses_client, user.get('email'), email_subject.render(access_key_expired=True, days_old=key_age), email_body_html.render( access_key_expired=True, full_name=user.get('FullName'), user_name=user.get('name'), access_key_id_masked=access_key_id_masked, aws_account_alias=aws_account_alias, threshold=config.ALERT_THRESHOLD, days_old=key_age, ), email_body_txt.render( access_key_expired=True, full_name=user.get('FullName'), user_name=user.get('name'), access_key_id_masked=access_key_id_masked, aws_account_alias=aws_account_alias, threshold=config.ALERT_THRESHOLD, days_old=key_age, ), ) elif is_dry_run and is_notification_enabled: # If it's a dry run, log that a notification would have been sent logger.info( "[DRY RUN] Would send deactivation notification for human user %s, key %s", user.get('name'), access_key_id_masked, extra={ "dry_run": True, "action": "send_notification", "notification_type": "key_deactivation", "user_name": user.get('name'), "access_key_id": access_key_id_masked, "email": user.get('email'), "key_age_days": key_age, "threshold_days": config.ALERT_THRESHOLD } ) elif not key_deactivated_successfully and not is_dry_run and is_notification_enabled: # If deactivation was attempted (not dry_run) but failed, log no email sent. logger.warning( "Key %s (user %s) NOT deactivated (error/inactive). No deactivation email.", access_key_id_masked, user.get('name') ) elif expiring_soon and in_use: if is_dry_run: logger.info( "[DRY RUN] Key %s for user %s expiring soon (age: %d days, warning: %d days)", access_key_id_masked, user.get('name'), key_age, config.WARNING_THRESHOLD, extra={ "dry_run": True, "action": "monitor", "status": "expiring_soon", "user_name": user.get('name'), "access_key_id": access_key_id_masked, "key_age_days": key_age, "warning_threshold_days": config.WARNING_THRESHOLD, "alert_threshold_days": config.ALERT_THRESHOLD } ) if is_notification_enabled: logger.info( "[DRY RUN] Would send expiration warning to user %s for key %s", user.get('name'), access_key_id_masked, extra={ "dry_run": True, "action": "send_notification", "notification_type": "expiring_soon", "user_name": user.get('name'), "access_key_id": access_key_id_masked, "email": user.get('email') } ) else: logger.info( "%s's access key %s is in use and it's expiring soon.", user.get('name'), access_key_id_masked ) if is_notification_enabled: email_user( ses_client, user.get('email'), email_subject.render(access_key_is_expiring_soon=True, days_old=key_age), email_body_html.render( access_key_is_expiring_soon=True, full_name=user.get('FullName'), user_name=user.get('name'), access_key_id_masked=access_key_id_masked, aws_account_alias=aws_account_alias, threshold=config.ALERT_THRESHOLD, days_old=key_age, ), email_body_txt.render( access_key_is_expiring_soon=True, full_name=user.get('FullName'), user_name=user.get('name'), access_key_id_masked=access_key_id_masked, aws_account_alias=aws_account_alias, threshold=config.ALERT_THRESHOLD, days_old=key_age, ), ) return {}