@@ -33,38 +33,31 @@ def push_branch(branch_name: str):
3333 subprocess .run (["git" , "push" , "--set-upstream" , "origin" , branch_name ], check = True )
3434
3535 typer .echo (f"✅ Pushed branch { branch_name } to origin." )
36-
3736 except subprocess .CalledProcessError as e :
3837 typer .echo (f"❌ Git command failed: { e } " )
3938 raise typer .Exit (1 )
4039
4140def detect_drift (local_doc , aws_doc ) -> bool :
42- """Detect removal drift: AWS has permissions missing from local (danger)."""
4341 local_statements = {json .dumps (s , sort_keys = True ) for s in local_doc .get ("Statement" , [])}
4442 aws_statements = {json .dumps (s , sort_keys = True ) for s in aws_doc .get ("Statement" , [])}
4543
4644 missing_in_local = aws_statements - local_statements
4745
4846 if missing_in_local :
4947 typer .echo ("❌ Drift detected: Local is missing permissions present in AWS." )
50- # No need to print each JSON line — rich diff will handle details
5148 return True
5249
5350 typer .echo ("✅ No removal drift detected (local may have extra permissions; that's fine)." )
5451 return False
5552
56-
57- typer .echo ("✅ No removal drift detected (local may have extra permissions; that's fine)." )
58- return False
59-
6053@app .command ()
6154def drift (
6255 policy_name : str = typer .Option (..., "--policy-name" , help = "Name of the IAM policy" ),
6356 policy_file : str = typer .Option (..., "--file" , help = "Path to local policy file" ),
64- account_id : str = typer .Option (None , "--account-id" , help = "AWS Account ID (optional, auto-detected if not provided )" ),
65- approvers : str = typer .Option ("" , help = "Comma-separated GitHub usernames for approval (optional) " ),
57+ account_id : str = typer .Option (None , "--account-id" , help = "AWS Account ID (optional)" ),
58+ approvers : str = typer .Option ("" , help = "Comma-separated GitHub usernames for approval" ),
6659 approval_anyway : bool = typer .Option (False , "--approval-anyway" , help = "Request approval even if no drift" ),
67- repo_full_name : str = typer .Option (None , "--repo" , help = "GitHub repo full name (e.g., org/repo)" )
60+ repo_full_name : str = typer .Option (None , "--repo" , help = "GitHub repo full name (org/repo)" )
6861):
6962 iam = boto3 .client ("iam" )
7063 if not account_id :
@@ -85,7 +78,11 @@ def drift(
8578 print_drift_diff (local_doc , aws_doc )
8679
8780 if not drift_detected :
88- _update_aws_policy (iam , policy_arn , local_doc )
81+ try :
82+ _update_aws_policy (iam , policy_arn , local_doc )
83+ except ValueError as ve :
84+ typer .echo (str (ve ))
85+ raise typer .Exit (1 )
8986 typer .echo (f"✅ AWS policy { policy_arn } updated to include any local additions." )
9087 if not approval_anyway :
9188 typer .echo ("✅ No forced approval requested. Exiting." )
@@ -110,31 +107,52 @@ def drift(
110107
111108 if choice == "local->aws" :
112109 merged_doc = merge_policy_documents (local_doc , aws_doc )
113- _update_aws_policy (iam , policy_arn , merged_doc )
110+ try :
111+ _update_aws_policy (iam , policy_arn , merged_doc )
112+ except ValueError as ve :
113+ typer .echo (str (ve ))
114+ raise typer .Exit (1 )
114115 typer .echo (f"✅ AWS policy { policy_arn } updated with local changes (append-only)." )
115116
116117 elif choice == "aws->local" :
117118 _update_local_and_create_pr (aws_doc , policy_file , repo_full_name , policy_name , issue_num , token , "from AWS policy" )
118119
119120 elif choice == "aws<->local" :
120121 superset_doc = build_superset_policy (local_doc , aws_doc )
121- _update_aws_policy (iam , policy_arn , superset_doc )
122+ try :
123+ _update_aws_policy (iam , policy_arn , superset_doc )
124+ except ValueError as ve :
125+ typer .echo (str (ve ))
126+ raise typer .Exit (1 )
122127 typer .echo (f"✅ AWS policy { policy_arn } updated with superset of local + AWS." )
123128 _update_local_and_create_pr (superset_doc , policy_file , repo_full_name , policy_name , issue_num , token , "with superset of local + AWS" )
124129
125130 else :
126131 typer .echo ("⏭ No synchronization performed (skip)." )
127132
128133def _update_aws_policy (iam , policy_arn , policy_doc ):
134+ sids = [stmt .get ("Sid" ) for stmt in policy_doc .get ("Statement" , []) if "Sid" in stmt ]
135+ if len (sids ) != len (set (sids )):
136+ raise ValueError ("❌ Merged policy would produce duplicate SIDs. Cannot update AWS policy." )
137+
138+ current_version_id = iam .get_policy (PolicyArn = policy_arn )["Policy" ]["DefaultVersionId" ]
139+ current_doc = iam .get_policy_version (PolicyArn = policy_arn , VersionId = current_version_id )["PolicyVersion" ]["Document" ]
140+
141+ if policy_doc == current_doc :
142+ print ("✅ Merged policy is identical to existing AWS policy. No update needed." )
143+ return
144+
129145 versions = iam .list_policy_versions (PolicyArn = policy_arn )["Versions" ]
130146 if len (versions ) >= 5 :
131147 oldest = sorted ((v for v in versions if not v ["IsDefaultVersion" ]), key = lambda v : v ["CreateDate" ])[0 ]
132148 iam .delete_policy_version (PolicyArn = policy_arn , VersionId = oldest ["VersionId" ])
149+
133150 iam .create_policy_version (
134151 PolicyArn = policy_arn ,
135152 PolicyDocument = json .dumps (policy_doc ),
136153 SetAsDefault = True
137154 )
155+ print (f"✅ AWS policy { policy_arn } updated successfully." )
138156
139157def _update_local_and_create_pr (doc , policy_file , repo_full_name , policy_name , issue_num , token , description = "" ):
140158 new_content = json .dumps (doc , indent = 2 )
@@ -153,8 +171,8 @@ def _update_local_and_create_pr(doc, policy_file, repo_full_name, policy_name, i
153171
154172 push_branch (branch )
155173
156- pr_title = f"Update { policy_file } { description } " . strip ()
157- pr_body = f"This PR updates `{ policy_file } ` { description } .\n \n Linked to issue #{ issue_num } ." . strip ()
174+ pr_title = f"Update { policy_file } { description } "
175+ pr_body = f"This PR updates `{ policy_file } ` { description } .\n \n Linked to issue #{ issue_num } ."
158176
159177 pr_num , pr_url = create_github_pr (repo_full_name , branch , pr_title , pr_body , issue_num = issue_num )
160178
0 commit comments