Skip to content

Commit 2479a33

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

19 files changed

Lines changed: 267 additions & 19 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: 157 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,10 +5,13 @@
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:
1112

13+
ACCESS_KEY_LABEL_MAP = {"access key 1": 0, "access key 2": 1}
14+
1215
def __init__(self, iam_client=None):
1316
self.iam_client = iam_client if iam_client else get_boto3_client('iam')
1417
self.utils = Utils()
@@ -158,3 +161,157 @@ def untag_role(self, role_name: str, tags: list):
158161
return True
159162
except Exception as err:
160163
raise err
164+
165+
def tag_user(self, user_name: str, tags: list):
166+
"""
167+
This method tags the IAM user.
168+
:param user_name: The name of the IAM user to tag.
169+
:param tags: A list of tags to associate with the user.
170+
:return: True if tagging is successful, otherwise raises an exception.
171+
"""
172+
try:
173+
self.iam_client.tag_user(UserName=user_name, Tags=tags)
174+
return True
175+
except Exception as err:
176+
raise err
177+
178+
def get_iam_users_access_keys(self):
179+
"""
180+
Retrieves IAM users and summarizes:
181+
- Access key status (active/inactive)
182+
- Access key age in days
183+
- Access key last used in days (or "N/A" if never used)
184+
- Tags (as a list of dictionaries)
185+
- Most recent key usage: last_activity_days
186+
- IAM client region (global context, since IAM is non-regional)
187+
- IAM user unique ID: ResourceId
188+
189+
Returns:
190+
dict: {
191+
"username": {
192+
"Access key 1": [status, age_days, last_used_days],
193+
"Access key 2": [...],
194+
"last_activity_days": int or "N/A",
195+
"tags": [{"Key": "tag_key", "Value": "tag_value"}, ...],
196+
"region": "us-east-1",
197+
"ResourceId": "AIDAEXAMPLEUSERID"
198+
},
199+
...
200+
}
201+
"""
202+
result = {}
203+
now = datetime.now(timezone.utc)
204+
region_name = self.iam_client.meta.region_name or "global"
205+
206+
paginator = self.iam_client.get_paginator('list_users')
207+
for page in paginator.paginate():
208+
for user in page['Users']:
209+
username = user['UserName']
210+
result[username] = {}
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+
else:
225+
last_used_days = "N/A"
226+
except Exception:
227+
last_used_days = "N/A"
228+
229+
result[username][label] = {'label': label, 'status': status, 'age_days': age_days, 'last_activity_days': last_used_days if last_used_days is not None else "N/A"}
230+
231+
# Tags as list of dicts
232+
try:
233+
tag_response = self.iam_client.list_user_tags(UserName=username)
234+
tags = tag_response.get('Tags', [])
235+
except Exception:
236+
tags = []
237+
238+
result[username]["tags"] = tags
239+
result[username]["region"] = region_name
240+
result[username]["ResourceId"] = user.get('UserId') # <-- Unique ID
241+
242+
return result
243+
244+
def has_active_access_keys(self, username: str, access_key_label: str = None) -> bool:
245+
"""
246+
Checks if the given IAM user has any active access keys.
247+
Optionally filters by access key label ("Access Key 1" or "Access Key 2").
248+
249+
Args:
250+
username (str): IAM user name
251+
access_key_label (str): Label to filter access keys ("Access Key 1"/"Access Key 2")
252+
253+
Returns:
254+
bool: True if any access key is active (and matches the label if provided), False otherwise
255+
"""
256+
try:
257+
keys = self.iam_client.list_access_keys(UserName=username)['AccessKeyMetadata']
258+
except Exception as e:
259+
logger.error(f"Failed to list access keys for user '{username}': {e}")
260+
return False
261+
262+
# Sort keys by CreateDate ascending (oldest first)
263+
keys.sort(key=lambda k: k['CreateDate'])
264+
265+
if access_key_label:
266+
idx = self.ACCESS_KEY_LABEL_MAP.get(access_key_label.lower())
267+
if idx is None or idx >= len(keys):
268+
return False
269+
return keys[idx].get('Status') == 'Active'
270+
271+
return any(k.get('Status') == 'Active' for k in keys)
272+
273+
def deactivate_user_access_key(self, username: str, **kwargs):
274+
"""
275+
Deactivates the specified access key for the given IAM user.
276+
277+
Args:
278+
username (str): IAM user name
279+
access_key_label (str): Access Key 1 or Access Key 2 (case-insensitive)
280+
"""
281+
access_key_label = kwargs.get('access_key_label', '').lower()
282+
if not access_key_label:
283+
logger.warning("No access key label provided for deactivation.")
284+
return
285+
286+
try:
287+
access_keys = self.iam_client.list_access_keys(UserName=username)['AccessKeyMetadata']
288+
except Exception as e:
289+
logger.error(f"Failed to list access keys for user '{username}': {e}")
290+
return
291+
292+
# Sort keys by CreateDate ascending (oldest first) for consistent indexing
293+
access_keys.sort(key=lambda k: k['CreateDate'])
294+
295+
idx = self.ACCESS_KEY_LABEL_MAP.get(access_key_label)
296+
if idx is None or idx >= len(access_keys):
297+
logger.warning(f"Access key label '{access_key_label}' not found for user '{username}'")
298+
return
299+
300+
key_to_deactivate = access_keys[idx]
301+
access_key_id = key_to_deactivate['AccessKeyId']
302+
current_status = key_to_deactivate['Status'].lower()
303+
304+
if current_status == 'active':
305+
try:
306+
self.iam_client.update_access_key(
307+
UserName=username,
308+
AccessKeyId=access_key_id,
309+
Status='Inactive'
310+
)
311+
logger.info(f"Access key '{access_key_id}' deactivated for user '{username}'")
312+
except Exception as e:
313+
logger.error(f"Failed to deactivate access key '{access_key_id}' for user '{username}': {e}")
314+
else:
315+
logger.info(f"Access key '{access_key_id}' is already inactive for user '{username}'")
316+
317+
logger.info(f"Access key deactivation processed for user '{username}'.")

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+
for access_key_label, access_key_data in user_data.items():
21+
if 'access key' in access_key_label.lower():
22+
last_activity_days = access_key_data['last_activity_days']
23+
age_days = access_key_data['age_days']
24+
# if access key last_activity_days is "N/A", use age_days
25+
if last_activity_days == "N/A":
26+
last_activity_days = age_days
27+
region = user_data['region']
28+
user_name = username
29+
tags = user_data.get('Tags', [])
30+
cleanup_result = False
31+
cleanup_days = 0
32+
if int(last_activity_days) >= UNUSED_ACCESS_KEY_DAYS and self._has_active_access_keys(user_name, access_key_label) and self.get_skip_policy_value(tags=tags) not in ('NOTDELETE', 'SKIP'):
33+
cleanup_days = self.get_clean_up_days_count(tags=tags)
34+
cleanup_result = self.verify_and_delete_resource(resource_id=user_name, tags=tags,
35+
clean_up_days=cleanup_days, access_key_label=access_key_label)
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=access_key_label,
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='Active',
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)