Exam Room · Security

Lab: Grant KMS Access With a Key Policy

January 19, 2028 · 10 min read

Cloud Security · part of The Exam Room

This is a lab in the Security Specialty hands-on track. The reference posts argue the decisions; this stands one up. The whole lab turns on a single idea that trips people up constantly: for a customer-managed KMS key, IAM permissions on a role are not enough on their own. The key has its own policy, and that policy has the final say. The full lab is in lab-scs-01-kms-key-policy.zip.

Before your first lab, do the one-time, once-per-account setup: run the zip’s preflight.sh to confirm your account is ready, then deploy the lab reaper, a standing backstop that auto-deletes any lab you forget to tear down after 24 hours. Every lab tags its stack for the reaper on deploy.

The scenario

A Lambda function needs to encrypt and decrypt some text with a customer-managed KMS key. The instinct that has served you everywhere else is to give the function’s execution role the KMS actions in its IAM policy and move on: kms:Encrypt, kms:Decrypt, kms:GenerateDataKey. On an AWS-managed key that would be the end of it.

A customer-managed key is different. It carries its own resource policy, the key policy, and that policy is authoritative. A principal reaches the key only when the key policy allows it, either by naming the principal directly or by delegating to IAM through the account-root statement. A role can hold every KMS action there is in its IAM policy and still come back with AccessDenied if the key’s policy never let it in.

To make the point impossible to miss, this lab strips the IAM half away entirely. The Lambda’s execution role has no kms permissions at all, only basic logging. So the key policy is the only door, which is exactly where the lab puts your attention.

What you’re given

CloudFormation builds three things: a customer-managed KMS key, a Lambda function on python3.12, and the function’s execution role. The role gets AWSLambdaBasicExecutionRole and nothing else, so it can write logs and touch nothing. The key id is passed to the function as the KEY_ID environment variable. The handler is complete and ships working in both src/ and solution/, because the gap here is the key policy, not the code.

The key policy as shipped has exactly one statement, the standard root statement:

KeyPolicy:
  Version: '2012-10-17'
  Id: scs-lab-01-key-policy
  Statement:
    - Sid: EnableRootAccountPermissions
      Effect: Allow
      Principal:
        AWS: !Sub arn:aws:iam::${AWS::AccountId}:root
      Action: kms:*
      Resource: '*'

That statement is what makes the key manageable and what lets IAM policies grant access at all, but it does not, on its own, grant the Lambda role anything. The role has no IAM kms permissions to be delegated, so the key stays shut to it.

Your task

Add a second statement to the key policy that names the Lambda role as a principal and allows the three data actions the handler calls:

- Sid: AllowLambdaRoleToUseTheKey
  Effect: Allow
  Principal:
    AWS: !GetAtt FunctionRole.Arn
  Action:
    - kms:Encrypt
    - kms:Decrypt
    - kms:GenerateDataKey
  Resource: '*'

Keep the root statement; you are adding alongside it, not replacing it. Referencing !GetAtt FunctionRole.Arn in the key policy is fine, because the role is created before the key and its ARN resolves cleanly. The Resource: '*' reads as “this key”: a key policy only ever governs its own key, so the wildcard is scoped to the one resource by definition.

Run it

./scripts/deploy.sh          # deploys src/template.yaml, then uploads the handler
./scripts/test.sh            # invokes the Lambda and checks the round-trip
./scripts/teardown.sh        # deletes everything

deploy.sh follows the same shape as the other Lambda labs: it deploys whichever template SRC points at, then zips handler.py as index.py and uploads it as the function code. test.sh invokes the function with a sample string and reads the result. With the unedited template the handler comes back with AccessDenied on the very first encrypt: the key exists, the role exists, but the role was never granted on the key. With the statement in place it prints:

PASS: the string was encrypted and decrypted back to itself. The key
policy grants the Lambda role, so KMS let the function use the key.

If it fails

  • AccessDenied on the key. The grant is missing. The role has no kms permissions in its IAM policy, so until the key policy names it as a principal, it cannot touch the key. That is the whole lesson in one error message.
  • Still AccessDenied after you added the statement. Check the principal. It has to be the execution role’s ARN via !GetAtt FunctionRole.Arn, and the actions have to include kms:Encrypt and kms:Decrypt.
  • The key policy update is rejected. A key policy has to keep a statement that lets the account manage the key. Leave the root statement where it is and add your grant beside it.

Reveal the solution

SRC=solution ./scripts/deploy.sh && ./scripts/test.sh

What you just learned

  • For a customer-managed key, the key policy is authoritative. A principal reaches the key only when the key policy allows it, directly or by delegating to IAM. IAM permissions on a role are not sufficient by themselves.
  • The default root statement is what lets IAM policies grant access to the key at all. Keep it and IAM allows on roles start working, because the key now delegates to the account; remove it and you can lock the key so tight that even an administrator cannot get back in.
  • KMS access has three sources: a statement in the key policy, an IAM policy on the principal (which only works because the root statement delegates), and a grant for narrow, temporary, programmatic access. This lab uses the first directly, with no IAM half, so you can see the key policy doing all the work with nothing else in the way.

Next

The rest of the Security Specialty lab track is in the track’s README.

These posts are LLM-aided. Backbone, original writing, and structure by Craig. Research and editing by Craig + LLM. Proof-reading by Craig.