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.
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
- 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.
# 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.
# 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.
# 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
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.
IAM is eventually consistent, and tests lie about it. A role created and immediately assumed can fail with
AccessDeniedfor several seconds even though the policy is correct. Integration tests that retry onAccessDeniedhide 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 principalis what you get from putting aPrincipalblock 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.
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.
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.