Enforcing IAM Least Privilege in Python IaC
Wildcard IAM grants are the single most common finding in cloud security audits, and Python IaC gives you a precise lever to eliminate them: typed policy builders that refuse to emit * actions or * resources. This task is part of Security & Compliance Basics within Python IaC Fundamentals & Strategy, and it turns least privilege from a review checklist into a property the code enforces at construction time.
The principle is narrow scope by default: every statement names the exact actions it allows and the exact resource ARNs they apply to. Instead of hand-writing JSON — where a stray "*" slips through review — you build policies through a typed generator that validates each grant, then test the rendered policy for over-broad permissions before it is ever attached to a role.
Prerequisites
- Python 3.9+ with
pulumi-aws >= 6.0(or the CDKTF AWS provider) pinned in your lockfile. - IAM permissions to create roles and policies (
iam:CreateRole,iam:PutRolePolicy) on the deploying principal. pytest >= 7for policy assertions, and the AWS CLI foraws iam simulate-principal-policy.- A clear list of the exact actions each workload needs — derive these from CloudTrail or IAM Access Analyzer, not from guesses.
Implementation
1. Build policies with a typed generator
A frozen dataclass per statement rejects wildcards at construction. The builder raises before any provider call, so an over-broad policy never reaches AWS.
# Run: pulumi up
from __future__ import annotations
from dataclasses import dataclass, field
from typing import Any
import json
import pulumi_aws as aws
@dataclass(frozen=True)
class Statement:
sid: str
actions: list[str]
resources: list[str]
def __post_init__(self) -> None:
# Reject the two most common least-privilege violations at construction time.
if any(a.endswith(":*") or a == "*" for a in self.actions):
raise ValueError(f"{self.sid}: wildcard action not allowed")
if "*" in self.resources:
raise ValueError(f"{self.sid}: wildcard resource not allowed")
def render(self) -> dict[str, Any]:
return {
"Sid": self.sid, "Effect": "Allow",
"Action": self.actions, "Resource": self.resources,
}
@dataclass(frozen=True)
class PolicyBuilder:
statements: list[Statement] = field(default_factory=list)
def to_json(self) -> str:
# State implication: this string is stored verbatim in IaC state as the role policy.
return json.dumps({
"Version": "2012-10-17",
"Statement": [s.render() for s in self.statements],
})
policy = PolicyBuilder([
Statement("ReadAppBucket", ["s3:GetObject"], ["arn:aws:s3:::app-data/*"]),
Statement("WriteAppBucket", ["s3:PutObject"], ["arn:aws:s3:::app-data/uploads/*"]),
])
role = aws.iam.Role("app", assume_role_policy=json.dumps({
"Version": "2012-10-17",
"Statement": [{"Effect": "Allow", "Action": "sts:AssumeRole",
"Principal": {"Service": "ec2.amazonaws.com"}}],
}))
aws.iam.RolePolicy("app-scoped", role=role.name, policy=policy.to_json())
Provider note: Keep the trust policy (
assume_role_policy) just as tight — name the exact principal service or account. A scoped permission policy on a role anyone can assume is not least privilege.
2. Scope resources, not just actions
s3:GetObject on * is still a breach. Pass concrete ARNs — bucket paths, table ARNs, key ARNs — and use conditions to fence broad-but-necessary actions like kms:Decrypt.
# Run: pulumi up
from __future__ import annotations
from typing import Any
def scoped_kms_decrypt(key_arn: str, calling_service: str) -> dict[str, Any]:
# Provider note: ViaService confines Decrypt to calls made through one service.
return {
"Sid": "DecryptViaS3", "Effect": "Allow",
"Action": ["kms:Decrypt", "kms:GenerateDataKey"],
"Resource": [key_arn],
"Condition": {"StringEquals": {"kms:ViaService": calling_service}},
}
3. Gate against drift back to wildcards
Run the builder's output through a check in CI so a future edit reintroducing "*" fails the pipeline before deploy. This complements scanning the synthesized output with Checkov.
# Run: python -m ci.iam_gate
from __future__ import annotations
import json
def assert_no_wildcards(policy_json: str) -> None:
doc = json.loads(policy_json)
for stmt in doc["Statement"]:
actions = stmt.get("Action", [])
actions = [actions] if isinstance(actions, str) else actions
resources = stmt.get("Resource", [])
resources = [resources] if isinstance(resources, str) else resources
assert "*" not in resources, f"{stmt.get('Sid')}: wildcard resource"
assert not any(a == "*" or a.endswith(":*") for a in actions), \
f"{stmt.get('Sid')}: wildcard action"
Verification
Test the rendered policy for over-broad grants, and confirm effective permissions with the IAM policy simulator before trusting the role in production.
# Run: pytest tests/test_iam.py -v
import pytest
from infra.iam import Statement, PolicyBuilder
from ci.iam_gate import assert_no_wildcards
def test_builder_rejects_wildcard_action() -> None:
with pytest.raises(ValueError):
Statement("Bad", ["s3:*"], ["arn:aws:s3:::b/*"])
def test_rendered_policy_has_no_wildcards() -> None:
p = PolicyBuilder([Statement("Ok", ["s3:GetObject"], ["arn:aws:s3:::b/*"])])
assert_no_wildcards(p.to_json()) # must not raise
# Verify EFFECTIVE permissions against the live role before relying on it.
aws iam simulate-principal-policy \
--policy-source-arn arn:aws:iam::123456789012:role/app \
--action-names s3:DeleteObject \
--resource-arns 'arn:aws:s3:::app-data/uploads/x' # expect: implicitDeny
Gotchas & Edge Cases
s3:*is a wildcard too. Service-prefixed wildcards (s3:*,ec2:Describe*) pass naive== "*"checks but grant far more than intended. The builder above also rejects any action ending in:*— keep that guard.
A tight permission policy on a loose trust policy is not least privilege. If the role's
assume_role_policylets any account or any service assume it, scoped permissions only limit the blast radius after assumption. Lock both ends.
Conditions can be silently ineffective. A condition key the action does not support (e.g. an unsupported
aws:SourceIpon an IAM action) is ignored, leaving the grant wide open. Validate withaws iam simulate-principal-policyusing realistic context keys rather than assuming the condition fires.
Related
- Security & Compliance Basics — the parent overview of policy-as-code layers, secret handling, and drift detection.
- Scanning Python IaC with Checkov — catch wildcard grants and other misconfigurations in synthesized output as a CI gate.
- Python IaC Fundamentals & Strategy — the grandparent overview connecting IAM scoping to design principles and tooling choice.