Hands-On Project: Least-Privilege IAM Review¶
Review five sample IAM policies offline: find the ones that grant too much, run a linter, write your own check, and rewrite the worst one. No cloud account, no credentials and no API calls are involved. Everything works on JSON files on your machine.
Estimated Time: 1-2 hours Difficulty: Intermediate Prerequisites: Python 3, uv or pip. Read Least privilege explained and IAM explained first. Cost: Free.
What you will build¶
flowchart LR
POL[Sample policy<br/>JSON files] --> LINT[Linter:<br/>syntax, unknown actions,<br/>resource scoping]
POL --> OWN[Your own check:<br/>wildcards, PassRole]
LINT --> REV[Human review:<br/>what could this do?]
OWN --> REV
REV --> FIX[Rewritten policy:<br/>named actions,<br/>named resources] Two automated passes find the cheap problems. A person then asks what the policy could do in the wrong hands and rewrites it.
What is local here and what a real account adds¶
This lab is deliberately offline. It teaches you to read policies. It cannot tell you which permissions are actually used, which is the part that makes right-sizing safe. In a real account you would add:
- Last-accessed data. AWS shows when a role last used each service, so you can remove what is unused: π Refining permissions using last accessed information (AWS) - the data behind a safe cut. GCP offers the same idea as π IAM Recommender overview. NOT RUN here; needs an account.
- Access Analyzer-style policy generation from audit logs (AWS CloudTrail), producing a policy from observed calls. NOT RUN.
- External access and unused access findings. NOT RUN.
- Effective permissions. Policies combine with permission boundaries, SCPs and resource policies. Sample files cannot show that.
Read the vendor guidance: π IAM security best practices (AWS) - apply least-privilege permissions. A linter that runs in the account: π Policy validation (IAM Access Analyzer) - the managed version of the check in step 2.
How this was run¶
On 2026-11-07, in a throwaway Python virtual environment under ~/.cache, with the open-source linter Parliament (1.6.4). It needed setuptools<81 to import. The venv was deleted afterwards. No AWS profile was set or used.
Step 1: Write the five sample policies¶
Make a policies/ folder. These are invented examples, not from a real account.
01-app-role.json - a role "just to get the app working":
{"Version": "2012-10-17",
"Statement": [{"Sid": "Everything", "Effect": "Allow", "Action": "*", "Resource": "*"}]}
02-s3-wide.json - "the app uses S3 and reads logs":
{"Version": "2012-10-17",
"Statement": [
{"Sid": "S3All", "Effect": "Allow", "Action": "s3:*", "Resource": "*"},
{"Sid": "ReadLogs", "Effect": "Allow",
"Action": ["logs:GetLogEvents", "logs:DescribeLogStreams"], "Resource": "*"}]}
03-passrole.json - a deploy role:
{"Version": "2012-10-17",
"Statement": [{"Sid": "Deploy", "Effect": "Allow",
"Action": ["lambda:CreateFunction", "lambda:UpdateFunctionCode", "iam:PassRole"],
"Resource": "*"}]}
04-scoped.json - read one prefix of one bucket:
{"Version": "2012-10-17",
"Statement": [
{"Sid": "ReadReports", "Effect": "Allow", "Action": ["s3:GetObject"],
"Resource": "arn:aws:s3:::example-reports-bucket/monthly/*"},
{"Sid": "ListReports", "Effect": "Allow", "Action": "s3:ListBucket",
"Resource": "arn:aws:s3:::example-reports-bucket",
"Condition": {"StringLike": {"s3:prefix": "monthly/*"}}}]}
05-typo.json - two misspelled action names:
{"Version": "2012-10-17",
"Statement": [{"Effect": "Allow",
"Action": ["s3:GetObjects", "ec2:DescribeInstancez"], "Resource": "*"}]}
Before running anything, review them by eye. For each, write down: what is the worst thing this could do if the role's credentials leaked? Compare with your notes after step 3.
Step 2: Run a linter¶
uv venv -q iamvenv
uv pip install -q --python iamvenv/bin/python parliament "setuptools<81"
iamvenv/bin/parliament --directory policies < /dev/null 2>/dev/null | cut -c1-110
The < /dev/null matters: Parliament reads stdin when it is attached, and refuses to combine that with a path. Real output (the long action lists are cut):
LOW - Unknown action - - Unknown action s3:GetObjects - {'string': 's3:GetObjects', 'line': 4, ...
LOW - Unknown action - - Unknown action ec2:DescribeInstancez - {'string': 'ec2:DescribeInstancez', ...
LOW - Unnecessary use of Resource * - - ['s3:AbortMultipartUpload', 's3:AssociateAccessGrantsIdentityCenter', ...
LOW - Unnecessary use of Resource * - - ['logs:DescribeLogStreams', 'logs:GetLogEvents'] - {'line': 5, ...
LOW - Unnecessary use of Resource * - - ['iam:PassRole', 'lambda:CreateFunction', 'lambda:UpdateFunctionCode'] - ...
What the linter caught and missed:
| Policy | Linter result |
|---|---|
01-app-role.json | Nothing. Action: "*" on Resource: "*" is valid syntax, so a syntax linter passes it. |
02-s3-wide.json | Resource * flagged for the logs actions and for the S3 actions. |
03-passrole.json | Resource * flagged. It does not say that iam:PassRole plus function creation is a privilege escalation path. |
04-scoped.json | Clean. |
05-typo.json | Both misspelled actions flagged. A typo here would silently grant nothing, and the app would fail in production. |
The lesson: a linter checks that a policy is well-formed. It does not check that it is wise. The worst policy in the set passed.
Step 3: Write a check for what the linter misses¶
# review.py - flags over-broad Allow statements
import json, glob
def as_list(x):
return x if isinstance(x, list) else [x]
for path in sorted(glob.glob("policies/*.json")):
doc = json.load(open(path))
flags = []
for st in as_list(doc["Statement"]):
if st.get("Effect") != "Allow":
continue
actions = as_list(st.get("Action", []))
resources = as_list(st.get("Resource", []))
if "*" in actions:
flags.append("ALL ACTIONS")
for a in actions:
if a.endswith(":*") and a != "*":
flags.append(f"service wildcard {a}")
if "*" in resources and "Condition" not in st:
flags.append("Resource * with no Condition")
if "iam:PassRole" in actions and "*" in resources:
flags.append("iam:PassRole on any role")
print(f"{path}: {', '.join(flags) if flags else 'no flags'}")
python3 review.py
policies/01-app-role.json: ALL ACTIONS, Resource * with no Condition
policies/02-s3-wide.json: service wildcard s3:*, Resource * with no Condition, Resource * with no Condition
policies/03-passrole.json: Resource * with no Condition, iam:PassRole on any role
policies/04-scoped.json: no flags
policies/05-typo.json: Resource * with no Condition
Together the two passes now cover all five. The script is a teaching tool, about 20 lines. It has false positives (some actions cannot be scoped to a resource, so Resource: "*" is correct) and false negatives (it does not know that a Deny elsewhere may cancel the grant). Treat its output as questions for a human, not verdicts.
Step 4: The human review¶
For each policy, answer:
- What can it do?
01is full administrator.02can read, overwrite and delete every bucket in the account, and change bucket policies.03can create a function that runs as any role it is allowed to pass, which can reach more than the deploy role itself holds. See the leaked cloud access key playbook for why the blast radius is the permission set. - What does the job need? Ask the owner for the list of API calls. In a real account, last-accessed data and audit logs answer this.
- What is the smallest rewrite? Named actions, named resources, a condition where one fits.
Rewrite 02-s3-wide.json for an app that reads one prefix and writes to another:
{"Version": "2012-10-17",
"Statement": [
{"Sid": "ReadInput", "Effect": "Allow", "Action": "s3:GetObject",
"Resource": "arn:aws:s3:::example-app-bucket/input/*"},
{"Sid": "WriteOutput", "Effect": "Allow", "Action": "s3:PutObject",
"Resource": "arn:aws:s3:::example-app-bucket/output/*"},
{"Sid": "ReadOwnLogs", "Effect": "Allow",
"Action": ["logs:GetLogEvents", "logs:DescribeLogStreams"],
"Resource": "arn:aws:logs:us-east-1:111122223333:log-group:/app/example:*"}]}
The account number 111122223333 and bucket name are placeholders. NOT RUN against a linter in the lab session; run it through step 2 and review.py yourself and confirm it is clean.
For 03-passrole.json, the fix is to limit iam:PassRole to the ARN of the one execution role the deploy needs, and to add a condition that the role is passed to the Lambda service. Check the exact condition key in the vendor docs before using it.
Step 5: Make it a habit¶
- Run a policy linter in CI on every infrastructure change.
- Require a justification and an expiry for any
*. - Review roles on a schedule using last-accessed data. A role with no use in 90 days is a deletion candidate.
Check your understanding¶
- Why did a linter pass an administrator policy?
- Why is
iam:PassRoleon*dangerous even when the role itself looks small? - What does last-accessed data tell you that reading the policy cannot?
Cleanup¶
rm -rf iamvenv policies review.py
Cert connections¶
Right-sizing policies and the account-level guardrails around them are core to AWS Security Specialty (identity and access management), CCSP and the access control objectives in Security+. The same ideas apply on Azure and GCP.
Related¶
- Least privilege explained
- IAM explained
- Case study: Capital One 2019 - an over-permissioned role in practice
- Identity and IAM topic