Docs / Use Cases / IAM Least-Privilege Mutation Testing

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.

What the demo does. Provisions 6 AWS resources (S3 bucket, DynamoDB table, KMS key, SSM parameter, Secrets Manager secret, SNS topic), a Lambda whose handler exercises all six, and an IAM role that the Lambda assumes via STS. Attaches a "golden" inline policy with six 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

Terminal
$ 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

infra/02_baseline.sh
# 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

Terminal
$ 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

infra/03_mutate.sh
# 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
$ ./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
GetConfigs3:GetObjectS3 read is only via this Sid
DecryptKmskms:DecryptKMS decrypt is only via this Sid
ReadParamssm:GetParameterSSM read is only via this Sid
ReadSecretsecretsmanager:GetSecretValueSecret read is only via this Sid
WriteEventdynamodb:PutItemDDB write is only via this Sid
Notifysns:PublishSNS 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 KeyId param, 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 KeyId explicitly lets the evaluator scope kms:Decrypt to 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": "&lt;service:Action&gt;", ...}.

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)