Skip to content

Commit 436ea4d

Browse files
committed
testing smarter logic
1 parent 23cb853 commit 436ea4d

3 files changed

Lines changed: 78 additions & 40 deletions

File tree

devolv/__init__.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,2 +1,2 @@
1-
__version__ = "0.2.27"
1+
__version__ = "0.2.31"
22

devolv/drift/aws_fetcher.py

Lines changed: 44 additions & 24 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
import boto3
22
import json
3+
from collections import defaultdict
34

45
def get_aws_policy_document(policy_arn: str) -> dict:
56
"""
@@ -11,37 +12,56 @@ def get_aws_policy_document(policy_arn: str) -> dict:
1112
version = iam.get_policy_version(PolicyArn=policy_arn, VersionId=default_version)
1213
return version['PolicyVersion']['Document']
1314

15+
def _combine_statements(docs):
16+
combined = defaultdict(lambda: {"Sid": None, "Effect": None, "Action": None, "Resource": set()})
17+
18+
for doc in docs:
19+
for stmt in doc.get("Statement", []):
20+
key = (
21+
stmt.get("Sid"),
22+
stmt.get("Effect"),
23+
json.dumps(stmt.get("Action"), sort_keys=True)
24+
)
25+
26+
combined_stmt = combined[key]
27+
combined_stmt["Sid"] = stmt.get("Sid")
28+
combined_stmt["Effect"] = stmt.get("Effect")
29+
combined_stmt["Action"] = stmt.get("Action")
30+
31+
resources = stmt.get("Resource")
32+
if not isinstance(resources, list):
33+
resources = [resources]
34+
35+
combined_stmt["Resource"].update(resources)
36+
37+
result = []
38+
for stmt in combined.values():
39+
result.append({
40+
"Sid": stmt["Sid"],
41+
"Effect": stmt["Effect"],
42+
"Action": stmt["Action"],
43+
"Resource": sorted(stmt["Resource"])
44+
})
45+
46+
return result
47+
1448
def merge_policy_documents(local_doc: dict, aws_doc: dict) -> dict:
1549
"""
16-
Merge statements by appending any local-only statements to the AWS document.
17-
This is an "append-only" merge (we do not delete existing AWS statements).
50+
Merge local and AWS policy documents: append any local-only permissions while
51+
merging resources under same Sid + Effect + Action where possible.
1852
"""
19-
aws_stmts = aws_doc.get("Statement", [])
20-
local_stmts = local_doc.get("Statement", [])
21-
merged = list(aws_stmts) # copy existing AWS statements
22-
for stmt in local_stmts:
23-
if stmt not in aws_stmts:
24-
merged.append(stmt)
25-
aws_doc["Statement"] = merged
26-
return aws_doc
53+
merged_statements = _combine_statements([aws_doc, local_doc])
54+
return {
55+
"Version": "2012-10-17",
56+
"Statement": merged_statements
57+
}
2758

2859
def build_superset_policy(local_doc: dict, aws_doc: dict) -> dict:
2960
"""
30-
Combine local and AWS policy documents into a superset without duplicate statements.
61+
Build a superset of local + AWS policy, merging where possible.
3162
"""
32-
local_statements = local_doc.get("Statement", [])
33-
aws_statements = aws_doc.get("Statement", [])
34-
35-
seen = set()
36-
combined = []
37-
38-
for stmt in local_statements + aws_statements:
39-
stmt_str = json.dumps(stmt, sort_keys=True)
40-
if stmt_str not in seen:
41-
seen.add(stmt_str)
42-
combined.append(stmt)
43-
63+
merged_statements = _combine_statements([local_doc, aws_doc])
4464
return {
4565
"Version": "2012-10-17",
46-
"Statement": combined
66+
"Statement": merged_statements
4767
}

devolv/drift/cli.py

Lines changed: 33 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -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

4140
def 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()
6154
def 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

128133
def _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

139157
def _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\nLinked to issue #{issue_num}.".strip()
174+
pr_title = f"Update {policy_file} {description}"
175+
pr_body = f"This PR updates `{policy_file}` {description}.\n\nLinked 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

Comments
 (0)