Skip to content

Commit c0cfdaa

Browse files
committed
Add unused access key policy
1 parent c5104a5 commit c0cfdaa

18 files changed

Lines changed: 237 additions & 15 deletions

File tree

POLICIES.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,7 @@ This tool support the following policies:
2424
monitoring the active connection count.
2525
* [s3_inactive](cloud_governance/policy/aws/s3_inactive.py): Identify the empty s3 buckets, causing the resource quota
2626
issues.
27+
* [unused_access_key](cloud_governance/policy/aws/unused_access_key.py): Identify user with unused active access key, causing the security issue
2728
* [empty_roles](cloud_governance/policy/aws/empty_roles.py): Identify the empty roles that do not have any attached
2829
policies to them.
2930
* [ebs_in_use](cloud_governance/policy/aws/ebs_in_use.py): list in use volumes.

README.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -35,6 +35,7 @@ List of Policies:
3535
- zombie_snapshots
3636
- unused_nat_gateway
3737
- s3_inactive
38+
- unused_access_key
3839
- empty_roles
3940
- tag_resources
4041
- tag_iam_user

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

Lines changed: 139 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@
55
from cloud_governance.common.clouds.aws.utils.common_methods import get_boto3_client
66
from cloud_governance.common.clouds.aws.utils.utils import Utils
77
from cloud_governance.common.logger.init_logger import logger
8+
from datetime import datetime, timezone
89

910

1011
class IAMOperations:
@@ -158,3 +159,141 @@ def untag_role(self, role_name: str, tags: list):
158159
return True
159160
except Exception as err:
160161
raise err
162+
163+
def tag_user(self, user_name: str, tags: list):
164+
"""
165+
This method tags the IAM user.
166+
:param user_name: The name of the IAM user to tag.
167+
:param tags: A list of tags to associate with the user.
168+
:return: True if tagging is successful, otherwise raises an exception.
169+
"""
170+
try:
171+
self.iam_client.tag_user(UserName=user_name, Tags=tags)
172+
return True
173+
except Exception as err:
174+
raise err
175+
176+
def get_iam_users_access_keys(self):
177+
"""
178+
Retrieves IAM users and summarizes:
179+
- Access key status (active/inactive)
180+
- Access key age in days
181+
- Access key last used in days (or "N/A" if never used)
182+
- Tags (as a list of dictionaries)
183+
- Most recent key usage: last_activity_days
184+
- IAM client region (global context, since IAM is non-regional)
185+
- IAM user unique ID: ResourceId
186+
187+
Returns:
188+
dict: {
189+
"username": {
190+
"Access key 1": [status, age_days, last_used_days],
191+
"Access key 2": [...],
192+
"last_activity_days": int or "N/A",
193+
"tags": [{"Key": "tag_key", "Value": "tag_value"}, ...],
194+
"region": "us-east-1",
195+
"ResourceId": "AIDAEXAMPLEUSERID"
196+
},
197+
...
198+
}
199+
"""
200+
result = {}
201+
now = datetime.now(timezone.utc)
202+
region_name = self.iam_client.meta.region_name or "global"
203+
204+
paginator = self.iam_client.get_paginator('list_users')
205+
for page in paginator.paginate():
206+
for user in page['Users']:
207+
username = user['UserName']
208+
result[username] = {}
209+
last_used_days_list = []
210+
211+
# Access keys
212+
access_keys = self.iam_client.list_access_keys(UserName=username)['AccessKeyMetadata']
213+
for idx, key in enumerate(access_keys, start=1):
214+
label = f"Access key {idx}"
215+
status = key['Status'].lower()
216+
age_days = (now - key['CreateDate']).days
217+
218+
# Get access key last used
219+
try:
220+
response = self.iam_client.get_access_key_last_used(AccessKeyId=key['AccessKeyId'])
221+
last_used_date = response.get('AccessKeyLastUsed', {}).get('LastUsedDate')
222+
if last_used_date:
223+
last_used_days = (now - last_used_date).days
224+
last_used_days_list.append(last_used_days)
225+
else:
226+
last_used_days = "N/A"
227+
except Exception:
228+
last_used_days = "N/A"
229+
230+
result[username][label] = [status, age_days, last_used_days]
231+
232+
# Most recent access key activity
233+
result[username]["last_activity_days"] = min(last_used_days_list) if last_used_days_list else "N/A"
234+
235+
# Tags as list of dicts
236+
try:
237+
tag_response = self.iam_client.list_user_tags(UserName=username)
238+
tags = tag_response.get('Tags', [])
239+
except Exception:
240+
tags = []
241+
242+
result[username]["tags"] = tags
243+
result[username]["region"] = region_name
244+
result[username]["ResourceId"] = user.get('UserId') # <-- Unique ID
245+
return result
246+
247+
def has_active_access_keys(self, username):
248+
"""
249+
Checks if the given IAM user has any active access keys.
250+
When no user access key return False
251+
Args:
252+
username (str): IAM user name
253+
254+
Returns:
255+
bool: True if any access key is active, False otherwise
256+
"""
257+
try:
258+
access_keys = self.iam_client.list_access_keys(UserName=username)['AccessKeyMetadata']
259+
except Exception as e:
260+
logger.error(f"Failed to list access keys for user '{username}': {e}")
261+
return False
262+
263+
for key in access_keys:
264+
if key.get('Status') == 'Active':
265+
return True
266+
return False
267+
268+
def deactivate_user_access_keys(self, username):
269+
"""
270+
Deactivates all active access keys for the given IAM user.
271+
272+
Args:
273+
username (str): IAM user name
274+
"""
275+
try:
276+
access_keys = self.iam_client.list_access_keys(UserName=username)['AccessKeyMetadata']
277+
except Exception as e:
278+
logger.error(f"Failed to list access keys for user '{username}': {e}")
279+
return
280+
281+
for idx, key in enumerate(access_keys, start=1):
282+
label = f"Access key {idx}"
283+
current_status = key['Status'].lower()
284+
access_key_id = key['AccessKeyId']
285+
286+
if current_status == 'active':
287+
try:
288+
self.iam_client.update_access_key(
289+
UserName=username,
290+
AccessKeyId=access_key_id,
291+
Status='Inactive'
292+
)
293+
logger.info(f"{label} deactivated for user '{username}'")
294+
except Exception as e:
295+
logger.error(f"Failed to deactivate {label} for user '{username}': {e}")
296+
else:
297+
logger.info(f"{label} is already inactive for user '{username}'")
298+
299+
logger.info(f"Deactivate access keys for user '{username}' have been processed.")

