Skip to content

Commit 027904c

Browse files
authored
Add delete_access_key policy, enhance unused_access_key policy (#974)
* Enable unused_access_key policy in action * Resolve pkg install error * Resolve pkg dependency issues * Resolve pkg dependency issues * Resolve pkg dependency issues * Resolve pkg dependency issues * Resolve pkg dependency issues * Modify config value * Unused access key policy changes * Review comments * Permissions for Policy changes * Add delete policy, modify unused key policy * Disable dry run no for unused_access_keys * Unittest files * Enable delete without tag * delete access key policy modifications * delete_access_key message changes
1 parent f149634 commit 027904c

19 files changed

Lines changed: 671 additions & 63 deletions

File tree

.gitignore

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -221,3 +221,6 @@ empty_test_environment_variables.py
221221
cloudsensei/.env.txt
222222
.vscode
223223
env.yaml
224+
225+
# macOS
226+
.DS_Store

cloud_governance/common/clouds/aws/iam/iam_operations.py

Lines changed: 76 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -175,6 +175,69 @@ def tag_user(self, user_name: str, tags: list):
175175
except Exception as err:
176176
raise err
177177

178+
def untag_user(self, user_name: str, tag_keys: list):
179+
"""
180+
Removes the given tag keys from the IAM user.
181+
:param user_name: The name of the IAM user.
182+
:param tag_keys: List of tag key names to remove (e.g. ['UnusedAccessKey1InactiveDate']).
183+
"""
184+
if not tag_keys:
185+
return
186+
try:
187+
self.iam_client.untag_user(UserName=user_name, TagKeys=tag_keys)
188+
logger.info(f"Untagged user '{user_name}': {tag_keys}")
189+
except Exception as err:
190+
logger.error(f"Failed to untag user '{user_name}': {err}")
191+
raise err
192+
193+
def delete_user_access_key(
194+
self,
195+
username: str,
196+
access_key_label: str,
197+
remove_inactive_tag: bool = True,
198+
access_key_id: str = None,
199+
):
200+
"""
201+
Deletes the specified access key for the given IAM user. Optionally removes the
202+
UnusedAccessKeyNInactiveDate tag (only when this policy had set it by deactivating the key).
203+
:param username: IAM user name.
204+
:param access_key_label: "Access key 1" or "Access key 2" (case-insensitive).
205+
:param remove_inactive_tag: If True, remove UnusedAccessKeyNInactiveDate after delete.
206+
Set False when the key was not deactivated by this policy (e.g. manually deactivated).
207+
:param access_key_id: Optional. When provided, delete this key by ID instead of resolving by
208+
label.
209+
"""
210+
access_key_label_lower = (access_key_label or '').lower()
211+
if not access_key_label_lower or 'access key' not in access_key_label_lower:
212+
logger.warning("Invalid access key label for deletion.")
213+
return
214+
key_num = access_key_label_lower.split()[-1]
215+
inactive_tag_key = f"UnusedAccessKey{key_num}InactiveDate"
216+
217+
if not access_key_id:
218+
try:
219+
access_keys = self.iam_client.list_access_keys(UserName=username)['AccessKeyMetadata']
220+
except Exception as e:
221+
logger.error(f"Failed to list access keys for user '{username}': {e}")
222+
raise
223+
access_keys.sort(key=lambda k: k['CreateDate'])
224+
idx = self.ACCESS_KEY_LABEL_MAP.get(access_key_label_lower)
225+
if idx is None or idx >= len(access_keys):
226+
logger.warning(f"Access key label '{access_key_label}' not found for user '{username}'")
227+
return
228+
access_key_id = access_keys[idx]['AccessKeyId']
229+
230+
try:
231+
self.iam_client.delete_access_key(UserName=username, AccessKeyId=access_key_id)
232+
logger.info(f"Deleted access key '{access_key_id}' for user '{username}'")
233+
except Exception as e:
234+
logger.error(f"Failed to delete access key '{access_key_id}' for user '{username}': {e}")
235+
raise
236+
tag_keys_to_remove = [access_key_id]
237+
if remove_inactive_tag:
238+
tag_keys_to_remove.append(inactive_tag_key)
239+
self.untag_user(username, tag_keys_to_remove)
240+
178241
def get_iam_users_access_keys(self):
179242
"""
180243
Retrieves IAM users and summarizes:
@@ -208,8 +271,8 @@ def get_iam_users_access_keys(self):
208271
for user in page['Users']:
209272
username = user['UserName']
210273
result[username] = {}
211-
# Access keys
212274
access_keys = self.iam_client.list_access_keys(UserName=username)['AccessKeyMetadata']
275+
access_keys = sorted(access_keys, key=lambda k: k['CreateDate'])
213276
for idx, key in enumerate(access_keys, start=1):
214277
label = f"Access key {idx}"
215278
status = key['Status'].lower()
@@ -229,7 +292,13 @@ def get_iam_users_access_keys(self):
229292
logger.error(f"Failed to get last used date for access key")
230293
last_used_days = None
231294

232-
result[username][label] = {'label': label, 'status': status, 'age_days': age_days, 'last_activity_days': last_used_days}
295+
result[username][label] = {
296+
'label': label,
297+
'status': status,
298+
'age_days': age_days,
299+
'last_activity_days': last_used_days,
300+
'access_key_id': key['AccessKeyId'],
301+
}
233302

234303
# Tags as list of dicts
235304
try:
@@ -312,6 +381,11 @@ def deactivate_user_access_key(self, username: str, **kwargs):
312381
Status='Inactive'
313382
)
314383
logger.info(f"Access key '{access_key_id}' deactivated for user '{username}'")
384+
# Tag the user so we only delete keys we deactivated (after DELETE_ACCESS_KEY_DAYS)
385+
key_num = access_key_label.split()[-1]
386+
inactive_tag_key = f"UnusedAccessKey{key_num}InactiveDate"
387+
inactive_date = datetime.now(timezone.utc).strftime("%Y-%m-%d")
388+
self.tag_user(username, [{'Key': inactive_tag_key, 'Value': inactive_date}])
315389
except Exception as e:
316390
logger.error(f"Failed to deactivate access key '{access_key_id}' for user '{username}': {e}")
317391
else:

cloud_governance/common/mails/mail_message.py

Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@
33
from jinja2 import Environment, FileSystemLoader
44

55
from cloud_governance.common.ldap.ldap_search import LdapSearch
6+
from cloud_governance.common.utils.configs import DELETE_ACCESS_KEY_DAYS
67
from cloud_governance.main.environment_variables import environment_variables
78

89

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

109+
def _get_unused_access_key_alert_message(self):
110+
"""
111+
Returns the custom message for unused_access_key policy in aggregated alerts.
112+
"""
113+
return (
114+
f"For the IAM access keys listed below: please rotate these access keys before they are "
115+
f"automatically deactivated. Keys will be deactivated after the grace period if no action is taken. "
116+
f"Keys older than {DELETE_ACCESS_KEY_DAYS} days (including deactivated ones) will be permanently deleted. "
117+
"To avoid deactivation, create a new access key and update your applications, then deactivate or delete the old key."
118+
)
119+
120+
def _get_delete_access_key_alert_message(self):
121+
"""
122+
Returns the custom message for delete_access_key policy in aggregated alerts.
123+
"""
124+
return (
125+
f"Your IAM access key age has exceeded {DELETE_ACCESS_KEY_DAYS} days. "
126+
"The key(s) listed below are in the deletion grace period and will be permanently deleted "
127+
"if no action is taken. Please rotate or remove these keys before the grace period ends."
128+
)
129+
108130
def aws_user_over_usage_cost(self, user: str, usage_cost: int, name: str, user_usage: int):
109131
"""
110132
This method send subject, body to over usage cost
@@ -517,5 +539,18 @@ def get_policy_alert_message(self, policy_data: list, user: str = ''):
517539
template_loader = self.env_loader.get_template('policy_alert_agg_message.j2')
518540
columns = ['User', 'PublicCloud', 'policy', 'RegionName', 'ResourceId', 'Name', 'DeleteDate']
519541
context = {'records': policy_data, 'columns': columns, 'User': user, 'account': self.account, 'cloud_name': self.__public_cloud_name}
542+
has_unused_access_key = any(
543+
(r.get('policy') or r.get('Policy') or '').lower() == 'unused_access_key'
544+
for r in (policy_data or [])
545+
)
546+
has_delete_access_key = any(
547+
(r.get('policy') or r.get('Policy') or '').lower() == 'delete_access_key'
548+
for r in (policy_data or [])
549+
)
550+
if has_unused_access_key:
551+
context['unused_access_key_message'] = self._get_unused_access_key_alert_message()
552+
if has_delete_access_key:
553+
context['delete_access_key_days'] = DELETE_ACCESS_KEY_DAYS
554+
context['delete_access_key_message'] = self._get_delete_access_key_alert_message()
520555
body = template_loader.render(context)
521556
return subject, body

cloud_governance/common/mails/templates/policy_alert_agg_message.j2

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -44,6 +44,12 @@
4444
<div style="margin-bottom: 10px">
4545
<p>You can find below your unused resources in the {{ cloud_name }} account ({{ account }}).</p>
4646
<p>If you want to keep them, please add "Policy=Not_Delete" or "Policy=skip" tag for each resource</p>
47+
{% if unused_access_key_message is defined and unused_access_key_message %}
48+
<p><strong>Unused access keys:</strong> {{ unused_access_key_message }}</p>
49+
{% endif %}
50+
{% if delete_access_key_message is defined and delete_access_key_message %}
51+
<p><strong>Access keys pending deletion (inactive, key age &gt; {{ delete_access_key_days }} days):</strong> {{ delete_access_key_message }}</p>
52+
{% endif %}
4753
</div>
4854
<table class="table-hover">
4955
<thead>

cloud_governance/common/utils/configs.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -23,7 +23,7 @@
2323
CLOUDWATCH_METRICS_AVAILABLE_DAYS = 14
2424
AWS_DEFAULT_GLOBAL_REGION = 'us-east-1'
2525
UNUSED_ACCESS_KEY_DAYS = 90
26-
UNUSED_ACCESS_KEY_MAX_DAY = 1000
26+
DELETE_ACCESS_KEY_DAYS = 365
2727

2828
# X86 to Graviton
2929
GRAVITON_MAPPINGS = {

cloud_governance/main/environment_variables.py

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -98,7 +98,7 @@ def __init__(self):
9898
'ip_unattached', 'unused_nat_gateway',
9999
'instance_idle',
100100
'ec2_stop', 'ebs_in_use', 'database_idle',
101-
's3_inactive', 'unused_access_key',
101+
's3_inactive', 'unused_access_key', 'delete_access_key',
102102
'empty_roles',
103103
'zombie_snapshots', 'skipped_resources',
104104
'monthly_report', 'optimize_resources_report']
@@ -121,6 +121,7 @@ def __init__(self):
121121
self._environment_variables_dict['user_tags'] = EnvironmentVariables.get_env('user_tags', '')
122122
self._environment_variables_dict['user_tag_operation'] = EnvironmentVariables.get_env('user_tag_operation', '')
123123
self._environment_variables_dict['username'] = EnvironmentVariables.get_env('username', '')
124+
self._environment_variables_dict['DELETE_INACTIVE_KEYS_WITHOUT_TAG'] = EnvironmentVariables.get_env('DELETE_INACTIVE_KEYS_WITHOUT_TAG', 'true').lower() == 'true'
124125
self._environment_variables_dict['remove_tags'] = EnvironmentVariables.get_env('remove_tags', '')
125126
self._environment_variables_dict['resource'] = EnvironmentVariables.get_env('resource', '')
126127
self._environment_variables_dict['cluster_tag'] = EnvironmentVariables.get_env('cluster_tag', '')

cloud_governance/main/main_oerations/main_operations.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -43,7 +43,7 @@ def run(self):
4343
if self._policy in policies and self._policy in ["instance_run", "unattached_volume", "cluster_run",
4444
"ip_unattached", "unused_nat_gateway", "instance_idle",
4545
"zombie_snapshots", "database_idle", "s3_inactive", "unused_access_key",
46-
"empty_roles", "tag_resources", "cost_usage_reports"]:
46+
"delete_access_key", "empty_roles", "tag_resources", "cost_usage_reports"]:
4747
source = policy_type
4848
if Utils.equal_ignore_case(policy_type, self._public_cloud_name):
4949
source = ''
Lines changed: 71 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,71 @@
1+
"""
2+
Policy to delete inactive IAM access keys older than DELETE_ACCESS_KEY_DAYS.
3+
"""
4+
from cloud_governance.common.utils.configs import DELETE_ACCESS_KEY_DAYS
5+
from cloud_governance.policy.helpers.aws.aws_policy_operations import AWSPolicyOperations
6+
7+
8+
class DeleteAccessKey(AWSPolicyOperations):
9+
RESOURCE_ACTION = "Delete"
10+
11+
def run_policy_operations(self):
12+
"""
13+
For inactive keys with age > DELETE_ACCESS_KEY_DAYS: apply a grace period
14+
(deletion_grace_days = age_days - DELETE_ACCESS_KEY_DAYS, capped at DAYS_TO_TAKE_ACTION). During
15+
grace period write to ES with cleanup_days 1..7 so send_aggregated_alerts sends
16+
reminder emails. After grace period, delete the key.
17+
"""
18+
result = []
19+
days_to_take_action = int(self._days_to_take_action)
20+
iam_users_access_keys = self._get_iam_users_access_keys()
21+
22+
for username, user_data in iam_users_access_keys.items():
23+
tags = user_data.get('tags', user_data.get('Tags', []))
24+
region = user_data['region']
25+
user_name = username
26+
27+
for access_key_label, access_key_data in user_data.items():
28+
if 'access key' not in access_key_label.lower():
29+
continue
30+
age_days = access_key_data.get('age_days')
31+
status = (access_key_data.get('status') or '').lower()
32+
if age_days is None or status != 'inactive':
33+
continue
34+
age_days = int(age_days)
35+
36+
key_num = access_key_label.split()[-1]
37+
inactive_tag_key = f"UnusedAccessKey{key_num}InactiveDate"
38+
inactive_date_str = self.get_tag_name_from_tags(tags=tags, tag_name=inactive_tag_key)
39+
delete_all_inactive = self._environment_variables_dict.get('DELETE_INACTIVE_KEYS_WITHOUT_TAG', False)
40+
if not (age_days > DELETE_ACCESS_KEY_DAYS and (inactive_date_str or delete_all_inactive)):
41+
continue
42+
43+
deletion_grace_days = min(age_days - DELETE_ACCESS_KEY_DAYS, days_to_take_action)
44+
cleanup_result = self.verify_and_delete_resource(
45+
resource_id=user_name,
46+
tags=tags,
47+
clean_up_days=deletion_grace_days,
48+
access_key_label=access_key_label,
49+
access_key_id=access_key_data.get('access_key_id'),
50+
remove_inactive_tag=bool(inactive_date_str),
51+
)
52+
resource_data = self._get_es_schema(
53+
resource_id=user_name,
54+
user=self.get_tag_name_from_tags(tags=tags, tag_name='User'),
55+
skip_policy=self.get_skip_policy_value(tags=tags),
56+
cleanup_days=deletion_grace_days,
57+
dry_run=self._dry_run,
58+
name=user_name,
59+
region=region,
60+
cleanup_result=str(cleanup_result),
61+
resource_action=self.RESOURCE_ACTION,
62+
cloud_name=self._cloud_name,
63+
resource_type='UnusedAccessKey',
64+
resource_state='Inactive',
65+
age_days=age_days,
66+
last_activity_days=access_key_data.get('last_activity_days'),
67+
unit_price=0,
68+
)
69+
result.append(resource_data)
70+
71+
return result

0 commit comments

Comments
 (0)