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.

Context

Before writing a builder it is worth being exact about what "the permissions of a role" means, because the identity policy your code emits is only one input to the decision AWS actually makes. An API call is authorized by intersecting several policy types, and any explicit Deny in any of them wins outright.

How AWS decides an API call How AWS decides an API call: layered from Explicit deny anywhere down to Resource policy. Explicit deny anywhere Any matching Deny ends evaluation immediately Service control policy Organization ceiling; an allow here is necessary, not sufficient Permission boundary Per-principal ceiling set by the platform team Identity policy What the role itself grants — the part this guide builds Resource policy Bucket, key, or queue policy on the target
An identity policy is one term in an intersection, which is why a tight role can still be over-permissioned in practice.

Two consequences shape the code below. First, a wildcard in an identity policy is not automatically an exploit — a service control policy above it may already forbid the dangerous half — but it is a permanent liability, because the day someone loosens the organization policy the role silently gains capability nobody reviewed. Least privilege at the identity layer is what keeps a change in one team's account from becoming a change in your blast radius.

Second, the reverse also holds: a perfectly scoped identity policy grants nothing if the resource policy on the target bucket does not also allow the principal, which is why cross-account access fails in a way that looks like an IAM bug and is really a missing bucket policy. When a call is denied and you cannot see why, the error message tells you which layer refused — User: arn:aws:sts::123456789012:assumed-role/app/i-0abc is not authorized to perform: s3:GetObject on resource: ... because no identity-based policy allows the action names the identity layer explicitly, while ... with an explicit deny in a service control policy names the organization layer.

Prerequisites

Prerequisites Prerequisites: layered from Python down to IAM Access Analyzer. Python CDKTF AWS IAM AWS CLI IAM Access Analyzer
Prerequisites: the building blocks this section assembles.
  • 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

Implementation Implementation: 1. Build policies then 2. Scope resources then 3. Gate against 1. Build policies 2. Scope resources 3. Gate against
Implementation: the stages run left to right — 1. Build policies, 2. Scope resources, 3. Gate against.

A frozen dataclass per statement rejects wildcards at construction. The builder raises before any provider call, so an over-broad policy never reaches AWS.

# CLI: 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.

The __post_init__ guard is deliberately crude, and that is a feature: a check that a reviewer can hold in their head is a check a reviewer will keep. Two extensions earn their complexity, though. NotAction should be rejected outright — a statement written as "NotAction": ["iam:*"] grants everything except IAM and reads, to a hurried reviewer, like a restriction. And iam:PassRole deserves a rule of its own, because it is the action that turns a modest role into an escalation: a principal that can pass an administrator role to a Lambda function it also controls has administrator access by a slightly longer path. Require an explicit resource ARN and an iam:PassedToService condition on any statement that includes it.

Watch the size of what the builder emits. An inline role policy is capped at 2,048 characters and a customer-managed policy at 6,144; exceeding either fails the apply with LimitExceeded: Maximum policy size of 2048 bytes exceeded. Least privilege is verbose by nature, so this limit is reached sooner than people expect — usually by listing a dozen bucket prefixes. The correct response is to split into several managed policies attached to the same role rather than to reach for a wildcard that collapses ten ARNs into one.

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.

