runbooks 0.7.9__py3-none-any.whl → 0.9.1__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- runbooks/__init__.py +1 -1
- runbooks/cfat/README.md +12 -1
- runbooks/cfat/__init__.py +1 -1
- runbooks/cfat/assessment/compliance.py +4 -1
- runbooks/cfat/assessment/runner.py +42 -34
- runbooks/cfat/models.py +1 -1
- runbooks/cloudops/__init__.py +123 -0
- runbooks/cloudops/base.py +385 -0
- runbooks/cloudops/cost_optimizer.py +811 -0
- runbooks/cloudops/infrastructure_optimizer.py +29 -0
- runbooks/cloudops/interfaces.py +828 -0
- runbooks/cloudops/lifecycle_manager.py +29 -0
- runbooks/cloudops/mcp_cost_validation.py +678 -0
- runbooks/cloudops/models.py +251 -0
- runbooks/cloudops/monitoring_automation.py +29 -0
- runbooks/cloudops/notebook_framework.py +676 -0
- runbooks/cloudops/security_enforcer.py +449 -0
- runbooks/common/__init__.py +152 -0
- runbooks/common/accuracy_validator.py +1039 -0
- runbooks/common/context_logger.py +440 -0
- runbooks/common/cross_module_integration.py +594 -0
- runbooks/common/enhanced_exception_handler.py +1108 -0
- runbooks/common/enterprise_audit_integration.py +634 -0
- runbooks/common/mcp_cost_explorer_integration.py +900 -0
- runbooks/common/mcp_integration.py +548 -0
- runbooks/common/performance_monitor.py +387 -0
- runbooks/common/profile_utils.py +216 -0
- runbooks/common/rich_utils.py +172 -1
- runbooks/feedback/user_feedback_collector.py +440 -0
- runbooks/finops/README.md +377 -458
- runbooks/finops/__init__.py +4 -21
- runbooks/finops/account_resolver.py +279 -0
- runbooks/finops/accuracy_cross_validator.py +638 -0
- runbooks/finops/aws_client.py +721 -36
- runbooks/finops/budget_integration.py +313 -0
- runbooks/finops/cli.py +59 -5
- runbooks/finops/cost_optimizer.py +1340 -0
- runbooks/finops/cost_processor.py +211 -37
- runbooks/finops/dashboard_router.py +900 -0
- runbooks/finops/dashboard_runner.py +990 -232
- runbooks/finops/embedded_mcp_validator.py +288 -0
- runbooks/finops/enhanced_dashboard_runner.py +8 -7
- runbooks/finops/enhanced_progress.py +327 -0
- runbooks/finops/enhanced_trend_visualization.py +423 -0
- runbooks/finops/finops_dashboard.py +184 -1829
- runbooks/finops/helpers.py +509 -196
- runbooks/finops/iam_guidance.py +400 -0
- runbooks/finops/markdown_exporter.py +466 -0
- runbooks/finops/multi_dashboard.py +1502 -0
- runbooks/finops/optimizer.py +15 -15
- runbooks/finops/profile_processor.py +2 -2
- runbooks/finops/runbooks.inventory.organizations_discovery.log +0 -0
- runbooks/finops/runbooks.security.report_generator.log +0 -0
- runbooks/finops/runbooks.security.run_script.log +0 -0
- runbooks/finops/runbooks.security.security_export.log +0 -0
- runbooks/finops/schemas.py +589 -0
- runbooks/finops/service_mapping.py +195 -0
- runbooks/finops/single_dashboard.py +710 -0
- runbooks/finops/tests/test_reference_images_validation.py +1 -1
- runbooks/inventory/README.md +12 -1
- runbooks/inventory/core/collector.py +157 -29
- runbooks/inventory/list_ec2_instances.py +9 -6
- runbooks/inventory/list_ssm_parameters.py +10 -10
- runbooks/inventory/organizations_discovery.py +210 -164
- runbooks/inventory/rich_inventory_display.py +74 -107
- runbooks/inventory/run_on_multi_accounts.py +13 -13
- runbooks/inventory/runbooks.inventory.organizations_discovery.log +0 -0
- runbooks/inventory/runbooks.security.security_export.log +0 -0
- runbooks/main.py +1371 -240
- runbooks/metrics/dora_metrics_engine.py +711 -17
- runbooks/monitoring/performance_monitor.py +433 -0
- runbooks/operate/README.md +394 -0
- runbooks/operate/base.py +215 -47
- runbooks/operate/ec2_operations.py +435 -5
- runbooks/operate/iam_operations.py +598 -3
- runbooks/operate/privatelink_operations.py +1 -1
- runbooks/operate/rds_operations.py +508 -0
- runbooks/operate/s3_operations.py +508 -0
- runbooks/operate/vpc_endpoints.py +1 -1
- runbooks/remediation/README.md +489 -13
- runbooks/remediation/base.py +5 -3
- runbooks/remediation/commons.py +8 -4
- runbooks/security/ENTERPRISE_SECURITY_FRAMEWORK.md +506 -0
- runbooks/security/README.md +12 -1
- runbooks/security/__init__.py +265 -33
- runbooks/security/cloudops_automation_security_validator.py +1164 -0
- runbooks/security/compliance_automation.py +12 -10
- runbooks/security/compliance_automation_engine.py +1021 -0
- runbooks/security/enterprise_security_framework.py +930 -0
- runbooks/security/enterprise_security_policies.json +293 -0
- runbooks/security/executive_security_dashboard.py +1247 -0
- runbooks/security/integration_test_enterprise_security.py +879 -0
- runbooks/security/module_security_integrator.py +641 -0
- runbooks/security/multi_account_security_controls.py +2254 -0
- runbooks/security/real_time_security_monitor.py +1196 -0
- runbooks/security/report_generator.py +1 -1
- runbooks/security/run_script.py +4 -8
- runbooks/security/security_baseline_tester.py +39 -52
- runbooks/security/security_export.py +99 -120
- runbooks/sre/README.md +472 -0
- runbooks/sre/__init__.py +33 -0
- runbooks/sre/mcp_reliability_engine.py +1049 -0
- runbooks/sre/performance_optimization_engine.py +1032 -0
- runbooks/sre/production_monitoring_framework.py +584 -0
- runbooks/sre/reliability_monitoring_framework.py +1011 -0
- runbooks/validation/__init__.py +2 -2
- runbooks/validation/benchmark.py +154 -149
- runbooks/validation/cli.py +159 -147
- runbooks/validation/mcp_validator.py +291 -248
- runbooks/vpc/README.md +478 -0
- runbooks/vpc/__init__.py +2 -2
- runbooks/vpc/manager_interface.py +366 -351
- runbooks/vpc/networking_wrapper.py +68 -36
- runbooks/vpc/rich_formatters.py +22 -8
- runbooks-0.9.1.dist-info/METADATA +308 -0
- {runbooks-0.7.9.dist-info → runbooks-0.9.1.dist-info}/RECORD +120 -59
- {runbooks-0.7.9.dist-info → runbooks-0.9.1.dist-info}/entry_points.txt +1 -1
- runbooks/finops/cross_validation.py +0 -375
- runbooks-0.7.9.dist-info/METADATA +0 -636
- {runbooks-0.7.9.dist-info → runbooks-0.9.1.dist-info}/WHEEL +0 -0
- {runbooks-0.7.9.dist-info → runbooks-0.9.1.dist-info}/licenses/LICENSE +0 -0
- {runbooks-0.7.9.dist-info → runbooks-0.9.1.dist-info}/top_level.txt +0 -0
@@ -2,22 +2,30 @@
|
|
2
2
|
IAM Operations Module.
|
3
3
|
|
4
4
|
Provides comprehensive IAM resource management capabilities including role management,
|
5
|
-
policy operations,
|
5
|
+
policy operations, cross-account access management, and access key lifecycle operations.
|
6
6
|
|
7
7
|
Migrated and enhanced from:
|
8
8
|
- inventory/update_iam_roles_cross_accounts.py
|
9
|
+
- unSkript AWS_Access_Key_Rotation.ipynb (access key rotation workflow)
|
9
10
|
"""
|
10
11
|
|
11
12
|
import json
|
12
|
-
from datetime import datetime
|
13
|
-
from typing import Any, Dict, List, Optional, Union
|
13
|
+
from datetime import datetime, timezone
|
14
|
+
from typing import Any, Dict, List, Optional, Union, Tuple
|
15
|
+
from dataclasses import dataclass
|
14
16
|
|
15
17
|
import boto3
|
18
|
+
import dateutil.tz
|
16
19
|
from botocore.exceptions import ClientError
|
17
20
|
from loguru import logger
|
21
|
+
from rich.console import Console
|
22
|
+
from rich.table import Table
|
23
|
+
from rich.panel import Panel
|
18
24
|
|
19
25
|
from runbooks.operate.base import BaseOperation, OperationContext, OperationResult, OperationStatus
|
20
26
|
|
27
|
+
console = Console()
|
28
|
+
|
21
29
|
|
22
30
|
class IAMOperations(BaseOperation):
|
23
31
|
"""
|
@@ -42,6 +50,11 @@ class IAMOperations(BaseOperation):
|
|
42
50
|
"create_service_linked_role",
|
43
51
|
"tag_role",
|
44
52
|
"untag_role",
|
53
|
+
"list_expiring_access_keys",
|
54
|
+
"create_access_key",
|
55
|
+
"update_access_key_status",
|
56
|
+
"delete_access_key",
|
57
|
+
"rotate_access_keys",
|
45
58
|
}
|
46
59
|
requires_confirmation = True
|
47
60
|
|
@@ -89,6 +102,16 @@ class IAMOperations(BaseOperation):
|
|
89
102
|
return self.tag_role(context, **kwargs)
|
90
103
|
elif operation_type == "untag_role":
|
91
104
|
return self.untag_role(context, **kwargs)
|
105
|
+
elif operation_type == "list_expiring_access_keys":
|
106
|
+
return self.list_expiring_access_keys(context, **kwargs)
|
107
|
+
elif operation_type == "create_access_key":
|
108
|
+
return self.create_access_key(context, **kwargs)
|
109
|
+
elif operation_type == "update_access_key_status":
|
110
|
+
return self.update_access_key_status(context, **kwargs)
|
111
|
+
elif operation_type == "delete_access_key":
|
112
|
+
return self.delete_access_key(context, **kwargs)
|
113
|
+
elif operation_type == "rotate_access_keys":
|
114
|
+
return self.rotate_access_keys(context, **kwargs)
|
92
115
|
else:
|
93
116
|
raise ValueError(f"Unsupported operation: {operation_type}")
|
94
117
|
|
@@ -337,6 +360,198 @@ class IAMOperations(BaseOperation):
|
|
337
360
|
|
338
361
|
return [result]
|
339
362
|
|
363
|
+
def update_role(
|
364
|
+
self,
|
365
|
+
context: OperationContext,
|
366
|
+
role_name: str,
|
367
|
+
description: Optional[str] = None,
|
368
|
+
max_session_duration: Optional[int] = None,
|
369
|
+
permissions_boundary: Optional[str] = None
|
370
|
+
) -> List[OperationResult]:
|
371
|
+
"""
|
372
|
+
Update IAM role properties.
|
373
|
+
|
374
|
+
Args:
|
375
|
+
context: Operation context
|
376
|
+
role_name: Name of role to update
|
377
|
+
description: New description
|
378
|
+
max_session_duration: New max session duration
|
379
|
+
permissions_boundary: New permissions boundary ARN
|
380
|
+
|
381
|
+
Returns:
|
382
|
+
List of operation results
|
383
|
+
"""
|
384
|
+
iam_client = self.get_client("iam")
|
385
|
+
|
386
|
+
result = self.create_operation_result(context, "update_role", "iam:role", role_name)
|
387
|
+
|
388
|
+
try:
|
389
|
+
if context.dry_run:
|
390
|
+
logger.info(f"[DRY-RUN] Would update IAM role {role_name}")
|
391
|
+
result.mark_completed(OperationStatus.DRY_RUN)
|
392
|
+
return [result]
|
393
|
+
|
394
|
+
update_params = {"RoleName": role_name}
|
395
|
+
|
396
|
+
if description is not None:
|
397
|
+
update_params["Description"] = description
|
398
|
+
if max_session_duration is not None:
|
399
|
+
update_params["MaxSessionDuration"] = max_session_duration
|
400
|
+
|
401
|
+
response = self.execute_aws_call(iam_client, "update_role", **update_params)
|
402
|
+
|
403
|
+
# Handle permissions boundary separately if provided
|
404
|
+
if permissions_boundary is not None:
|
405
|
+
if permissions_boundary == "":
|
406
|
+
# Remove permissions boundary
|
407
|
+
self.execute_aws_call(iam_client, "delete_role_permissions_boundary", RoleName=role_name)
|
408
|
+
else:
|
409
|
+
# Set permissions boundary
|
410
|
+
self.execute_aws_call(iam_client, "put_role_permissions_boundary",
|
411
|
+
RoleName=role_name, PermissionsBoundary=permissions_boundary)
|
412
|
+
|
413
|
+
result.response_data = response
|
414
|
+
result.mark_completed(OperationStatus.SUCCESS)
|
415
|
+
logger.info(f"Successfully updated IAM role {role_name}")
|
416
|
+
|
417
|
+
except ClientError as e:
|
418
|
+
error_msg = f"Failed to update IAM role {role_name}: {e}"
|
419
|
+
logger.error(error_msg)
|
420
|
+
result.mark_completed(OperationStatus.FAILED, error_msg)
|
421
|
+
|
422
|
+
return [result]
|
423
|
+
|
424
|
+
def update_policy(
|
425
|
+
self,
|
426
|
+
context: OperationContext,
|
427
|
+
policy_arn: str,
|
428
|
+
policy_document: str,
|
429
|
+
set_as_default: bool = True
|
430
|
+
) -> List[OperationResult]:
|
431
|
+
"""
|
432
|
+
Update IAM policy by creating a new version.
|
433
|
+
|
434
|
+
Args:
|
435
|
+
context: Operation context
|
436
|
+
policy_arn: ARN of policy to update
|
437
|
+
policy_document: New policy document JSON
|
438
|
+
set_as_default: Whether to set new version as default
|
439
|
+
|
440
|
+
Returns:
|
441
|
+
List of operation results
|
442
|
+
"""
|
443
|
+
iam_client = self.get_client("iam")
|
444
|
+
|
445
|
+
result = self.create_operation_result(context, "update_policy", "iam:policy", policy_arn)
|
446
|
+
|
447
|
+
try:
|
448
|
+
if context.dry_run:
|
449
|
+
logger.info(f"[DRY-RUN] Would update IAM policy {policy_arn}")
|
450
|
+
result.mark_completed(OperationStatus.DRY_RUN)
|
451
|
+
return [result]
|
452
|
+
|
453
|
+
response = self.execute_aws_call(
|
454
|
+
iam_client,
|
455
|
+
"create_policy_version",
|
456
|
+
PolicyArn=policy_arn,
|
457
|
+
PolicyDocument=policy_document,
|
458
|
+
SetAsDefault=set_as_default
|
459
|
+
)
|
460
|
+
|
461
|
+
result.response_data = response
|
462
|
+
result.mark_completed(OperationStatus.SUCCESS)
|
463
|
+
logger.info(f"Successfully updated IAM policy {policy_arn}")
|
464
|
+
|
465
|
+
except ClientError as e:
|
466
|
+
error_msg = f"Failed to update IAM policy {policy_arn}: {e}"
|
467
|
+
logger.error(error_msg)
|
468
|
+
result.mark_completed(OperationStatus.FAILED, error_msg)
|
469
|
+
|
470
|
+
return [result]
|
471
|
+
|
472
|
+
def create_service_linked_role(
|
473
|
+
self,
|
474
|
+
context: OperationContext,
|
475
|
+
aws_service_name: str,
|
476
|
+
description: Optional[str] = None,
|
477
|
+
custom_suffix: Optional[str] = None
|
478
|
+
) -> List[OperationResult]:
|
479
|
+
"""
|
480
|
+
Create service-linked role for AWS service.
|
481
|
+
|
482
|
+
Args:
|
483
|
+
context: Operation context
|
484
|
+
aws_service_name: AWS service name (e.g., 'elasticloadbalancing.amazonaws.com')
|
485
|
+
description: Custom description
|
486
|
+
custom_suffix: Custom suffix for role name
|
487
|
+
|
488
|
+
Returns:
|
489
|
+
List of operation results
|
490
|
+
"""
|
491
|
+
iam_client = self.get_client("iam")
|
492
|
+
|
493
|
+
result = self.create_operation_result(context, "create_service_linked_role", "iam:service-linked-role", aws_service_name)
|
494
|
+
|
495
|
+
try:
|
496
|
+
if context.dry_run:
|
497
|
+
logger.info(f"[DRY-RUN] Would create service-linked role for {aws_service_name}")
|
498
|
+
result.mark_completed(OperationStatus.DRY_RUN)
|
499
|
+
return [result]
|
500
|
+
|
501
|
+
create_params = {"AWSServiceName": aws_service_name}
|
502
|
+
|
503
|
+
if description:
|
504
|
+
create_params["Description"] = description
|
505
|
+
if custom_suffix:
|
506
|
+
create_params["CustomSuffix"] = custom_suffix
|
507
|
+
|
508
|
+
response = self.execute_aws_call(iam_client, "create_service_linked_role", **create_params)
|
509
|
+
|
510
|
+
result.response_data = response
|
511
|
+
result.mark_completed(OperationStatus.SUCCESS)
|
512
|
+
logger.info(f"Successfully created service-linked role for {aws_service_name}")
|
513
|
+
|
514
|
+
except ClientError as e:
|
515
|
+
error_msg = f"Failed to create service-linked role for {aws_service_name}: {e}"
|
516
|
+
logger.error(error_msg)
|
517
|
+
result.mark_completed(OperationStatus.FAILED, error_msg)
|
518
|
+
|
519
|
+
return [result]
|
520
|
+
|
521
|
+
def untag_role(self, context: OperationContext, role_name: str, tag_keys: List[str]) -> List[OperationResult]:
|
522
|
+
"""
|
523
|
+
Remove tags from IAM role.
|
524
|
+
|
525
|
+
Args:
|
526
|
+
context: Operation context
|
527
|
+
role_name: Name of role to untag
|
528
|
+
tag_keys: List of tag keys to remove
|
529
|
+
|
530
|
+
Returns:
|
531
|
+
List of operation results
|
532
|
+
"""
|
533
|
+
iam_client = self.get_client("iam")
|
534
|
+
|
535
|
+
result = self.create_operation_result(context, "untag_role", "iam:role", role_name)
|
536
|
+
|
537
|
+
try:
|
538
|
+
if context.dry_run:
|
539
|
+
logger.info(f"[DRY-RUN] Would remove {len(tag_keys)} tags from role {role_name}")
|
540
|
+
result.mark_completed(OperationStatus.DRY_RUN)
|
541
|
+
else:
|
542
|
+
response = self.execute_aws_call(iam_client, "untag_role", RoleName=role_name, TagKeys=tag_keys)
|
543
|
+
|
544
|
+
result.response_data = response
|
545
|
+
result.mark_completed(OperationStatus.SUCCESS)
|
546
|
+
logger.info(f"Successfully removed {len(tag_keys)} tags from role {role_name}")
|
547
|
+
|
548
|
+
except ClientError as e:
|
549
|
+
error_msg = f"Failed to untag role {role_name}: {e}"
|
550
|
+
logger.error(error_msg)
|
551
|
+
result.mark_completed(OperationStatus.FAILED, error_msg)
|
552
|
+
|
553
|
+
return [result]
|
554
|
+
|
340
555
|
def attach_role_policy(self, context: OperationContext, role_name: str, policy_arn: str) -> List[OperationResult]:
|
341
556
|
"""
|
342
557
|
Attach policy to IAM role.
|
@@ -567,3 +782,383 @@ class IAMOperations(BaseOperation):
|
|
567
782
|
result.mark_completed(OperationStatus.FAILED, error_msg)
|
568
783
|
|
569
784
|
return [result]
|
785
|
+
|
786
|
+
# =======================================
|
787
|
+
# Access Key Management Operations
|
788
|
+
# Migrated from unSkript AWS_Access_Key_Rotation.ipynb
|
789
|
+
# =======================================
|
790
|
+
|
791
|
+
@dataclass
|
792
|
+
class ExpiringAccessKey:
|
793
|
+
"""Data class for expiring access key information."""
|
794
|
+
username: str
|
795
|
+
access_key_id: str
|
796
|
+
create_date: datetime
|
797
|
+
days_old: int
|
798
|
+
|
799
|
+
def list_expiring_access_keys(
|
800
|
+
self, context: OperationContext, threshold_days: int = 90
|
801
|
+
) -> List[OperationResult]:
|
802
|
+
"""
|
803
|
+
List all IAM access keys that are expiring within threshold days.
|
804
|
+
|
805
|
+
Migrated from unSkript aws_list_expiring_access_keys function.
|
806
|
+
|
807
|
+
Args:
|
808
|
+
context: Operation context
|
809
|
+
threshold_days: Threshold number of days to check for expiry
|
810
|
+
|
811
|
+
Returns:
|
812
|
+
List of operation results with expiring access keys
|
813
|
+
"""
|
814
|
+
iam_client = self.get_client("iam")
|
815
|
+
|
816
|
+
result = self.create_operation_result(
|
817
|
+
context, "list_expiring_access_keys", "iam:access-keys", f"threshold-{threshold_days}-days"
|
818
|
+
)
|
819
|
+
|
820
|
+
try:
|
821
|
+
console.print(f"[blue]Checking for access keys older than {threshold_days} days...[/blue]")
|
822
|
+
|
823
|
+
expiring_keys = []
|
824
|
+
|
825
|
+
# Get all IAM users
|
826
|
+
paginator = iam_client.get_paginator('list_users')
|
827
|
+
|
828
|
+
for page in paginator.paginate():
|
829
|
+
for user in page['Users']:
|
830
|
+
username = user['UserName']
|
831
|
+
|
832
|
+
try:
|
833
|
+
# List access keys for each user
|
834
|
+
response = self.execute_aws_call(iam_client, "list_access_keys", UserName=username)
|
835
|
+
|
836
|
+
for key_metadata in response.get("AccessKeyMetadata", []):
|
837
|
+
create_date = key_metadata["CreateDate"]
|
838
|
+
right_now = datetime.now(dateutil.tz.tzlocal())
|
839
|
+
|
840
|
+
# Calculate age in days
|
841
|
+
age_diff = right_now - create_date
|
842
|
+
days_old = age_diff.days
|
843
|
+
|
844
|
+
if days_old > threshold_days:
|
845
|
+
expiring_key = self.ExpiringAccessKey(
|
846
|
+
username=username,
|
847
|
+
access_key_id=key_metadata["AccessKeyId"],
|
848
|
+
create_date=create_date,
|
849
|
+
days_old=days_old
|
850
|
+
)
|
851
|
+
expiring_keys.append(expiring_key)
|
852
|
+
|
853
|
+
except ClientError as e:
|
854
|
+
if e.response['Error']['Code'] != 'NoSuchEntity':
|
855
|
+
logger.warning(f"Failed to list access keys for user {username}: {e}")
|
856
|
+
|
857
|
+
# Display results with Rich table
|
858
|
+
if expiring_keys:
|
859
|
+
table = Table(title=f"Access Keys Expiring (>{threshold_days} days old)")
|
860
|
+
table.add_column("Username", style="cyan")
|
861
|
+
table.add_column("Access Key ID", style="yellow")
|
862
|
+
table.add_column("Created Date", style="magenta")
|
863
|
+
table.add_column("Days Old", style="red")
|
864
|
+
|
865
|
+
for key in expiring_keys:
|
866
|
+
table.add_row(
|
867
|
+
key.username,
|
868
|
+
key.access_key_id,
|
869
|
+
key.create_date.strftime("%Y-%m-%d %H:%M:%S"),
|
870
|
+
str(key.days_old)
|
871
|
+
)
|
872
|
+
|
873
|
+
console.print(table)
|
874
|
+
console.print(f"[red]Found {len(expiring_keys)} expiring access keys[/red]")
|
875
|
+
else:
|
876
|
+
console.print(Panel("[green]✅ No expiring access keys found[/green]", title="Success"))
|
877
|
+
|
878
|
+
result.response_data = {
|
879
|
+
"expiring_keys": [
|
880
|
+
{
|
881
|
+
"username": key.username,
|
882
|
+
"access_key_id": key.access_key_id,
|
883
|
+
"create_date": key.create_date.isoformat(),
|
884
|
+
"days_old": key.days_old
|
885
|
+
}
|
886
|
+
for key in expiring_keys
|
887
|
+
],
|
888
|
+
"count": len(expiring_keys),
|
889
|
+
"threshold_days": threshold_days
|
890
|
+
}
|
891
|
+
result.mark_completed(OperationStatus.SUCCESS)
|
892
|
+
logger.info(f"Found {len(expiring_keys)} expiring access keys")
|
893
|
+
|
894
|
+
except Exception as e:
|
895
|
+
error_msg = f"Failed to list expiring access keys: {e}"
|
896
|
+
logger.error(error_msg)
|
897
|
+
result.mark_completed(OperationStatus.FAILED, error_msg)
|
898
|
+
|
899
|
+
return [result]
|
900
|
+
|
901
|
+
def create_access_key(self, context: OperationContext, username: str) -> List[OperationResult]:
|
902
|
+
"""
|
903
|
+
Create new access key for specified IAM user.
|
904
|
+
|
905
|
+
Migrated from unSkript aws_create_access_key function.
|
906
|
+
|
907
|
+
Args:
|
908
|
+
context: Operation context
|
909
|
+
username: IAM username to create access key for
|
910
|
+
|
911
|
+
Returns:
|
912
|
+
List of operation results
|
913
|
+
"""
|
914
|
+
iam_client = self.get_client("iam")
|
915
|
+
|
916
|
+
result = self.create_operation_result(context, "create_access_key", "iam:access-key", username)
|
917
|
+
|
918
|
+
try:
|
919
|
+
if context.dry_run:
|
920
|
+
console.print(f"[yellow][DRY-RUN] Would create new access key for user {username}[/yellow]")
|
921
|
+
result.mark_completed(OperationStatus.DRY_RUN)
|
922
|
+
return [result]
|
923
|
+
|
924
|
+
# Safety confirmation for access key creation
|
925
|
+
if not self.confirm_operation(context, username, "create new access key for user"):
|
926
|
+
result.mark_completed(OperationStatus.CANCELLED, "Operation cancelled by user")
|
927
|
+
return [result]
|
928
|
+
|
929
|
+
response = self.execute_aws_call(iam_client, "create_access_key", UserName=username)
|
930
|
+
|
931
|
+
# Extract access key information
|
932
|
+
access_key = response.get("AccessKey", {})
|
933
|
+
|
934
|
+
# Display new access key information (with security warning)
|
935
|
+
console.print(Panel(
|
936
|
+
f"[green]✅ New access key created for user: {username}[/green]\n"
|
937
|
+
f"[yellow]⚠️ IMPORTANT: Save these credentials securely![/yellow]\n"
|
938
|
+
f"Access Key ID: [cyan]{access_key.get('AccessKeyId')}[/cyan]\n"
|
939
|
+
f"Secret Access Key: [red]{'*' * 20}[/red] (Check logs for full key)",
|
940
|
+
title="Access Key Created"
|
941
|
+
))
|
942
|
+
|
943
|
+
result.response_data = {
|
944
|
+
"username": username,
|
945
|
+
"access_key_id": access_key.get("AccessKeyId"),
|
946
|
+
"status": access_key.get("Status"),
|
947
|
+
"create_date": access_key.get("CreateDate").isoformat() if access_key.get("CreateDate") else None,
|
948
|
+
# Note: Secret key not stored in result for security
|
949
|
+
}
|
950
|
+
result.mark_completed(OperationStatus.SUCCESS)
|
951
|
+
logger.info(f"Successfully created access key for user {username}")
|
952
|
+
|
953
|
+
except ClientError as e:
|
954
|
+
error_msg = f"Failed to create access key for user {username}: {e}"
|
955
|
+
logger.error(error_msg)
|
956
|
+
result.mark_completed(OperationStatus.FAILED, error_msg)
|
957
|
+
console.print(f"[red]❌ {error_msg}[/red]")
|
958
|
+
|
959
|
+
return [result]
|
960
|
+
|
961
|
+
def update_access_key_status(
|
962
|
+
self, context: OperationContext, username: str, access_key_id: str, status: str
|
963
|
+
) -> List[OperationResult]:
|
964
|
+
"""
|
965
|
+
Update access key status (Active/Inactive).
|
966
|
+
|
967
|
+
Migrated from unSkript aws_update_access_key function.
|
968
|
+
|
969
|
+
Args:
|
970
|
+
context: Operation context
|
971
|
+
username: IAM username
|
972
|
+
access_key_id: Access key ID to update
|
973
|
+
status: New status ('Active' or 'Inactive')
|
974
|
+
|
975
|
+
Returns:
|
976
|
+
List of operation results
|
977
|
+
"""
|
978
|
+
iam_client = self.get_client("iam")
|
979
|
+
|
980
|
+
result = self.create_operation_result(
|
981
|
+
context, "update_access_key_status", "iam:access-key", f"{username}:{access_key_id}"
|
982
|
+
)
|
983
|
+
|
984
|
+
try:
|
985
|
+
if status not in ['Active', 'Inactive']:
|
986
|
+
raise ValueError(f"Invalid status '{status}'. Must be 'Active' or 'Inactive'")
|
987
|
+
|
988
|
+
if context.dry_run:
|
989
|
+
console.print(f"[yellow][DRY-RUN] Would update access key {access_key_id} status to {status}[/yellow]")
|
990
|
+
result.mark_completed(OperationStatus.DRY_RUN)
|
991
|
+
return [result]
|
992
|
+
|
993
|
+
# Safety confirmation for status changes
|
994
|
+
if not self.confirm_operation(context, access_key_id, f"update access key status to {status}"):
|
995
|
+
result.mark_completed(OperationStatus.CANCELLED, "Operation cancelled by user")
|
996
|
+
return [result]
|
997
|
+
|
998
|
+
response = self.execute_aws_call(
|
999
|
+
iam_client,
|
1000
|
+
"update_access_key",
|
1001
|
+
UserName=username,
|
1002
|
+
AccessKeyId=access_key_id,
|
1003
|
+
Status=status
|
1004
|
+
)
|
1005
|
+
|
1006
|
+
status_color = "green" if status == "Active" else "yellow"
|
1007
|
+
console.print(f"[{status_color}]✅ Access key {access_key_id} status updated to {status}[/{status_color}]")
|
1008
|
+
|
1009
|
+
result.response_data = {
|
1010
|
+
"username": username,
|
1011
|
+
"access_key_id": access_key_id,
|
1012
|
+
"status": status,
|
1013
|
+
"updated_at": datetime.now().isoformat()
|
1014
|
+
}
|
1015
|
+
result.mark_completed(OperationStatus.SUCCESS)
|
1016
|
+
logger.info(f"Successfully updated access key {access_key_id} status to {status}")
|
1017
|
+
|
1018
|
+
except ClientError as e:
|
1019
|
+
error_msg = f"Failed to update access key status: {e}"
|
1020
|
+
logger.error(error_msg)
|
1021
|
+
result.mark_completed(OperationStatus.FAILED, error_msg)
|
1022
|
+
console.print(f"[red]❌ {error_msg}[/red]")
|
1023
|
+
|
1024
|
+
return [result]
|
1025
|
+
|
1026
|
+
def delete_access_key(self, context: OperationContext, username: str, access_key_id: str) -> List[OperationResult]:
|
1027
|
+
"""
|
1028
|
+
Delete access key for specified user.
|
1029
|
+
|
1030
|
+
Migrated from unSkript aws_delete_access_key function.
|
1031
|
+
|
1032
|
+
Args:
|
1033
|
+
context: Operation context
|
1034
|
+
username: IAM username
|
1035
|
+
access_key_id: Access key ID to delete
|
1036
|
+
|
1037
|
+
Returns:
|
1038
|
+
List of operation results
|
1039
|
+
"""
|
1040
|
+
iam_client = self.get_client("iam")
|
1041
|
+
|
1042
|
+
result = self.create_operation_result(
|
1043
|
+
context, "delete_access_key", "iam:access-key", f"{username}:{access_key_id}"
|
1044
|
+
)
|
1045
|
+
|
1046
|
+
try:
|
1047
|
+
if context.dry_run:
|
1048
|
+
console.print(f"[yellow][DRY-RUN] Would delete access key {access_key_id} for user {username}[/yellow]")
|
1049
|
+
result.mark_completed(OperationStatus.DRY_RUN)
|
1050
|
+
return [result]
|
1051
|
+
|
1052
|
+
# Strong confirmation required for deletion
|
1053
|
+
console.print(f"[red]⚠️ WARNING: This will permanently delete access key {access_key_id}[/red]")
|
1054
|
+
if not self.confirm_operation(context, access_key_id, f"PERMANENTLY DELETE access key"):
|
1055
|
+
result.mark_completed(OperationStatus.CANCELLED, "Operation cancelled by user")
|
1056
|
+
return [result]
|
1057
|
+
|
1058
|
+
response = self.execute_aws_call(
|
1059
|
+
iam_client,
|
1060
|
+
"delete_access_key",
|
1061
|
+
UserName=username,
|
1062
|
+
AccessKeyId=access_key_id
|
1063
|
+
)
|
1064
|
+
|
1065
|
+
console.print(f"[green]✅ Access key {access_key_id} successfully deleted[/green]")
|
1066
|
+
|
1067
|
+
result.response_data = {
|
1068
|
+
"username": username,
|
1069
|
+
"access_key_id": access_key_id,
|
1070
|
+
"deleted_at": datetime.now().isoformat()
|
1071
|
+
}
|
1072
|
+
result.mark_completed(OperationStatus.SUCCESS)
|
1073
|
+
logger.info(f"Successfully deleted access key {access_key_id} for user {username}")
|
1074
|
+
|
1075
|
+
except ClientError as e:
|
1076
|
+
error_msg = f"Failed to delete access key: {e}"
|
1077
|
+
logger.error(error_msg)
|
1078
|
+
result.mark_completed(OperationStatus.FAILED, error_msg)
|
1079
|
+
console.print(f"[red]❌ {error_msg}[/red]")
|
1080
|
+
|
1081
|
+
return [result]
|
1082
|
+
|
1083
|
+
def rotate_access_keys(
|
1084
|
+
self, context: OperationContext, threshold_days: int = 90, auto_rotate: bool = False
|
1085
|
+
) -> List[OperationResult]:
|
1086
|
+
"""
|
1087
|
+
Complete access key rotation workflow combining all steps.
|
1088
|
+
|
1089
|
+
This orchestrates the full unSkript notebook workflow:
|
1090
|
+
1. List expiring access keys
|
1091
|
+
2. Create new access keys
|
1092
|
+
3. Deactivate old access keys
|
1093
|
+
4. Delete old access keys (optional)
|
1094
|
+
|
1095
|
+
Args:
|
1096
|
+
context: Operation context
|
1097
|
+
threshold_days: Age threshold for rotation
|
1098
|
+
auto_rotate: If True, automatically rotates without confirmation per key
|
1099
|
+
|
1100
|
+
Returns:
|
1101
|
+
List of operation results
|
1102
|
+
"""
|
1103
|
+
results = []
|
1104
|
+
|
1105
|
+
# Step 1: Find expiring access keys
|
1106
|
+
console.print(Panel("[blue]Step 1: Finding expiring access keys...[/blue]", title="Access Key Rotation"))
|
1107
|
+
expiring_result = self.list_expiring_access_keys(context, threshold_days=threshold_days)
|
1108
|
+
results.extend(expiring_result)
|
1109
|
+
|
1110
|
+
if not expiring_result or expiring_result[0].status == OperationStatus.FAILED:
|
1111
|
+
return results
|
1112
|
+
|
1113
|
+
expiring_keys_data = expiring_result[0].response_data.get("expiring_keys", [])
|
1114
|
+
|
1115
|
+
if not expiring_keys_data:
|
1116
|
+
console.print(Panel("[green]✅ No access keys need rotation[/green]", title="Complete"))
|
1117
|
+
return results
|
1118
|
+
|
1119
|
+
console.print(f"[yellow]Found {len(expiring_keys_data)} keys to rotate[/yellow]")
|
1120
|
+
|
1121
|
+
if not auto_rotate:
|
1122
|
+
if not self.confirm_operation(context, f"{len(expiring_keys_data)} access keys", "rotate"):
|
1123
|
+
cancelled_result = self.create_operation_result(
|
1124
|
+
context, "rotate_access_keys", "iam:access-keys", "rotation-workflow"
|
1125
|
+
)
|
1126
|
+
cancelled_result.mark_completed(OperationStatus.CANCELLED, "Rotation cancelled by user")
|
1127
|
+
results.append(cancelled_result)
|
1128
|
+
return results
|
1129
|
+
|
1130
|
+
# Steps 2-4: Rotate each expiring key
|
1131
|
+
for key_data in expiring_keys_data:
|
1132
|
+
username = key_data["username"]
|
1133
|
+
old_access_key_id = key_data["access_key_id"]
|
1134
|
+
|
1135
|
+
console.print(f"[cyan]Rotating access key for user: {username}[/cyan]")
|
1136
|
+
|
1137
|
+
# Step 2: Create new access key
|
1138
|
+
console.print(f"[blue] → Creating new access key...[/blue]")
|
1139
|
+
create_result = self.create_access_key(context, username=username)
|
1140
|
+
results.extend(create_result)
|
1141
|
+
|
1142
|
+
if create_result[0].status == OperationStatus.SUCCESS:
|
1143
|
+
# Step 3: Deactivate old access key
|
1144
|
+
console.print(f"[yellow] → Deactivating old access key...[/yellow]")
|
1145
|
+
deactivate_result = self.update_access_key_status(
|
1146
|
+
context, username=username, access_key_id=old_access_key_id, status="Inactive"
|
1147
|
+
)
|
1148
|
+
results.extend(deactivate_result)
|
1149
|
+
|
1150
|
+
if deactivate_result[0].status == OperationStatus.SUCCESS:
|
1151
|
+
# Step 4: Option to delete old key (with confirmation)
|
1152
|
+
console.print(f"[red] → Old key deactivated. Delete permanently?[/red]")
|
1153
|
+
if auto_rotate or self.confirm_operation(
|
1154
|
+
context, old_access_key_id, "delete old access key"
|
1155
|
+
):
|
1156
|
+
delete_result = self.delete_access_key(
|
1157
|
+
context, username=username, access_key_id=old_access_key_id
|
1158
|
+
)
|
1159
|
+
results.extend(delete_result)
|
1160
|
+
else:
|
1161
|
+
console.print(f"[yellow] → Old key kept inactive for manual cleanup[/yellow]")
|
1162
|
+
|
1163
|
+
console.print(Panel("[green]✅ Access key rotation workflow complete[/green]", title="Complete"))
|
1164
|
+
return results
|
@@ -31,7 +31,6 @@ from typing import Any, Dict, List, Optional, Set, Tuple
|
|
31
31
|
import boto3
|
32
32
|
from botocore.exceptions import BotoCoreError, ClientError
|
33
33
|
|
34
|
-
from runbooks.operate.base import BaseOperation, OperationResult
|
35
34
|
from runbooks.common.rich_utils import (
|
36
35
|
console,
|
37
36
|
create_panel,
|
@@ -43,6 +42,7 @@ from runbooks.common.rich_utils import (
|
|
43
42
|
print_success,
|
44
43
|
print_warning,
|
45
44
|
)
|
45
|
+
from runbooks.operate.base import BaseOperation, OperationResult
|
46
46
|
|
47
47
|
logger = logging.getLogger(__name__)
|
48
48
|
|