Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
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
3 changes: 3 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -221,3 +221,6 @@ empty_test_environment_variables.py
cloudsensei/.env.txt
.vscode
env.yaml

# macOS
.DS_Store
78 changes: 76 additions & 2 deletions cloud_governance/common/clouds/aws/iam/iam_operations.py
Original file line number Diff line number Diff line change
Expand Up @@ -175,6 +175,69 @@ def tag_user(self, user_name: str, tags: list):
except Exception as err:
raise err

def untag_user(self, user_name: str, tag_keys: list):
"""
Comment thread
pragya811 marked this conversation as resolved.
Removes the given tag keys from the IAM user.
:param user_name: The name of the IAM user.
:param tag_keys: List of tag key names to remove (e.g. ['UnusedAccessKey1InactiveDate']).
"""
if not tag_keys:
return
try:
self.iam_client.untag_user(UserName=user_name, TagKeys=tag_keys)
logger.info(f"Untagged user '{user_name}': {tag_keys}")
except Exception as err:
logger.error(f"Failed to untag user '{user_name}': {err}")
raise err

def delete_user_access_key(
self,
username: str,
access_key_label: str,
remove_inactive_tag: bool = True,
access_key_id: str = None,
):
"""
Deletes the specified access key for the given IAM user. Optionally removes the
UnusedAccessKeyNInactiveDate tag (only when this policy had set it by deactivating the key).
:param username: IAM user name.
:param access_key_label: "Access key 1" or "Access key 2" (case-insensitive).
:param remove_inactive_tag: If True, remove UnusedAccessKeyNInactiveDate after delete.
Set False when the key was not deactivated by this policy (e.g. manually deactivated).
:param access_key_id: Optional. When provided, delete this key by ID instead of resolving by
label.
"""
access_key_label_lower = (access_key_label or '').lower()
if not access_key_label_lower or 'access key' not in access_key_label_lower:
logger.warning("Invalid access key label for deletion.")
return
key_num = access_key_label_lower.split()[-1]
inactive_tag_key = f"UnusedAccessKey{key_num}InactiveDate"

if not access_key_id:
try:
access_keys = self.iam_client.list_access_keys(UserName=username)['AccessKeyMetadata']
except Exception as e:
logger.error(f"Failed to list access keys for user '{username}': {e}")
raise
access_keys.sort(key=lambda k: k['CreateDate'])
idx = self.ACCESS_KEY_LABEL_MAP.get(access_key_label_lower)
if idx is None or idx >= len(access_keys):
logger.warning(f"Access key label '{access_key_label}' not found for user '{username}'")
return
access_key_id = access_keys[idx]['AccessKeyId']

try:
self.iam_client.delete_access_key(UserName=username, AccessKeyId=access_key_id)
logger.info(f"Deleted access key '{access_key_id}' for user '{username}'")
except Exception as e:
logger.error(f"Failed to delete access key '{access_key_id}' for user '{username}': {e}")
raise
tag_keys_to_remove = [access_key_id]
if remove_inactive_tag:
tag_keys_to_remove.append(inactive_tag_key)
self.untag_user(username, tag_keys_to_remove)

def get_iam_users_access_keys(self):
"""
Retrieves IAM users and summarizes:
Expand Down Expand Up @@ -208,8 +271,8 @@ def get_iam_users_access_keys(self):
for user in page['Users']:
username = user['UserName']
result[username] = {}
# Access keys
access_keys = self.iam_client.list_access_keys(UserName=username)['AccessKeyMetadata']
access_keys = sorted(access_keys, key=lambda k: k['CreateDate'])
for idx, key in enumerate(access_keys, start=1):
label = f"Access key {idx}"
status = key['Status'].lower()
Expand All @@ -229,7 +292,13 @@ def get_iam_users_access_keys(self):
logger.error(f"Failed to get last used date for access key")
last_used_days = None

result[username][label] = {'label': label, 'status': status, 'age_days': age_days, 'last_activity_days': last_used_days}
result[username][label] = {
'label': label,
'status': status,
'age_days': age_days,
'last_activity_days': last_used_days,
'access_key_id': key['AccessKeyId'],
}

# Tags as list of dicts
try:
Expand Down Expand Up @@ -312,6 +381,11 @@ def deactivate_user_access_key(self, username: str, **kwargs):
Status='Inactive'
)
logger.info(f"Access key '{access_key_id}' deactivated for user '{username}'")
# Tag the user so we only delete keys we deactivated (after DELETE_ACCESS_KEY_DAYS)
key_num = access_key_label.split()[-1]
inactive_tag_key = f"UnusedAccessKey{key_num}InactiveDate"
inactive_date = datetime.now(timezone.utc).strftime("%Y-%m-%d")
self.tag_user(username, [{'Key': inactive_tag_key, 'Value': inactive_date}])
except Exception as e:
logger.error(f"Failed to deactivate access key '{access_key_id}' for user '{username}': {e}")
else:
Comment thread
pragya811 marked this conversation as resolved.
Expand Down
35 changes: 35 additions & 0 deletions cloud_governance/common/mails/mail_message.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@
from jinja2 import Environment, FileSystemLoader

from cloud_governance.common.ldap.ldap_search import LdapSearch
from cloud_governance.common.utils.configs import DELETE_ACCESS_KEY_DAYS
from cloud_governance.main.environment_variables import environment_variables


Expand Down Expand Up @@ -105,6 +106,27 @@ def iam_user_add_tags(self, name: str, user: str, spreadsheet_id: str):
Cloud-governance Team""".strip()
return subject, body