The most common scoping bug in S3 policies is not a wildcard at all: it is applying an object-level ARN to a bucket-level action. s3:ListBucket acts on the bucket (arn:aws:s3:::app-data), while s3:GetObject acts on objects (arn:aws:s3:::app-data/*). Put ListBucket on the object ARN and every list call returns AccessDenied while the gets succeed — a confusing failure that usually gets "fixed" by adding arn:aws:s3:::app-data/* and a wildcard action. Encode the distinction in the builder so it cannot be got wrong by hand:

# CLI: pytest tests/test_iam.py -q
from __future__ import annotations
from typing import Final

BUCKET_LEVEL_ACTIONS: Final[frozenset[str]] = frozenset({
    "s3:ListBucket", "s3:GetBucketLocation", "s3:ListBucketMultipartUploads",
})

def s3_arns(bucket: str, prefix: str, actions: list[str]) -> list[str]:
    """Return the ARN shape each S3 action actually operates on."""
    if all(a in BUCKET_LEVEL_ACTIONS for a in actions):
        return [f"arn:aws:s3:::{bucket}"]
    if any(a in BUCKET_LEVEL_ACTIONS for a in actions):
        raise ValueError("split bucket-level and object-level actions into separate statements")
    return [f"arn:aws:s3:::{bucket}/{prefix}"]
# Provider note: ListBucket is further narrowed with a Condition on s3:prefix —
# the resource ARN alone cannot restrict which keys are listed.
# CLI: 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.

# CLI: 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"

Run this against the rendered document rather than the builder, so it also catches policies that arrive from elsewhere — a JSON file checked into the repository, a policy fetched from a module, a document assembled by a helper that bypasses Statement. The gate is only as good as its coverage of the inputs.

4. Cap the role with a permission boundary

The builder constrains what your code writes. A permission boundary constrains what any future code can write, including code written under time pressure at 3am. It is an IAM policy attached to a principal that acts as a ceiling: the effective permissions are the intersection of the boundary and the identity policy.

Permission boundary versus identity policy Permission boundary versus identity policy: Effective permissions with 4 facets. Effective permissions Boundary sets the maximum a role may ever hold Identity policy grants inside that maximum Intersection only actions allowed by both iam:PassRole the escalation path both must fence
Effective permissions are the intersection: widening the identity policy alone changes nothing above the boundary.
# CLI: pulumi up
from __future__ import annotations
import json
import pulumi_aws as aws

boundary = aws.iam.Policy(
    "workload-boundary",
    description="Ceiling for all application roles in this account",
    policy=json.dumps({
        "Version": "2012-10-17",
        "Statement": [
            {"Sid": "AllowedServices", "Effect": "Allow",
             "Action": ["s3:*", "dynamodb:*", "logs:*"], "Resource": "*"},
            {"Sid": "NeverTouchIdentity", "Effect": "Deny",
             "Action": ["iam:*", "organizations:*", "account:*"], "Resource": "*"},
        ],
    }),
)

app_role = aws.iam.Role(
    "app",
    permissions_boundary=boundary.arn,
    assume_role_policy=json.dumps({
        "Version": "2012-10-17",
        "Statement": [{"Effect": "Allow", "Action": "sts:AssumeRole",
                       "Principal": {"Service": "ec2.amazonaws.com"}}],
    }),
)
# State implication: attaching a boundary to an existing role changes effective
# permissions immediately on the next API call — there is no propagation delay
# you can rely on, and no rollback other than detaching it.

The wildcards in the boundary are correct and are not a contradiction of everything above. A boundary is a maximum, not a grant: "s3:*" there means "an identity policy may allow S3 actions", and nothing is permitted until an identity policy actually allows it. The Deny on iam:* is what makes the boundary load-bearing — it prevents any role in the account from editing its own permissions, which closes the escalation path that makes over-broad identity policies catastrophic rather than merely untidy.

Verification

Test the rendered policy for over-broad grants, and confirm effective permissions with the IAM policy simulator before trusting the role in production.

Verification Verification: Test → Program → Mock/Cloud. Test Program Mock/Cloud invoke declare resolve assert
Verification: the test drives the program and asserts on resolved values.
# CLI: 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

The simulator is the only tool that answers the question you actually care about — "can this principal do this thing?" — across all the layers in the evaluation diagram. Reading the JSON by eye does not scale past a handful of actions, so drive it from a table of expectations and fail CI on any mismatch:

# CLI: python -m ci.simulate_iam
from __future__ import annotations
from typing import Final
import boto3

ROLE_ARN: Final[str] = "arn:aws:iam::123456789012:role/app"

# (action, resource, expected decision)
EXPECTATIONS: Final[list[tuple[str, str, str]]] = [
    ("s3:GetObject",    "arn:aws:s3:::app-data/reports/q3.csv", "allowed"),
    ("s3:PutObject",    "arn:aws:s3:::app-data/uploads/in.csv", "allowed"),
    ("s3:DeleteObject", "arn:aws:s3:::app-data/uploads/in.csv", "implicitDeny"),
    ("iam:CreateUser",  "*",                                    "explicitDeny"),
]

def check() -> int:
    iam = boto3.client("iam")
    failures = 0
    for action, resource, expected in EXPECTATIONS:
        response = iam.simulate_principal_policy(
            PolicySourceArn=ROLE_ARN,
            ActionNames=[action],
            ResourceArns=[resource],
        )
        decision = response["EvaluationResults"][0]["EvalDecision"]
        if decision != expected:
            print(f"FAIL {action} on {resource}: got {decision}, want {expected}")
            failures += 1
    return failures
# Provider note: EvalDecision is one of allowed, explicitDeny, implicitDeny.
# explicitDeny means a Deny statement matched — usually the permission boundary.

Assert the denials, not only the allows. A test suite that checks the role can read its bucket passes just as happily against a role with AdministratorAccess; the assertions that carry information are the ones that expect implicitDeny. Note also that the simulator evaluates policy, not reality — it does not know about a bucket policy in another account, and it will report allowed for a call that would fail in practice.

Gotchas & Edge Cases

Gotchas & Edge Cases Gotchas & Edge Cases: Where it breaks with 3 facets. Where it breaks assume_role_po watch this boundary Edge Cases watch this boundary IAM watch this boundary
Gotchas & Edge Cases: the boundaries where things break and what to check.

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.

IAM is eventually consistent, and tests lie about it. A role created and immediately assumed can fail with AccessDenied for several seconds even though the policy is correct. Integration tests that retry on AccessDenied hide genuine policy bugs; retry on the assume and not on the operation under test.

A malformed document fails at apply, not at synth. MalformedPolicyDocument: Policy document should not specify a principal is what you get from putting a Principal block in an identity policy instead of a trust policy — the two look similar enough that copy-paste between them is common. Typed builders that cannot express a principal on an identity statement remove the whole class of error.

Resource: "*" is unavoidable for a few actions. ec2:DescribeInstances, sts:GetCallerIdentity, and most list operations do not support resource-level permissions, so a scoped ARN there produces a policy that denies everything. Keep an explicit allowlist of these actions in the builder with a comment naming why, rather than weakening the general rule.

Operational Notes

Least privilege is not a one-time policy write; it is a loop. Start from the specific actions your code invokes, deploy with Access Analyzer or CloudTrail observing, then remove everything that never fired. Encoding the resulting policy in typed Python keeps it reviewable and lets you diff permission changes in the same pull request as the resources they protect.

Least-privilege loop Least-privilege loop: Grant minimal → Observe usage → Tighten → Review → repeat. Grant minimal Observe usage Tighten Review
Least privilege is a loop: start narrow, watch what is actually used, and tighten continuously.

The highest-leverage rule is to ban wildcard actions on privileged services, which you can enforce automatically with a CrossGuard or custom Checkov rule so a "Action": "*" never merges. Scope roles per workload rather than sharing one broad role, and prefer short-lived credentials from an execution role over long-lived keys, so a leak has a small blast radius and a short lifetime.

FAQ

How do I find the minimum permissions a role needs?

Start from the actions your code actually calls, deploy with CloudTrail or Access Analyzer watching, then tighten the policy to the observed set rather than guessing up front.

Should I use managed policies or inline?

Prefer customer-managed policies you version in code; reserve AWS-managed policies for well-understood, broad grants and inline policies for one-off, resource-scoped permissions.

Can policy-as-code enforce least privilege?

Yes — a CrossGuard or Checkov rule can fail any role that attaches a wildcard action, turning the principle into an automated gate.

What is the difference between a permission boundary and a service control policy?

A boundary is attached to an individual principal and caps what that role or user can do; a service control policy is attached to an organizational unit and caps every principal in every account beneath it. Both are ceilings rather than grants, and both are evaluated as an intersection with the identity policy. Use the organization policy for account-wide invariants and boundaries for "this role may never touch IAM".

How do I stop a policy from exceeding the IAM size limit without adding wildcards?

Split it. A role can carry up to ten attached managed policies, so group statements by resource family — one policy per bucket, one for the queue, one for logging — and attach several. If a single statement is the problem, factor common prefixes into an ARN with a narrow path wildcard (arn:aws:s3:::app-data/tenants/*/reports/*), which is a scoped path rather than an open grant.

Do I need a separate role per environment, or is one role with conditions enough?

Separate roles. Condition-based separation depends on every future statement remembering to carry the condition, and one that forgets silently spans environments. Distinct roles in distinct accounts make the boundary structural, and the extra roles cost nothing.

Should the deployment role itself be least privilege?

Yes, and it is usually the worst offender because giving CI AdministratorAccess is the fastest way to unblock a pipeline. Scope it to the services the stack actually manages and attach a permission boundary that denies iam:* outside a specific path prefix, so a compromised pipeline cannot mint itself a new administrator.