IAM Least-Privilege Mutation Testing
Most teams claim their IAM policies are least-privilege. Few prove it with an executable test. This tutorial makes the proof mechanical: take a Lambda that touches six services, compile a golden policy with one Sid per action, then mutate it, one Sid removed at a time, and assert the Lambda fails with AccessDenied on exactly the expected action. If every mutation fails on the right action, the policy is provably minimum-viable.
Sids
(one per service action, each scoped to one resource) and invokes the Lambda once,
asserting all six steps succeed.
Then loops through six mutations, each removing one Sid,
invoking the Lambda, and asserting the Lambda fails with AccessDenied
on exactly the action that Sid was authorizing.
Total runtime ~15 s.
Requires IAM_ENFORCEMENT=1 on the LocalEmu host (without it,
every API call would pass and the mutation loop would prove nothing).
Source: 19-iam-least-privilege/ in the examples repo.
Proves justification
Every Sid, removed, breaks something. If it doesn't, the Sid is unjustified, delete it.
Proves minimality
Each mutation breaks exactly the expected action, no other Sid was covertly authorizing it.
Fast policy swaps
LocalEmu evaluates IAM in-process, the next Lambda invoke uses the new policy immediately. 7 policy swaps in 15 s.
Step-by-Step Walkthrough
Step 1: Start LocalEmu with IAM enforcement enabled
$ IAM_ENFORCEMENT=1 localemu start
# IAM_ENFORCEMENT=1 = evaluate every API request against the caller's
# attached policies and deny on Effect != Allow. Without it, the
# mutation loop is meaningless: every call would pass. With IAM_ENFORCEMENT=1, every API call is evaluated against the caller's attached policies (or the role assumed via STS), and a deny short-circuits the dispatch. Soft mode is also available: IAM_ENFORCEMENT=soft logs every denial but lets the request through, useful for retro-fitting policies onto an existing service without breaking it on the first run.
Step 2: Provision the 6 resources + Lambda + role
Scripted in infra/01_setup.sh: creates an S3 bucket with a config object, a DynamoDB table, a KMS key with a pre-encrypted ciphertext, an SSM parameter, a Secrets Manager secret, an SNS topic, and finally a Lambda whose execution role starts with CloudWatch Logs only. The Lambda's environment points at each resource.
Step 3: Attach the "golden" minimum-viable policy
# The golden policy. One Sid per business action, each scoped to exactly
# one resource. This is the minimum the Lambda needs to succeed on all
# six steps, the thing the mutation loop will prove is minimum-viable.
$ cat << 'JSON' | awsx iam put-role-policy \
--role-name iam-mvp-lambda-role \
--policy-name mutation-test-policy \
--policy-document file:///dev/stdin
{
"Version": "2012-10-17",
"Statement": [
{"Sid":"GetConfig", "Effect":"Allow","Action":"s3:GetObject", "Resource":"arn:aws:s3:::iam-mvp-config/*"},
{"Sid":"DecryptKms", "Effect":"Allow","Action":"kms:Decrypt", "Resource":"arn:aws:kms:us-east-1:000000000000:key/9fc314c1..."},
{"Sid":"ReadParam", "Effect":"Allow","Action":"ssm:GetParameter", "Resource":"arn:aws:ssm:us-east-1:000000000000:parameter/iam-mvp/runtime-config"},
{"Sid":"ReadSecret", "Effect":"Allow","Action":"secretsmanager:GetSecretValue", "Resource":"arn:aws:secretsmanager:us-east-1:000000000000:secret:iam-mvp/api-key-GsqIWz"},
{"Sid":"WriteEvent", "Effect":"Allow","Action":"dynamodb:PutItem", "Resource":"arn:aws:dynamodb:us-east-1:000000000000:table/iam-mvp-events"},
{"Sid":"Notify", "Effect":"Allow","Action":"sns:Publish", "Resource":"arn:aws:sns:us-east-1:000000000000:iam-mvp-notify"}
]
}
JSON Six Sids, one per business action, each scoped to exactly one resource. This is the hypothesis. The mutation loop will prove (or refute) it.
Step 4: Baseline, invoke the Lambda, expect status=ok
$ awsx lambda invoke --function-name iam-mvp-target \
--payload fileb://./payload.json /tmp/response.json
$ cat /tmp/response.json
{
"status": "ok",
"trace": [
{"action": "s3:GetObject", "result": "ok"},
{"action": "kms:Decrypt", "result": "ok"},
{"action": "ssm:GetParameter", "result": "ok"},
{"action": "secretsmanager:GetSecretValue", "result": "ok"},
{"action": "dynamodb:PutItem", "result": "ok"},
{"action": "sns:Publish", "result": "ok"}
]
} Lambda runs under STS-derived session credentials for the execution role. Each of its boto3 calls is evaluated against the role's attached policies. All six steps succeed, the golden policy is at least sufficient. Is it minimum?
Step 5: Mutate, remove one Sid, invoke, assert the expected action is denied
# Remove Sid "GetConfig" and invoke. The handler should stop on step 1.
$ python3 -c "
import json
pol = json.load(open('.state/golden.json'))
pol['Statement'] = [s for s in pol['Statement'] if s['Sid'] != 'GetConfig']
print(json.dumps(pol))
" > /tmp/mutant.json
$ awsx iam put-role-policy --role-name iam-mvp-lambda-role \
--policy-name mutation-test-policy \
--policy-document file:///tmp/mutant.json
$ awsx lambda invoke --function-name iam-mvp-target \
--payload fileb://./payload.json /tmp/response.json
$ cat /tmp/response.json
{
"status": "forbidden",
"denied_action": "s3:GetObject",
"trace": [
{"action": "s3:GetObject", "result": "denied", "error_code": "AccessDenied"}
]
} The handler's response is structured so the assertion can be exact: denied_action == "s3:GetObject". Not "some call failed", the right call failed. That's what proves the Sid was justified.
Step 6: Loop over all six Sids
$ ./scripts/demo.sh
== 5. Invoke the Lambda under the golden policy, expect status=ok ==
✓ Baseline green, golden policy lets all 6 steps through
== 6. Mutation loop, remove each Sid, expect its action to be denied ==
✓ denied_action=s3:GetObject (as expected)
✓ denied_action=kms:Decrypt (as expected)
✓ denied_action=ssm:GetParameter (as expected)
✓ denied_action=secretsmanager:GetSecretValue (as expected)
✓ denied_action=dynamodb:PutItem (as expected)
✓ denied_action=sns:Publish (as expected)
✓ All 6/6 mutations denied exactly the expected action.
Golden policy is provably minimum-viable. Six mutations, six denials, each on exactly the expected action. The golden policy is now provably minimum-viable.
What the run proves
| Sid removed | Expected denial | Proves |
|---|---|---|
| GetConfig | s3:GetObject | S3 read is only via this Sid |
| DecryptKms | kms:Decrypt | KMS decrypt is only via this Sid |
| ReadParam | ssm:GetParameter | SSM read is only via this Sid |
| ReadSecret | secretsmanager:GetSecretValue | Secret read is only via this Sid |
| WriteEvent | dynamodb:PutItem | DDB write is only via this Sid |
| Notify | sns:Publish | SNS publish is only via this Sid |
Design note: why pass KeyId explicitly on kms:Decrypt
The handler calls kms.decrypt(CiphertextBlob=..., KeyId=KMS_KEY_ID), not just CiphertextBlob=.... Here is why on LocalEmu:
- LocalEmu's IAM evaluator builds the resource ARN from the request parameters it sees. With no
KeyIdparam, it falls back to a wildcard (arn:aws:kms:*:*:key/*), which a narrow policy granting one specific key ARN will not authorize, so the call is denied. - Passing
KeyIdexplicitly lets the evaluator scopekms:Decryptto that one key.
Rule of thumb on LocalEmu: if you want a narrow kms:Decrypt policy, pass KeyId at the SDK call site.
Full source: src/handler.py
The Lambda that the mutation harness deploys. Performs six API calls in a fixed order and returns a structured envelope so the test can pinpoint which permission was missing. boto3 inherits the role's STS credentials via AWS_ENDPOINT_URL set by LocalEmu's Lambda runtime.
"""
Lambda that touches 6 AWS services in a fixed order. The handler is
structured so a mutation test can pinpoint which permission is missing:
- If every step succeeds, return {"status": "ok", "trace": [...]}.
- If any step fails with AccessDenied, stop and return
{"status": "forbidden", "denied_action": "<service:Action>", ...}.
The handler reads its resource handles from environment variables, which
Terraform writes when it creates the function. AWS_ENDPOINT_URL is
injected automatically by LocalEmu's Lambda runtime.
"""
import base64
import json
import os
import boto3
from botocore.exceptions import ClientError
BUCKET = os.environ["CONFIG_BUCKET"]
TABLE = os.environ["EVENT_TABLE"]
TOPIC_ARN = os.environ["NOTIFY_TOPIC"]
KMS_KEY_ID = os.environ["KMS_KEY_ID"]
SSM_PARAM = os.environ["SSM_PARAM_NAME"]
SECRET_ID = os.environ["SECRET_ID"]
CIPHERTEXT_B64 = os.environ["KMS_CIPHERTEXT_B64"]
DENIED_CODES = {
"AccessDenied",
"AccessDeniedException",
"UnauthorizedOperation",
"403",
}
def _step(action, fn, trace):
try:
fn()
trace.append({"action": action, "result": "ok"})
return None
except ClientError as e:
code = e.response.get("Error", {}).get("Code", "")
if code in DENIED_CODES:
trace.append({"action": action, "result": "denied", "error_code": code})
return {"status": "forbidden", "denied_action": action, "trace": trace}
trace.append({"action": action, "result": "error", "error_code": code})
raise
def handler(event, context):
s3 = boto3.client("s3")
kms = boto3.client("kms")
ssm = boto3.client("ssm")
sm = boto3.client("secretsmanager")
ddb = boto3.client("dynamodb")
sns = boto3.client("sns")
request_id = (event or {}).get("request_id", context.aws_request_id)
trace = []
steps = (
("s3:GetObject",
lambda: s3.get_object(Bucket=BUCKET, Key="config.json")["Body"].read()),
("kms:Decrypt",
lambda: kms.decrypt(
CiphertextBlob=base64.b64decode(CIPHERTEXT_B64),
KeyId=KMS_KEY_ID, # pass explicitly so IAM can scope to this key
)),
("ssm:GetParameter",
lambda: ssm.get_parameter(Name=SSM_PARAM)),
("secretsmanager:GetSecretValue",
lambda: sm.get_secret_value(SecretId=SECRET_ID)),
("dynamodb:PutItem",
lambda: ddb.put_item(
TableName=TABLE,
Item={"pk": {"S": request_id}, "at": {"S": "now"}},
)),
("sns:Publish",
lambda: sns.publish(TopicArn=TOPIC_ARN, Message=json.dumps({"id": request_id}))),
)
for action, fn in steps:
denied = _step(action, fn, trace)
if denied is not None:
return denied
return {"status": "ok", "trace": trace} Full demo output
Captured from a clean run on LocalEmu v0.1.dev133 with IAM_ENFORCEMENT=1.
[15:25:11] Clearing any previous run state (best effort)
✓ Clean slate
== 1. Setup: create the six AWS resources ==
[15:25:14] Creating S3 bucket iam-mvp-config
✓ Bucket iam-mvp-config created + config.json uploaded
[15:25:14] Creating DynamoDB table iam-mvp-events
✓ Table iam-mvp-events ACTIVE
[15:25:15] Creating KMS key
✓ KMS key ec21b661-ab4f-4df5-9aa6-549ea76278f1
[15:25:15] Encrypting a bootstrap secret with the key (will be decrypted at invoke)
✓ Ciphertext captured (136 chars base64)
[15:25:16] Creating SSM parameter /iam-mvp/runtime-config
✓ Parameter /iam-mvp/runtime-config
[15:25:16] Creating Secrets Manager secret iam-mvp/api-key
✓ Secret arn:aws:secretsmanager:us-east-1:000000000000:secret:iam-mvp/api-key-zXyAXf
[15:25:16] Creating SNS topic iam-mvp-notify
✓ Topic arn:aws:sns:us-east-1:000000000000:iam-mvp-notify
== 2. Create the Lambda execution role (CloudWatch Logs only) ==
[15:25:17] Creating role iam-mvp-lambda-role
✓ Role arn:aws:iam::000000000000:role/iam-mvp-lambda-role (logs-only baseline)
== 3. Package + create the Lambda function ==
[15:25:17] Zip built (./build/handler.zip)
[15:25:17] Creating Lambda function iam-mvp-target
✓ Lambda iam-mvp-target Active
✓ Setup complete. Ids in ./.state/ids.env
== 4. Attach the golden policy (6 Sids, one per action) ==
✓ Golden policy attached to iam-mvp-lambda-role
== 5. Invoke the Lambda under the golden policy: expect status=ok ==
[15:25:28] Invoking iam-mvp-target
{"status": "ok", "trace": [{"action": "s3:GetObject", "result": "ok"}, {"action": "kms:Decrypt", "result": "ok"}, {"action": "ssm:GetParameter", "result": "ok"}, {"action": "secretsmanager:GetSecretValue", "result": "ok"}, {"action": "dynamodb:PutItem", "result": "ok"}, {"action": "sns:Publish", "result": "ok"}]}
✓ Baseline green: golden policy lets all 6 steps through
== 6. Mutation loop: remove each Sid, expect its action to be denied ==
[15:25:32] Mutant: remove Sid 'GetConfig', expect denied_action == 's3:GetObject'
✓ denied_action=s3:GetObject (as expected)
[15:25:32] Mutant: remove Sid 'DecryptKms', expect denied_action == 'kms:Decrypt'
✓ denied_action=kms:Decrypt (as expected)
[15:25:33] Mutant: remove Sid 'ReadParam', expect denied_action == 'ssm:GetParameter'
✓ denied_action=ssm:GetParameter (as expected)
[15:25:34] Mutant: remove Sid 'ReadSecret', expect denied_action == 'secretsmanager:GetSecretValue'
✓ denied_action=secretsmanager:GetSecretValue (as expected)
[15:25:34] Mutant: remove Sid 'WriteEvent', expect denied_action == 'dynamodb:PutItem'
✓ denied_action=dynamodb:PutItem (as expected)
[15:25:35] Mutant: remove Sid 'Notify', expect denied_action == 'sns:Publish'
✓ denied_action=sns:Publish (as expected)
== 7. Result ==
[15:25:36] Restoring the golden policy so the role ends the demo in a working state
✓ Golden restored
✓ All 6/6 mutations denied exactly the expected action.
Golden policy is provably minimum-viable.
→ demo complete. Run scripts/teardown.sh when done. Files
Repository layout.
19-iam-least-privilege/
├── README.md
├── scripts/
│ ├── demo.sh
│ └── teardown.sh
├── lib/
│ └── common.sh (awsx, invoke_lambda helper)
├── src/
│ └── handler.py (6 API calls with structured response)
└── infra/
├── 01_setup.sh (S3, DDB, KMS, SSM, Secret, SNS + Lambda + role)
├── 02_baseline.sh (golden policy, invoke, assert ok)
└── 03_mutate.sh (6 mutations, each must deny exactly one action)