def _get_unused_access_key_alert_message(self):
"""
Returns the custom message for unused_access_key policy in aggregated alerts.
"""
return (
f"For the IAM access keys listed below: please rotate these access keys before they are "
f"automatically deactivated. Keys will be deactivated after the grace period if no action is taken. "
f"Keys older than {DELETE_ACCESS_KEY_DAYS} days (including deactivated ones) will be permanently deleted. "
"To avoid deactivation, create a new access key and update your applications, then deactivate or delete the old key."
)

def _get_delete_access_key_alert_message(self):
"""
Returns the custom message for delete_access_key policy in aggregated alerts.
"""
return (
f"Your IAM access key age has exceeded {DELETE_ACCESS_KEY_DAYS} days. "
"The key(s) listed below are in the deletion grace period and will be permanently deleted "
"if no action is taken. Please rotate or remove these keys before the grace period ends."
)

def aws_user_over_usage_cost(self, user: str, usage_cost: int, name: str, user_usage: int):
"""
This method send subject, body to over usage cost
Expand Down Expand Up @@ -517,5 +539,18 @@ def get_policy_alert_message(self, policy_data: list, user: str = ''):
template_loader = self.env_loader.get_template('policy_alert_agg_message.j2')
columns = ['User', 'PublicCloud', 'policy', 'RegionName', 'ResourceId', 'Name', 'DeleteDate']
context = {'records': policy_data, 'columns': columns, 'User': user, 'account': self.account, 'cloud_name': self.__public_cloud_name}
has_unused_access_key = any(
(r.get('policy') or r.get('Policy') or '').lower() == 'unused_access_key'
for r in (policy_data or [])
)
has_delete_access_key = any(
(r.get('policy') or r.get('Policy') or '').lower() == 'delete_access_key'
for r in (policy_data or [])
)
if has_unused_access_key:
context['unused_access_key_message'] = self._get_unused_access_key_alert_message()
if has_delete_access_key:
context['delete_access_key_days'] = DELETE_ACCESS_KEY_DAYS
context['delete_access_key_message'] = self._get_delete_access_key_alert_message()
body = template_loader.render(context)
return subject, body
Original file line number Diff line number Diff line change
Expand Up @@ -44,6 +44,12 @@
<div style="margin-bottom: 10px">
<p>You can find below your unused resources in the {{ cloud_name }} account ({{ account }}).</p>
<p>If you want to keep them, please add "Policy=Not_Delete" or "Policy=skip" tag for each resource</p>
{% if unused_access_key_message is defined and unused_access_key_message %}
<p><strong>Unused access keys:</strong> {{ unused_access_key_message }}</p>
{% endif %}
{% if delete_access_key_message is defined and delete_access_key_message %}
<p><strong>Access keys pending deletion (inactive, key age &gt; {{ delete_access_key_days }} days):</strong> {{ delete_access_key_message }}</p>
{% endif %}
</div>
<table class="table-hover">
<thead>
Expand Down
2 changes: 1 addition & 1 deletion cloud_governance/common/utils/configs.py
Original file line number Diff line number Diff line change
Expand Up @@ -23,7 +23,7 @@
CLOUDWATCH_METRICS_AVAILABLE_DAYS = 14
AWS_DEFAULT_GLOBAL_REGION = 'us-east-1'
UNUSED_ACCESS_KEY_DAYS = 90
UNUSED_ACCESS_KEY_MAX_DAY = 1000
DELETE_ACCESS_KEY_DAYS = 365

# X86 to Graviton
GRAVITON_MAPPINGS = {
Expand Down
3 changes: 2 additions & 1 deletion cloud_governance/main/environment_variables.py
Original file line number Diff line number Diff line change
Expand Up @@ -98,7 +98,7 @@ def __init__(self):
'ip_unattached', 'unused_nat_gateway',
'instance_idle',
'ec2_stop', 'ebs_in_use', 'database_idle',
's3_inactive', 'unused_access_key',
's3_inactive', 'unused_access_key', 'delete_access_key',
'empty_roles',
'zombie_snapshots', 'skipped_resources',
'monthly_report', 'optimize_resources_report']
Expand All @@ -121,6 +121,7 @@ def __init__(self):
self._environment_variables_dict['user_tags'] = EnvironmentVariables.get_env('user_tags', '')
self._environment_variables_dict['user_tag_operation'] = EnvironmentVariables.get_env('user_tag_operation', '')
self._environment_variables_dict['username'] = EnvironmentVariables.get_env('username', '')
self._environment_variables_dict['DELETE_INACTIVE_KEYS_WITHOUT_TAG'] = EnvironmentVariables.get_env('DELETE_INACTIVE_KEYS_WITHOUT_TAG', 'true').lower() == 'true'
Comment thread
pragya811 marked this conversation as resolved.
self._environment_variables_dict['remove_tags'] = EnvironmentVariables.get_env('remove_tags', '')
self._environment_variables_dict['resource'] = EnvironmentVariables.get_env('resource', '')
self._environment_variables_dict['cluster_tag'] = EnvironmentVariables.get_env('cluster_tag', '')
Expand Down
2 changes: 1 addition & 1 deletion cloud_governance/main/main_oerations/main_operations.py
Original file line number Diff line number Diff line change
Expand Up @@ -43,7 +43,7 @@ def run(self):
if self._policy in policies and self._policy in ["instance_run", "unattached_volume", "cluster_run",
"ip_unattached", "unused_nat_gateway", "instance_idle",
"zombie_snapshots", "database_idle", "s3_inactive", "unused_access_key",
"empty_roles", "tag_resources", "cost_usage_reports"]:
"delete_access_key", "empty_roles", "tag_resources", "cost_usage_reports"]:
source = policy_type
if Utils.equal_ignore_case(policy_type, self._public_cloud_name):
source = ''
Expand Down
71 changes: 71 additions & 0 deletions cloud_governance/policy/aws/delete_access_key.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,71 @@
"""
Policy to delete inactive IAM access keys older than DELETE_ACCESS_KEY_DAYS.
"""
from cloud_governance.common.utils.configs import DELETE_ACCESS_KEY_DAYS
from cloud_governance.policy.helpers.aws.aws_policy_operations import AWSPolicyOperations


class DeleteAccessKey(AWSPolicyOperations):
RESOURCE_ACTION = "Delete"

def run_policy_operations(self):
"""
For inactive keys with age > DELETE_ACCESS_KEY_DAYS: apply a grace period
(deletion_grace_days = age_days - DELETE_ACCESS_KEY_DAYS, capped at DAYS_TO_TAKE_ACTION). During
grace period write to ES with cleanup_days 1..7 so send_aggregated_alerts sends
reminder emails. After grace period, delete the key.
"""
result = []
days_to_take_action = int(self._days_to_take_action)
iam_users_access_keys = self._get_iam_users_access_keys()

for username, user_data in iam_users_access_keys.items():
tags = user_data.get('tags', user_data.get('Tags', []))
region = user_data['region']
user_name = username

for access_key_label, access_key_data in user_data.items():
if 'access key' not in access_key_label.lower():
continue
age_days = access_key_data.get('age_days')
status = (access_key_data.get('status') or '').lower()
if age_days is None or status != 'inactive':
continue
age_days = int(age_days)

key_num = access_key_label.split()[-1]
inactive_tag_key = f"UnusedAccessKey{key_num}InactiveDate"
inactive_date_str = self.get_tag_name_from_tags(tags=tags, tag_name=inactive_tag_key)
delete_all_inactive = self._environment_variables_dict.get('DELETE_INACTIVE_KEYS_WITHOUT_TAG', False)
if not (age_days > DELETE_ACCESS_KEY_DAYS and (inactive_date_str or delete_all_inactive)):
continue

deletion_grace_days = min(age_days - DELETE_ACCESS_KEY_DAYS, days_to_take_action)
cleanup_result = self.verify_and_delete_resource(
resource_id=user_name,
tags=tags,
clean_up_days=deletion_grace_days,
access_key_label=access_key_label,
access_key_id=access_key_data.get('access_key_id'),
remove_inactive_tag=bool(inactive_date_str),
)
resource_data = self._get_es_schema(
resource_id=user_name,
user=self.get_tag_name_from_tags(tags=tags, tag_name='User'),
skip_policy=self.get_skip_policy_value(tags=tags),
cleanup_days=deletion_grace_days,
dry_run=self._dry_run,
name=user_name,
region=region,
cleanup_result=str(cleanup_result),
resource_action=self.RESOURCE_ACTION,
cloud_name=self._cloud_name,
resource_type='UnusedAccessKey',
resource_state='Inactive',
age_days=age_days,
last_activity_days=access_key_data.get('last_activity_days'),
unit_price=0,
)
result.append(resource_data)

return result
Loading