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 >= 7 for policy assertions, and the AWS CLI for aws 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_policy lets 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:SourceIp on an IAM action) is ignored, leaving the grant wide open. Validate with aws iam simulate-principal-policy using realistic context keys rather than assuming the condition fires.