Exam Room · Advanced Security Specialist

Lab: Grant KMS Access With a Key Policy

· 12 min read

Cloud Security · part of The Exam Room

This is the first lab in the Security Specialty hands-on track. The reference posts argue the decisions; this stands one up. For a customer managed KMS key, IAM permissions on a role are not enough on their own. The key policy controls access to the key, and an IAM allow on a role is ineffective unless that policy has delegated to IAM. 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. An AWS managed key is not the easier case to retreat to. You cannot call Encrypt directly against one at all. The integrated service that created it uses it on your behalf, and its key policy is not yours to change.

No principal holds any permission on a KMS key unless a policy allows it explicitly. A role reaches a customer managed key only when the key policy names the role directly or delegates to IAM through the account statement, so a role can hold every KMS action there is in its IAM policy and still get AccessDeniedException. Denies are not symmetrical with allows: an IAM policy that denies access to the key takes effect whether or not the key policy delegates anything.

This lab strips the IAM half away entirely. The Lambda’s execution role has no kms permissions at all, only basic logging, so every call the handler makes depends on the key policy and nothing else.

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 nothing more. 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 one naming the account principal:

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: '*'

The principal arn:aws:iam::ACCOUNT:root is the account principal, not the root user, and a statement naming it gives no IAM principal any permission directly. What it does is let the account use IAM policies to delegate the permissions the statement lists, here kms:*. The Lambda role has no IAM kms permissions to be delegated, so it gets nothing from this statement.

Your task

Add a second statement to the key policy that names the Lambda role as a principal and allows three cryptographic actions. The handler calls two of them, Encrypt and Decrypt. GenerateDataKey goes in because any client doing envelope encryption against this key needs it, and the console’s own key users statement lists it alongside the other two:

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

Keep the account statement; you are adding alongside it, not replacing it. The !GetAtt FunctionRole.Arn reference makes CloudFormation build the role before the key, so the ARN resolves. KMS also requires every principal in a key policy to exist and be visible to KMS, and a role created seconds earlier may not be yet, so a first deploy can fail on the policy and succeed on a re-run. In a key policy Resource: '*' means this KMS key and nothing wider, because a key policy only governs the key it is attached to.

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 AccessDeniedException on the very first encrypt: the key exists, the role exists, and no policy anywhere allows the one to use the other. 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

  • AccessDeniedException on the key. The key-policy statement is missing. The role holds no kms permissions in its IAM policy, so until the key policy names it as a principal it has no access to the key at all.
  • Still denied 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 stack fails on the key policy itself. KMS requires every principal in a key policy to exist and be visible to KMS, and a role CloudFormation created seconds earlier may not be yet. Run deploy.sh again.
  • The key policy update is rejected on the lockout check. PutKeyPolicy requires the policy you submit to allow the calling principal a subsequent PutKeyPolicy on the key, unless you set BypassPolicyLockoutSafetyCheck to true. Leave the account statement in place and add yours 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 the primary mechanism for access. A role reaches the key only when the key policy names it or delegates to IAM, and IAM permissions on the role are not sufficient by themselves. An IAM deny applies either way.
  • The account statement gives no principal direct permission on the key. It lets the account use IAM policies to delegate the permissions that statement lists. Drop it and IAM allows on the key stop working while IAM denies keep working. Delete the last principal that can edit the policy and the key is unmanageable, with AWS Support the only way back.
  • Access can come from a statement in the key policy, from an IAM policy the key policy delegates to, or from a grant, which adds permission without editing the key policy. This lab uses the first with no IAM half, so nothing else can account for the result.
  • The key policy is the preventive half of this control. CloudTrail is the detection half. It records every KMS call as a management event, cryptographic operations included, and a call denied on the key is one of the failed calls it records. A trail with ExcludeManagementEventSources set to kms.amazonaws.com records none of it.

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.