runbooks 0.9.0__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.
Files changed (46) hide show
  1. runbooks/__init__.py +1 -1
  2. runbooks/cfat/assessment/compliance.py +4 -1
  3. runbooks/cloudops/__init__.py +123 -0
  4. runbooks/cloudops/base.py +385 -0
  5. runbooks/cloudops/cost_optimizer.py +811 -0
  6. runbooks/cloudops/infrastructure_optimizer.py +29 -0
  7. runbooks/cloudops/interfaces.py +828 -0
  8. runbooks/cloudops/lifecycle_manager.py +29 -0
  9. runbooks/cloudops/mcp_cost_validation.py +678 -0
  10. runbooks/cloudops/models.py +251 -0
  11. runbooks/cloudops/monitoring_automation.py +29 -0
  12. runbooks/cloudops/notebook_framework.py +676 -0
  13. runbooks/cloudops/security_enforcer.py +449 -0
  14. runbooks/common/mcp_cost_explorer_integration.py +900 -0
  15. runbooks/common/mcp_integration.py +19 -10
  16. runbooks/common/rich_utils.py +1 -1
  17. runbooks/finops/README.md +31 -0
  18. runbooks/finops/cost_optimizer.py +1340 -0
  19. runbooks/finops/finops_dashboard.py +211 -5
  20. runbooks/finops/schemas.py +589 -0
  21. runbooks/inventory/runbooks.inventory.organizations_discovery.log +0 -0
  22. runbooks/inventory/runbooks.security.security_export.log +0 -0
  23. runbooks/main.py +525 -0
  24. runbooks/operate/ec2_operations.py +428 -0
  25. runbooks/operate/iam_operations.py +598 -3
  26. runbooks/operate/rds_operations.py +508 -0
  27. runbooks/operate/s3_operations.py +508 -0
  28. runbooks/remediation/base.py +5 -3
  29. runbooks/security/__init__.py +101 -0
  30. runbooks/security/cloudops_automation_security_validator.py +1164 -0
  31. runbooks/security/compliance_automation_engine.py +4 -4
  32. runbooks/security/enterprise_security_framework.py +4 -5
  33. runbooks/security/executive_security_dashboard.py +1247 -0
  34. runbooks/security/multi_account_security_controls.py +2254 -0
  35. runbooks/security/real_time_security_monitor.py +1196 -0
  36. runbooks/security/security_baseline_tester.py +3 -3
  37. runbooks/sre/production_monitoring_framework.py +584 -0
  38. runbooks/validation/mcp_validator.py +29 -15
  39. runbooks/vpc/networking_wrapper.py +6 -3
  40. runbooks-0.9.1.dist-info/METADATA +308 -0
  41. {runbooks-0.9.0.dist-info → runbooks-0.9.1.dist-info}/RECORD +45 -23
  42. runbooks-0.9.0.dist-info/METADATA +0 -718
  43. {runbooks-0.9.0.dist-info → runbooks-0.9.1.dist-info}/WHEEL +0 -0
  44. {runbooks-0.9.0.dist-info → runbooks-0.9.1.dist-info}/entry_points.txt +0 -0
  45. {runbooks-0.9.0.dist-info → runbooks-0.9.1.dist-info}/licenses/LICENSE +0 -0
  46. {runbooks-0.9.0.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, and cross-account access management.
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