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
AccessDeniedExceptionon 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 includekms:Encryptandkms: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.shagain. - The key policy update is rejected on the lockout check.
PutKeyPolicyrequires the policy you submit to allow the calling principal a subsequentPutKeyPolicyon the key, unless you setBypassPolicyLockoutSafetyCheckto 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
ExcludeManagementEventSourcesset tokms.amazonaws.comrecords none of it.
Next
The rest of the Security Specialty lab track is in the track’s README.