From 58fcc51f67cb904b4a4d9d461ae49c09c680793a Mon Sep 17 00:00:00 2001 From: Cristian Magherusan-Stanciu Date: Thu, 23 Jul 2026 00:56:27 +0200 Subject: [PATCH 1/2] fix(iac/aws): grant deploy role KMS key-lifecycle for OIDC signing-key replace The cudly-terraform-deploy GitHub Actions role holds KMS data-plane and alias actions but no key-lifecycle actions, so the OIDC issuer signing key was bootstrap-created. PR #1480 migrated aws_kms_key.signing from RSA_2048 to ECC_NIST_P256 (ES256); KMS cannot change a key spec in place, so Terraform must replace the key. The deploy then failed on: AccessDenied: not authorized to perform kms:ScheduleKeyDeletion on arn:aws:kms:us-east-1:...:key/a8e28eac-... Add a tag-gated key-lifecycle grant so the replace can complete: - ScheduleKeyDeletion + management reads/mutates gated to Project=CUDly keys (deploy SA can never schedule deletion of an unrelated account CMK). - TagResource/UntagResource gated on aws:RequestTag/Project=CUDly (a new key is untagged at create, so the request tag is used). - CreateKey on "*" (cannot be resource/tag-scoped; benign - creating a CMK grants no data access; deletion stays tag-gated). Requires the ci-cd-permissions bootstrap apply to take effect before the runtime deploy can complete. --- .../aws/ci-cd-permissions/policy_compute_b.tf | 59 +++++++++++++++++++ 1 file changed, 59 insertions(+) diff --git a/terraform/environments/aws/ci-cd-permissions/policy_compute_b.tf b/terraform/environments/aws/ci-cd-permissions/policy_compute_b.tf index 3e02a12b2..4d35a2f64 100644 --- a/terraform/environments/aws/ci-cd-permissions/policy_compute_b.tf +++ b/terraform/environments/aws/ci-cd-permissions/policy_compute_b.tf @@ -85,6 +85,65 @@ resource "aws_iam_policy" "compute_b" { } } }, + { + # Key-lifecycle mutate + read actions on CUDly-tagged CMKs. Needed so + # the deploy can manage the OIDC issuer signing key (aws_kms_key.signing): + # PR #1480 migrated its spec RSA_2048 -> ECC_NIST_P256 (ES256), and KMS + # cannot change a key spec in place, so Terraform must replace the key + # (ScheduleKeyDeletion on the old CMK + CreateKey/TagResource on the new). + # Destructive actions (ScheduleKeyDeletion) are gated to Project=CUDly + # keys so the deploy SA can never schedule deletion of an unrelated + # workload's CMK sharing the account. GetKeyRotationStatus is read by the + # provider for aws_kms_key.enable_key_rotation. + Sid = "KMSKeyLifecycleTaggedOnly" + Effect = "Allow" + Action = [ + "kms:ScheduleKeyDeletion", + "kms:CancelKeyDeletion", + "kms:PutKeyPolicy", + "kms:EnableKeyRotation", + "kms:DisableKeyRotation", + "kms:GetKeyRotationStatus", + "kms:ListResourceTags", + ] + Resource = "*" + Condition = { + StringEquals = { + "aws:ResourceTag/Project" = "CUDly" + } + } + }, + { + # TagResource/UntagResource are gated on the REQUEST tag (aws:RequestTag) + # rather than the resource tag, because a freshly created CMK is not yet + # tagged Project=CUDly when Terraform applies its tags. This constrains + # the deploy SA to only ever tag keys AS Project=CUDly, so a new key + # immediately falls under the ResourceTag-gated statements above. + Sid = "KMSTagResourceRequestGated" + Effect = "Allow" + Action = [ + "kms:TagResource", + "kms:UntagResource", + ] + Resource = "*" + Condition = { + StringEquals = { + "aws:RequestTag/Project" = "CUDly" + } + } + }, + { + # kms:CreateKey cannot be scoped to a resource ARN (the key does not yet + # exist) and AWS does not support conditioning it on tags reliably across + # providers, so it is granted on "*". This is low-risk: creating a CMK + # grants no access to any data and does not expose existing keys; the + # dangerous action (ScheduleKeyDeletion) remains tag-gated above, and the + # new key is immediately tagged Project=CUDly via KMSTagResourceRequestGated. + Sid = "KMSCreateKey" + Effect = "Allow" + Action = ["kms:CreateKey"] + Resource = "*" + }, ] }) From c96e856e028b879914806cd848e4ee21061a8c6e Mon Sep 17 00:00:00 2001 From: Cristian Magherusan-Stanciu Date: Thu, 23 Jul 2026 16:08:12 +0200 Subject: [PATCH 2/2] fix(iac/aws): match KMS tag condition on Project casing, drop no-op grants The prior fix added kms:ScheduleKeyDeletion and other lifecycle actions gated on aws:ResourceTag/Project=CUDly in policy_compute_b.tf, but those actions were already granted under an identical condition by KMSMutateTaggedOnly in policy_compute.tf (IAM unions statements, so the new grants were no-ops). The real blocker is a tag-value case mismatch: the signing key's Project tag comes from var.project_name, which defaults to lowercase "cudly", overriding the provider's default_tags value of "CUDly" for that resource. StringEquals is case-sensitive, so the existing tag-gated grant never matched the signing key. Switch KMSMutateTaggedOnly's condition to StringEqualsIgnoreCase so it matches regardless of tag casing, add the one genuinely new action (kms:CancelKeyDeletion) to that statement, and remove the duplicate KMSKeyLifecycleTaggedOnly/KMSTagResourceRequestGated/KMSCreateKey statements from policy_compute_b.tf (their actions were already granted elsewhere; the RequestTag-gated UntagResource was additionally a dead grant since UntagResource evaluates aws:TagKeys, not aws:RequestTag). --- .../aws/ci-cd-permissions/policy_compute.tf | 23 +++++++- .../aws/ci-cd-permissions/policy_compute_b.tf | 59 ------------------- 2 files changed, 21 insertions(+), 61 deletions(-) diff --git a/terraform/environments/aws/ci-cd-permissions/policy_compute.tf b/terraform/environments/aws/ci-cd-permissions/policy_compute.tf index 2aa7c22c4..e7d2b793d 100644 --- a/terraform/environments/aws/ci-cd-permissions/policy_compute.tf +++ b/terraform/environments/aws/ci-cd-permissions/policy_compute.tf @@ -320,7 +320,8 @@ resource "aws_iam_policy" "compute" { { # KMS mutating + destructive actions are gated on the key being # tagged Project=CUDly. The CUDly OIDC signing key is tagged at - # creation by Terraform (see modules/security/aws/kms_signing.tf). + # creation by Terraform (see modules/compute/aws/lambda/signing-key.tf, + # tags = merge(var.tags, ...)). # Without this gate the deploy SA could schedule deletion or # disable any KMS key in the account, causing denial of service # for unrelated workloads. @@ -330,9 +331,27 @@ resource "aws_iam_policy" "compute" { # key-side check (tag-gated). The alias-side check lives in # policy_compute_b.tf KMSAliasMutate (ARN-scoped because aliases # have no IAM-visible tags). + # kms:CancelKeyDeletion lets the deploy SA reverse an + # in-progress ScheduleKeyDeletion on a CUDly-tagged key (e.g. a + # replace that gets interrupted before the new key is fully + # provisioned) without ever unlocking deletion of an unrelated + # account CMK. + # + # The condition uses StringEqualsIgnoreCase rather than + # StringEquals: the signing key's Project tag comes from + # local.common_tags (terraform/environments/aws/main.tf), which + # sets Project = var.project_name, and var.project_name defaults + # to the lowercase "cudly" (terraform/environments/aws/variables.tf). + # That resource-level tag overrides the provider's default_tags + # value of "CUDly" (uppercase) for the same key, so a + # case-sensitive StringEquals match against "CUDly" never + # matches the signing key and every action below was silently + # denied (AccessDenied: kms:ScheduleKeyDeletion) even though the + # key IS a CUDly-owned resource. See PR #1496. Sid = "KMSMutateTaggedOnly" Effect = "Allow" Action = [ + "kms:CancelKeyDeletion", "kms:CreateAlias", "kms:DeleteAlias", "kms:DisableKey", @@ -346,7 +365,7 @@ resource "aws_iam_policy" "compute" { ] Resource = "*" Condition = { - StringEquals = { + StringEqualsIgnoreCase = { "aws:ResourceTag/Project" = "CUDly" } } diff --git a/terraform/environments/aws/ci-cd-permissions/policy_compute_b.tf b/terraform/environments/aws/ci-cd-permissions/policy_compute_b.tf index 4d35a2f64..3e02a12b2 100644 --- a/terraform/environments/aws/ci-cd-permissions/policy_compute_b.tf +++ b/terraform/environments/aws/ci-cd-permissions/policy_compute_b.tf @@ -85,65 +85,6 @@ resource "aws_iam_policy" "compute_b" { } } }, - { - # Key-lifecycle mutate + read actions on CUDly-tagged CMKs. Needed so - # the deploy can manage the OIDC issuer signing key (aws_kms_key.signing): - # PR #1480 migrated its spec RSA_2048 -> ECC_NIST_P256 (ES256), and KMS - # cannot change a key spec in place, so Terraform must replace the key - # (ScheduleKeyDeletion on the old CMK + CreateKey/TagResource on the new). - # Destructive actions (ScheduleKeyDeletion) are gated to Project=CUDly - # keys so the deploy SA can never schedule deletion of an unrelated - # workload's CMK sharing the account. GetKeyRotationStatus is read by the - # provider for aws_kms_key.enable_key_rotation. - Sid = "KMSKeyLifecycleTaggedOnly" - Effect = "Allow" - Action = [ - "kms:ScheduleKeyDeletion", - "kms:CancelKeyDeletion", - "kms:PutKeyPolicy", - "kms:EnableKeyRotation", - "kms:DisableKeyRotation", - "kms:GetKeyRotationStatus", - "kms:ListResourceTags", - ] - Resource = "*" - Condition = { - StringEquals = { - "aws:ResourceTag/Project" = "CUDly" - } - } - }, - { - # TagResource/UntagResource are gated on the REQUEST tag (aws:RequestTag) - # rather than the resource tag, because a freshly created CMK is not yet - # tagged Project=CUDly when Terraform applies its tags. This constrains - # the deploy SA to only ever tag keys AS Project=CUDly, so a new key - # immediately falls under the ResourceTag-gated statements above. - Sid = "KMSTagResourceRequestGated" - Effect = "Allow" - Action = [ - "kms:TagResource", - "kms:UntagResource", - ] - Resource = "*" - Condition = { - StringEquals = { - "aws:RequestTag/Project" = "CUDly" - } - } - }, - { - # kms:CreateKey cannot be scoped to a resource ARN (the key does not yet - # exist) and AWS does not support conditioning it on tags reliably across - # providers, so it is granted on "*". This is low-risk: creating a CMK - # grants no access to any data and does not expose existing keys; the - # dangerous action (ScheduleKeyDeletion) remains tag-gated above, and the - # new key is immediately tagged Project=CUDly via KMSTagResourceRequestGated. - Sid = "KMSCreateKey" - Effect = "Allow" - Action = ["kms:CreateKey"] - Resource = "*" - }, ] })