cloud_governance/common/elasticsearch/modals/policy_es_data.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -37,6 +37,8 @@ class PolicyEsMetaData(dict):
3737
launch_time: str = ''
3838
running_days: int = ''
3939
create_date: str = ''
40+
age_days: int = ''
41+
last_activity_days: int = ''
4042

4143
def __post_init__(self):
4244
"""

cloud_governance/common/utils/configs.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,8 @@
2222
EC2_NAMESPACE = 'AWS/EC2'
2323
CLOUDWATCH_METRICS_AVAILABLE_DAYS = 14
2424
AWS_DEFAULT_GLOBAL_REGION = 'us-east-1'
25+
UNUSED_ACCESS_KEY_DAYS = 90
26+
UNUSED_ACCESS_KEY_MAX_DAY = 1000
2527

2628
# X86 to Graviton
2729
GRAVITON_MAPPINGS = {

cloud_governance/main/environment_variables.py

Lines changed: 1 addition & 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',
101+
's3_inactive', 'unused_access_key',
102102
'empty_roles',
103103
'zombie_snapshots', 'skipped_resources',
104104
'monthly_report', 'optimize_resources_report']

cloud_governance/main/example.yaml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -23,7 +23,7 @@ ATHENA_ACCOUNT_SECRET_KEY: ""
2323

2424
non_cluster_policies: [ 'instance_run', 'unattached_volume', 'cluster_run',
2525
'ip_unattached', 'unused_nat_gateway', 'instance_idle',
26-
'ec2_stop', 'ebs_in_use', 'database_idle', 's3_inactive',
26+
'ec2_stop', 'ebs_in_use', 'database_idle', 's3_inactive', 'unused_access_key',
2727
'empty_roles', 'tag_resources', 'cost_usage_reports',
2828
'zombie_snapshots', 'skipped_resources',
2929
'monthly_report', 'optimize_resources_report' ]

cloud_governance/main/main_oerations/main_operations.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -42,7 +42,7 @@ def run(self):
4242
# @Todo support for all the aws policies, currently supports ec2_run as urgent requirement
4343
if self._policy in policies and self._policy in ["instance_run", "unattached_volume", "cluster_run",
4444
"ip_unattached", "unused_nat_gateway", "instance_idle",
45-
"zombie_snapshots", "database_idle", "s3_inactive",
45+
"zombie_snapshots", "database_idle", "s3_inactive", "unused_access_key",
4646
"empty_roles", "tag_resources", "cost_usage_reports"]:
4747
source = policy_type
4848
if Utils.equal_ignore_case(policy_type, self._public_cloud_name):

cloud_governance/policy/aws/monthly_report.py

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -37,7 +37,8 @@ def policy_description(self, policy_name: str):
3737
'ip_unattached': 'Delete all the elastic_ips that are unused',
3838
'unused_nat_gateway': ' Delete all unused nat gateways',
3939
'zombie_snapshots': 'Delete all the snapshots which the AMI does not use',
40-
's3_inactive': 'Delete the empty buckets which don’t have any content.',
40+
's3_inactive': 'Delete the empty buckets which don’t have any content',
41+
'unused_access_key': 'Deactivate user access keys that are still active but have not been used',
4142
'empty_roles': 'Delete the empty role which does\'t have any policies',
4243
'zombie_cluster_resource': 'Delete up the cluster resources which are not deleted while cleaning the cluster'
4344
}
Lines changed: 55 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,55 @@
1+
from cloud_governance.common.utils.configs import UNUSED_ACCESS_KEY_DAYS, UNUSED_ACCESS_KEY_MAX_DAY
2+
from cloud_governance.policy.helpers.aws.aws_policy_operations import AWSPolicyOperations
3+
4+
5+
class UnusedAccessKey(AWSPolicyOperations):
6+
RESOURCE_ACTION = "DeActivate"
7+
8+
def __init__(self):
9+
super().__init__()
10+
11+
def run_policy_operations(self):
12+
"""
13+
This method returns a list of users with at least one active access key whose last used date is greater than UNUSED_ACCESS_KEY_DAYS
14+
:return:
15+
:rtype:
16+
"""
17+
unused_access_keys = []
18+
iam_users_access_keys = self._get_iam_users_access_keys()
19+
for username, user_data in iam_users_access_keys.items():
20+
last_activity_days = user_data['last_activity_days']
21+
# Collect age_days only for active access keys
22+
age_days_list = [
23+
value[1] for key, value in user_data.items()
24+
if key.startswith("Access key") and isinstance(value, list) and value[0] == "active"
25+
]
26+
# "N/A"/None implies unused access key — fallback to UNUSED_ACCESS_KEY_MAX_DAY
27+
if last_activity_days == "N/A":
28+
last_activity_days = UNUSED_ACCESS_KEY_MAX_DAY
29+
age_days = min(age_days_list) if age_days_list else UNUSED_ACCESS_KEY_MAX_DAY
30+
region = user_data['region']
31+
user_name = username
32+
tags = user_data.get('Tags', [])
33+
cleanup_result = False
34+
cleanup_days = 0
35+
if int(last_activity_days) >= UNUSED_ACCESS_KEY_DAYS and self._has_active_access_keys(user_name) and self.get_skip_policy_value(tags=tags) not in ('NOTDELETE', 'SKIP'):
36+
resource_data = self._get_es_schema(resource_id=user_name,
37+
user=self.get_tag_name_from_tags(tags=tags, tag_name='User'),
38+
skip_policy=self.get_skip_policy_value(tags=tags),
39+
cleanup_days=cleanup_days,
40+
dry_run=self._dry_run,
41+
name=user_name,
42+
region=region,
43+
cleanup_result=str(cleanup_result),
44+
resource_action=self.RESOURCE_ACTION,
45+
cloud_name=self._cloud_name,
46+
resource_type='UnusedAccessKey',
47+
resource_state='Unused',
48+
age_days=age_days,
49+
last_activity_days=last_activity_days,
50+
unit_price=0)
51+
unused_access_keys.append(resource_data)
52+
if not cleanup_result:
53+
self.update_resource_day_count_tag(resource_id=user_name, cleanup_days=cleanup_days, tags=tags)
54+
55+
return unused_access_keys

0 commit comments

Comments
 (0)