From 1957279af0215b7c9554ae94ee1e8b3ae9504c79 Mon Sep 17 00:00:00 2001 From: Cristian Magherusan-Stanciu Date: Mon, 27 Apr 2026 15:16:29 +0200 Subject: [PATCH 1/5] fix(security): narrow IAM Resource="*" on ECS, ECR, CloudFront, KMS, SES MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Closes 5 HIGH findings (H4–H8) from the security review. Each Resource="*" on the deploy SA / runtime Lambda role now uses the tightest scope AWS IAM allows for the action, falling back to a Project=CUDly tag condition where the API doesn't accept a resource ARN at create time. H4 — ECS (policy_compute.tf): Split the previous all-* statement into three: 1. Resource-scoped: Create/Update/Delete on cluster/cudly-*, service/cudly-*/*, task/cudly-*/*, task-definition/cudly-*:*. 2. Account-wide read: RegisterTaskDefinition (no resource ARN at registration time) + ListTagsForResource (read-only). 3. Task-definition-scoped: Deregister/Describe limited to task-definition/cudly-*:*. The deploy SA can no longer touch unrelated ECS clusters/services in the same account. H5 — SES SendEmail (modules/compute/aws/lambda/main.tf): Added `ses:FromAddress` StringLike condition restricting send actions to *@${var.email_from_domain}. The Resource stays "*" because SES walks the identity hierarchy (see existing comment), but a compromised Lambda can no longer spoof from arbitrary verified identities — only from CUDly's configured domain. Read-only ses:GetAccount/GetEmailIdentity split into a separate statement (no spoofing risk). H6 — ECR (policy_compute.tf): Repository-scoped actions now require resource ARN arn:aws:ecr:*:*:repository/cudly-*. ecr:GetAuthorizationToken split into its own statement because it's an account-wide token endpoint with no resource ARN. H7 — CloudFront (policy_compute.tf): Split into Create+Read (Resource=*) and Mutate (Resource=* + StringEquals aws:ResourceTag/Project=CUDly). The Terraform aws_cloudfront_distribution sets the Project tag at creation time, so CUDly distributions remain manageable while attempts to update or delete unrelated distributions in the same account are denied. H8 — KMS (policy_compute.tf): Same pattern as CloudFront: read/create stay broad (key ARN unknown at create time, terraform plan needs to enumerate), but mutating actions (ScheduleKeyDeletion, DisableKey, PutKeyPolicy, etc.) now require aws:ResourceTag/Project=CUDly. Closes the path where the deploy SA could schedule deletion of unrelated KMS keys. Operator action required: this is a bootstrap module per CLAUDE.md's CI/CD IAM split — `terraform/environments/aws/ci-cd-permissions/` is applied manually by a privileged human, not by the CI workflow itself. After merging, re-apply that directory out of band. Until then, the deploy SA still has the old (broader) grants and nothing breaks. Validation: - terraform fmt clean - terraform validate green - No runtime code paths touched --- .../aws/ci-cd-permissions/policy_compute.tf | 115 +++++++++++++++--- terraform/modules/compute/aws/lambda/main.tf | 30 ++++- 2 files changed, 121 insertions(+), 24 deletions(-) diff --git a/terraform/environments/aws/ci-cd-permissions/policy_compute.tf b/terraform/environments/aws/ci-cd-permissions/policy_compute.tf index 2a7d91db..f5da1e1e 100644 --- a/terraform/environments/aws/ci-cd-permissions/policy_compute.tf +++ b/terraform/environments/aws/ci-cd-permissions/policy_compute.tf @@ -69,7 +69,11 @@ resource "aws_iam_policy" "compute" { Resource = "arn:aws:events:*:*:rule/cudly-*" }, { - Sid = "ECR" + # ECR repository-scoped actions: every action that takes a + # repository ARN is restricted to cudly-* repositories. This + # prevents the deploy SA from poisoning, deleting, or exfiltrating + # images in unrelated ECR repositories sharing the same account. + Sid = "ECRRepositoryScoped" Effect = "Allow" Action = [ "ecr:BatchCheckLayerAvailability", @@ -81,7 +85,6 @@ resource "aws_iam_policy" "compute" { "ecr:DeleteRepository", "ecr:DeleteRepositoryPolicy", "ecr:DescribeRepositories", - "ecr:GetAuthorizationToken", "ecr:GetDownloadUrlForLayer", "ecr:GetLifecyclePolicy", "ecr:GetRepositoryPolicy", @@ -96,21 +99,46 @@ resource "aws_iam_policy" "compute" { "ecr:UntagResource", "ecr:UploadLayerPart", ] + Resource = "arn:aws:ecr:*:*:repository/cudly-*" + }, + { + # ECR token endpoint — does not take a resource ARN and is + # account-wide by design. Required for `docker login` against ECR. + Sid = "ECRGetAuthorizationToken" + Effect = "Allow" + Action = ["ecr:GetAuthorizationToken"] Resource = "*" }, { - Sid = "CloudFrontFrontend" + # CloudFront create + read actions don't take a resource ARN at + # the API level (a distribution's ARN is only known after create). + # Read actions are also broad because terraform plan needs to + # enumerate resources. Mutating actions are gated below. + Sid = "CloudFrontCreateAndRead" Effect = "Allow" Action = [ "cloudfront:CreateDistribution", "cloudfront:CreateFunction", - "cloudfront:DeleteDistribution", - "cloudfront:DeleteFunction", "cloudfront:DescribeFunction", "cloudfront:GetDistribution", "cloudfront:GetDistributionConfig", "cloudfront:GetFunction", "cloudfront:ListTagsForResource", + ] + Resource = "*" + }, + { + # CloudFront mutations are restricted to distributions/functions + # tagged Project=CUDly. The Terraform aws_cloudfront_distribution + # resource sets this tag at creation time (see modules/frontend/aws), + # so subsequent Update/Delete/Tag/Untag operations against + # CUDly-owned distributions succeed while attempts to mutate any + # third-party distribution sharing the account are denied. + Sid = "CloudFrontMutateTaggedOnly" + Effect = "Allow" + Action = [ + "cloudfront:DeleteDistribution", + "cloudfront:DeleteFunction", "cloudfront:PublishFunction", "cloudfront:TagResource", "cloudfront:UntagResource", @@ -118,6 +146,11 @@ resource "aws_iam_policy" "compute" { "cloudfront:UpdateFunction", ] Resource = "*" + Condition = { + StringEquals = { + "aws:ResourceTag/Project" = "CUDly" + } + } }, { Sid = "CloudWatchAlarms" @@ -133,29 +166,56 @@ resource "aws_iam_policy" "compute" { Resource = "arn:aws:cloudwatch:*:*:alarm:cudly-*" }, { - Sid = "ECSFargate" + # ECS resource-scoped actions: cluster, service, and task ARNs + # all match cudly-* prefix. RegisterTaskDefinition cannot take a + # specific ARN at registration time and is split below. + Sid = "ECSFargateResourceScoped" Effect = "Allow" Action = [ "ecs:CreateCluster", "ecs:CreateService", "ecs:DeleteCluster", "ecs:DeleteService", - "ecs:DeregisterTaskDefinition", "ecs:DescribeClusters", "ecs:DescribeServices", - "ecs:DescribeTaskDefinition", - "ecs:ListTagsForResource", "ecs:ListTasks", "ecs:PutClusterCapacityProviders", "ecs:StopTask", - "ecs:RegisterTaskDefinition", "ecs:TagResource", "ecs:UntagResource", "ecs:UpdateCluster", "ecs:UpdateService", ] + Resource = [ + "arn:aws:ecs:*:*:cluster/cudly-*", + "arn:aws:ecs:*:*:service/cudly-*/*", + "arn:aws:ecs:*:*:task/cudly-*/*", + "arn:aws:ecs:*:*:task-definition/cudly-*:*", + ] + }, + { + # ECS task-definition + tag-listing actions don't take a specific + # ARN at API level. RegisterTaskDefinition has no resource; + # DeregisterTaskDefinition + DescribeTaskDefinition take a + # task-definition ARN that we constrain to cudly-*. + # ListTagsForResource takes any ARN type but is read-only. + Sid = "ECSFargateAccountWide" + Effect = "Allow" + Action = [ + "ecs:RegisterTaskDefinition", + "ecs:ListTagsForResource", + ] Resource = "*" }, + { + Sid = "ECSFargateTaskDefinition" + Effect = "Allow" + Action = [ + "ecs:DeregisterTaskDefinition", + "ecs:DescribeTaskDefinition", + ] + Resource = "arn:aws:ecs:*:*:task-definition/cudly-*:*" + }, { Sid = "ELBFargate" Effect = "Allow" @@ -213,24 +273,36 @@ resource "aws_iam_policy" "compute" { Resource = "*" }, { - # KMS asymmetric signing key for the CUDly OIDC issuer. - # Broad KMS management perms scoped to Describe/Tag/Alias - # actions required by terraform-aws-provider on key create - # and update. Resource is "*" because KMS key ARNs are only - # known after creation. - Sid = "KMSSigningKey" + # KMS create + read-only actions don't accept a key ARN at API + # level (key ARNs are only known after CreateKey). Read-only + # actions are also broad because terraform plan needs to + # enumerate key state. + Sid = "KMSCreateAndRead" Effect = "Allow" Action = [ "kms:CreateAlias", "kms:CreateKey", - "kms:DeleteAlias", "kms:DescribeKey", - "kms:DisableKey", - "kms:EnableKey", "kms:GetKeyPolicy", "kms:GetKeyRotationStatus", "kms:ListAliases", "kms:ListResourceTags", + ] + Resource = "*" + }, + { + # 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). + # Without this gate the deploy SA could schedule deletion or + # disable any KMS key in the account, causing denial of service + # for unrelated workloads. + Sid = "KMSMutateTaggedOnly" + Effect = "Allow" + Action = [ + "kms:DeleteAlias", + "kms:DisableKey", + "kms:EnableKey", "kms:PutKeyPolicy", "kms:ScheduleKeyDeletion", "kms:TagResource", @@ -239,6 +311,11 @@ resource "aws_iam_policy" "compute" { "kms:UpdateKeyDescription", ] Resource = "*" + Condition = { + StringEquals = { + "aws:ResourceTag/Project" = "CUDly" + } + } }, ] }) diff --git a/terraform/modules/compute/aws/lambda/main.tf b/terraform/modules/compute/aws/lambda/main.tf index bb1831eb..64cc4cb4 100644 --- a/terraform/modules/compute/aws/lambda/main.tf +++ b/terraform/modules/compute/aws/lambda/main.tf @@ -230,10 +230,16 @@ resource "aws_iam_role_policy" "secrets_access" { # where SES walks up the identity hierarchy and evaluates IAM against the # verified parent's ARN (identity/leanercloud.com). Enumerating every # ancestor the operator might verify would work on paper but drifts every -# time the SES identity tree is reorganised; `*` defers the real check to -# SES itself (which still rejects sends from unverified identities at the -# service layer). Configuration-set access stays scoped to ${stack_name}* -# so a compromised Lambda can't touch unrelated stacks' config sets. +# time the SES identity tree is reorganised. +# +# To still narrow the blast radius (a compromised Lambda must not be able +# to spoof emails from arbitrary verified identities in the same account), +# the SendEmail/SendRawEmail actions are gated by a `ses:FromAddress` +# condition restricting the From header to *@${var.email_from_domain}. +# SES enforces FromAddress on the wire, so this prevents phishing from +# unrelated identities even though the resource ARN remains broad. +# Configuration-set access stays scoped to ${stack_name}* so a compromised +# Lambda can't touch unrelated stacks' config sets either. # # Only attached when var.email_from_domain is set — deployments without email # notifications don't get any SES permissions at all. @@ -247,11 +253,25 @@ resource "aws_iam_role_policy" "ses_access" { Version = "2012-10-17" Statement = [ { - Sid = "SendFromAnyVerifiedIdentity" + Sid = "SendFromCUDlyDomain" Effect = "Allow" Action = [ "ses:SendEmail", "ses:SendRawEmail", + ] + Resource = "*" + Condition = { + StringLike = { + "ses:FromAddress" = "*@${var.email_from_domain}" + } + } + }, + { + # Read-only SES status checks for healthchecks and quota + # introspection. No spoofing risk — these don't send mail. + Sid = "SESReadOnly" + Effect = "Allow" + Action = [ "ses:GetAccount", "ses:GetEmailIdentity", ] From eccfdf7cac1a9d23bc83345a82f2820b72f8ccc8 Mon Sep 17 00:00:00 2001 From: Cristian Magherusan-Stanciu Date: Tue, 28 Apr 2026 19:10:32 +0200 Subject: [PATCH 2/5] fix(security): correct three IAM scope bugs from CR review (PR #103) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit CodeRabbit flagged three statements in policy_compute.tf where the intended scoping is silently broken because the AWS service does not support the condition key or resource type used. In all three cases the policy LOOKED narrow but actually granted account-wide access for the affected actions: 1. CloudFront functions (DeleteFunction/PublishFunction/ UpdateFunction) operate on the `function` resource type, which does not support the aws:ResourceTag condition key per the AWS Service Authorization Reference. They were grouped with distribution mutations under CloudFrontMutateTaggedOnly with a `aws:ResourceTag/Project=CUDly` condition that silently evaluates to no-match for functions, so the deploy SA could mutate any CloudFront function in the account. 2. ecs:ListTasks does not support resource-level permissions per the AWS Service Authorization Reference (no resource types are listed for the action). It was bundled with cluster/service/task ARNs under ECSFargateResourceScoped where AWS silently ignores the Resource list, so the deploy SA could list tasks in any cluster in the account. 3. KMS alias actions (CreateAlias/DeleteAlias/UpdateAlias) act on the `alias` resource type, but the aws:ResourceTag/Project gate applies only to keys (aliases inherit no IAM-visible tags). CreateAlias was in the unrestricted KMSCreateAndRead bucket and DeleteAlias/UpdateAlias in the key-tag-gated bucket, so the deploy SA could create or rename aliases on any KMS key in the account. Each fix replaces the silently-no-op tag/ARN scoping with explicit ARN-prefix scoping that AWS will actually enforce. Net effect tightens permissions; no new grants are introduced. Structural note — split into a second managed policy: The existing `aws_iam_policy.compute` is at AWS's 6144-char default managed-policy limit. The three new ARN-scoped statements wouldn't fit without evicting unrelated existing grants, so they live in a new `aws_iam_policy.compute_b` (file `policy_compute_b.tf`) attached to the same role. Three statements moved out, three statements added in. Verification: - terraform validate, terraform fmt, tflint all pass. - tflint's aws_iam_policy_too_long_policy rule confirms the main policy is back under 6144 chars after the alias/ListTasks deletions. - Each new statement uses an AWS-supported condition key or resource type per the Service Authorization Reference (verified against docs cited in CR's review). --- .../aws/ci-cd-permissions/policy_compute.tf | 23 ++++--- .../aws/ci-cd-permissions/policy_compute_b.tf | 69 +++++++++++++++++++ .../aws/ci-cd-permissions/role.tf | 5 ++ 3 files changed, 89 insertions(+), 8 deletions(-) create mode 100644 terraform/environments/aws/ci-cd-permissions/policy_compute_b.tf diff --git a/terraform/environments/aws/ci-cd-permissions/policy_compute.tf b/terraform/environments/aws/ci-cd-permissions/policy_compute.tf index f5da1e1e..4340efcd 100644 --- a/terraform/environments/aws/ci-cd-permissions/policy_compute.tf +++ b/terraform/environments/aws/ci-cd-permissions/policy_compute.tf @@ -128,22 +128,22 @@ resource "aws_iam_policy" "compute" { Resource = "*" }, { - # CloudFront mutations are restricted to distributions/functions + # CloudFront distribution mutations are restricted to distributions # tagged Project=CUDly. The Terraform aws_cloudfront_distribution # resource sets this tag at creation time (see modules/frontend/aws), # so subsequent Update/Delete/Tag/Untag operations against # CUDly-owned distributions succeed while attempts to mutate any # third-party distribution sharing the account are denied. + # Function mutations live in policy_compute_b.tf — the function + # resource type does not support aws:ResourceTag per the AWS + # Service Authorization Reference, so it needs ARN scoping. Sid = "CloudFrontMutateTaggedOnly" Effect = "Allow" Action = [ "cloudfront:DeleteDistribution", - "cloudfront:DeleteFunction", - "cloudfront:PublishFunction", "cloudfront:TagResource", "cloudfront:UntagResource", "cloudfront:UpdateDistribution", - "cloudfront:UpdateFunction", ] Resource = "*" Condition = { @@ -169,6 +169,9 @@ resource "aws_iam_policy" "compute" { # ECS resource-scoped actions: cluster, service, and task ARNs # all match cudly-* prefix. RegisterTaskDefinition cannot take a # specific ARN at registration time and is split below. + # ecs:ListTasks does NOT support resource-level permissions per + # the AWS Service Authorization Reference; it lives in + # policy_compute_b.tf gated by an ecs:cluster condition. Sid = "ECSFargateResourceScoped" Effect = "Allow" Action = [ @@ -178,7 +181,6 @@ resource "aws_iam_policy" "compute" { "ecs:DeleteService", "ecs:DescribeClusters", "ecs:DescribeServices", - "ecs:ListTasks", "ecs:PutClusterCapacityProviders", "ecs:StopTask", "ecs:TagResource", @@ -277,10 +279,14 @@ resource "aws_iam_policy" "compute" { # level (key ARNs are only known after CreateKey). Read-only # actions are also broad because terraform plan needs to # enumerate key state. + # kms:CreateAlias is NOT here — alias actions act on alias + # resources, and the aws:ResourceTag/Project gate below applies + # only to keys (aliases inherit no IAM-visible tags). Alias + # operations live in policy_compute_b.tf scoped by alias-ARN + # prefix. Sid = "KMSCreateAndRead" Effect = "Allow" Action = [ - "kms:CreateAlias", "kms:CreateKey", "kms:DescribeKey", "kms:GetKeyPolicy", @@ -297,17 +303,18 @@ resource "aws_iam_policy" "compute" { # Without this gate the deploy SA could schedule deletion or # disable any KMS key in the account, causing denial of service # for unrelated workloads. + # Alias mutations (DeleteAlias/UpdateAlias) live in + # policy_compute_b.tf — the aws:ResourceTag condition does not + # match alias resources. Sid = "KMSMutateTaggedOnly" Effect = "Allow" Action = [ - "kms:DeleteAlias", "kms:DisableKey", "kms:EnableKey", "kms:PutKeyPolicy", "kms:ScheduleKeyDeletion", "kms:TagResource", "kms:UntagResource", - "kms:UpdateAlias", "kms:UpdateKeyDescription", ] Resource = "*" diff --git a/terraform/environments/aws/ci-cd-permissions/policy_compute_b.tf b/terraform/environments/aws/ci-cd-permissions/policy_compute_b.tf new file mode 100644 index 00000000..54c18bd9 --- /dev/null +++ b/terraform/environments/aws/ci-cd-permissions/policy_compute_b.tf @@ -0,0 +1,69 @@ +# Second compute-permissions policy. This file exists because the main +# `aws_iam_policy.compute` is at AWS's 6144-char managed-policy limit; +# additional ARN-scoped statements (added in response to a CodeRabbit +# review of fix/iam-resource-wildcards) wouldn't fit there without +# evicting unrelated existing grants. Each statement here corrects a +# scoping bug that the AWS service authorization reference forced us to +# split out of `policy_compute.tf`: +# +# - CloudFrontFnMutate: cloudfront:DeleteFunction/PublishFunction/ +# UpdateFunction operate on the `function` resource type which does +# not support aws:ResourceTag, so tag-gating them is a no-op. ARN +# scope used instead. +# +# - ECSListTasks: ecs:ListTasks does not support resource-level +# permissions, so Resource must be "*". The ecs:cluster condition +# key restricts it to CUDly clusters. +# +# - KMSAliasMutate: kms:CreateAlias/DeleteAlias/UpdateAlias act on +# alias resources, but aws:ResourceTag/Project gates only key +# resources (aliases inherit nothing). ARN scope used instead. + +resource "aws_iam_policy" "compute_b" { + name = "cudly-deploy-compute-b" + description = "CUDly Terraform deploy: ARN-scoped statements split out of compute policy due to size" + + policy = jsonencode({ + Version = "2012-10-17" + Statement = [ + { + Sid = "CloudFrontFnMutate" + Effect = "Allow" + Action = [ + "cloudfront:DeleteFunction", + "cloudfront:PublishFunction", + "cloudfront:UpdateFunction", + ] + Resource = "arn:aws:cloudfront::*:function/cudly-*" + }, + { + Sid = "ECSListTasks" + Effect = "Allow" + Action = [ + "ecs:ListTasks", + ] + Resource = "*" + Condition = { + ArnLike = { + "ecs:cluster" = "arn:aws:ecs:*:*:cluster/cudly-*" + } + } + }, + { + Sid = "KMSAliasMutate" + Effect = "Allow" + Action = [ + "kms:CreateAlias", + "kms:DeleteAlias", + "kms:UpdateAlias", + ] + Resource = "arn:aws:kms:*:*:alias/cudly-*" + }, + ] + }) + + tags = { + Project = "CUDly" + ManagedBy = "terraform" + } +} diff --git a/terraform/environments/aws/ci-cd-permissions/role.tf b/terraform/environments/aws/ci-cd-permissions/role.tf index 1b5be028..0c6d2014 100644 --- a/terraform/environments/aws/ci-cd-permissions/role.tf +++ b/terraform/environments/aws/ci-cd-permissions/role.tf @@ -47,6 +47,11 @@ resource "aws_iam_role_policy_attachment" "compute" { policy_arn = aws_iam_policy.compute.arn } +resource "aws_iam_role_policy_attachment" "compute_b" { + role = aws_iam_role.cudly_deploy.name + policy_arn = aws_iam_policy.compute_b.arn +} + resource "aws_iam_role_policy_attachment" "data" { role = aws_iam_role.cudly_deploy.name policy_arn = aws_iam_policy.data.arn From 57fefbcfa80d984fbe82f5ac0a4b744db7151cfa Mon Sep 17 00:00:00 2001 From: Cristian Magherusan-Stanciu Date: Tue, 28 Apr 2026 19:21:19 +0200 Subject: [PATCH 3/5] fix(security): grant KMSMutateTaggedOnly the alias actions for key check (CR r2) CodeRabbit flagged that KMSAliasMutate in policy_compute_b.tf only allows alias ARNs, which is insufficient: AWS KMS evaluates kms:CreateAlias / kms:DeleteAlias / kms:UpdateAlias against BOTH the alias resource AND the target key resource. Without permissions on the target key, every CreateAlias/UpdateAlias call (and DeleteAlias) would 403 at deploy time. The CR-proposed fix added "arn:aws:kms:*:*:key/*" to the alias statement's Resource list with no condition, which would allow alias mutations against ANY key in the account. Tighter alternative: add the three alias actions to the existing KMSMutateTaggedOnly statement in policy_compute.tf, which is already tag-gated to keys carrying Project=CUDly. The deploy SA can now operate aliases targeting the CUDly OIDC signing key (and any future CUDly keys) but not other keys in the same account. The dual-check pattern is now: - Alias side: policy_compute_b.tf KMSAliasMutate, ARN-scoped to arn:aws:kms:*:*:alias/cudly-*. - Key side: policy_compute.tf KMSMutateTaggedOnly, Resource="*" with aws:ResourceTag/Project=CUDly. Both must allow for the API call to succeed, which is exactly the authorization model AWS uses for these actions per the AWS KMS authorization docs. --- .../aws/ci-cd-permissions/policy_compute.tf | 21 ++++++++++++------- .../aws/ci-cd-permissions/policy_compute_b.tf | 5 ++++- 2 files changed, 17 insertions(+), 9 deletions(-) diff --git a/terraform/environments/aws/ci-cd-permissions/policy_compute.tf b/terraform/environments/aws/ci-cd-permissions/policy_compute.tf index 4340efcd..c99b4c69 100644 --- a/terraform/environments/aws/ci-cd-permissions/policy_compute.tf +++ b/terraform/environments/aws/ci-cd-permissions/policy_compute.tf @@ -279,11 +279,10 @@ resource "aws_iam_policy" "compute" { # level (key ARNs are only known after CreateKey). Read-only # actions are also broad because terraform plan needs to # enumerate key state. - # kms:CreateAlias is NOT here — alias actions act on alias - # resources, and the aws:ResourceTag/Project gate below applies - # only to keys (aliases inherit no IAM-visible tags). Alias - # operations live in policy_compute_b.tf scoped by alias-ARN - # prefix. + # kms:CreateAlias is NOT here — it moved to KMSMutateTaggedOnly + # below (key-side check, tag-gated) and policy_compute_b.tf + # KMSAliasMutate (alias-side check, ARN-scoped). KMS evaluates + # alias actions against both the alias and the target key. Sid = "KMSCreateAndRead" Effect = "Allow" Action = [ @@ -303,18 +302,24 @@ resource "aws_iam_policy" "compute" { # Without this gate the deploy SA could schedule deletion or # disable any KMS key in the account, causing denial of service # for unrelated workloads. - # Alias mutations (DeleteAlias/UpdateAlias) live in - # policy_compute_b.tf — the aws:ResourceTag condition does not - # match alias resources. + # Alias mutations (CreateAlias/DeleteAlias/UpdateAlias) appear + # here because AWS KMS evaluates these actions against BOTH the + # alias and the target key resource — this statement covers the + # 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). Sid = "KMSMutateTaggedOnly" Effect = "Allow" Action = [ + "kms:CreateAlias", + "kms:DeleteAlias", "kms:DisableKey", "kms:EnableKey", "kms:PutKeyPolicy", "kms:ScheduleKeyDeletion", "kms:TagResource", "kms:UntagResource", + "kms:UpdateAlias", "kms:UpdateKeyDescription", ] Resource = "*" 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 54c18bd9..f2a4e50d 100644 --- a/terraform/environments/aws/ci-cd-permissions/policy_compute_b.tf +++ b/terraform/environments/aws/ci-cd-permissions/policy_compute_b.tf @@ -17,7 +17,10 @@ # # - KMSAliasMutate: kms:CreateAlias/DeleteAlias/UpdateAlias act on # alias resources, but aws:ResourceTag/Project gates only key -# resources (aliases inherit nothing). ARN scope used instead. +# resources (aliases inherit nothing). KMS evaluates these actions +# against BOTH the alias and the target key, so the alias-side +# check lives here (ARN-scoped) and the key-side check lives in +# policy_compute.tf KMSMutateTaggedOnly (tag-gated to CUDly keys). resource "aws_iam_policy" "compute_b" { name = "cudly-deploy-compute-b" From 18138b0ebc8ad67ecba3f39a8be966fa0d5f701e Mon Sep 17 00:00:00 2001 From: Cristian Magherusan-Stanciu Date: Tue, 28 Apr 2026 23:07:34 +0200 Subject: [PATCH 4/5] fix(security): drop ses:SendEmail from CI deploy SA (CR r3 nitpick) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit CR pass-3 nitpick on policy_compute.tf:271. The deploy SA's SES statement listed the identity-management actions (Create/Delete/Get EmailIdentity, PutDkimSigningAttributes, Tag/Untag) plus SendEmail — the latter on Resource = "*" with no FromAddress condition. Splitting actions is the principled fix: CI deploy configures email identities (verify domain, rotate DKIM, tag); it doesn't send mail. The runtime Lambda role in terraform/modules/compute/aws/lambda/main.tf keeps its own ses:SendEmail grant (already restricted) for the actual sending path. Dropping the CI grant means a leaked deploy SA can no longer exfiltrate via mail. A comment on the SES statement explains the omission so a future reader doesn't add SendEmail back as a "missing action". Closes the last open CR finding on PR #103. --- .../environments/aws/ci-cd-permissions/policy_compute.tf | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/terraform/environments/aws/ci-cd-permissions/policy_compute.tf b/terraform/environments/aws/ci-cd-permissions/policy_compute.tf index c99b4c69..d7ab8d72 100644 --- a/terraform/environments/aws/ci-cd-permissions/policy_compute.tf +++ b/terraform/environments/aws/ci-cd-permissions/policy_compute.tf @@ -261,6 +261,12 @@ resource "aws_iam_policy" "compute" { Resource = "*" }, { + # CI deploy SA configures email identities (verify domain, + # rotate DKIM, tag) but does NOT send mail — that path lives + # in the Lambda runtime role (modules/compute/aws/lambda). + # ses:SendEmail intentionally excluded here per CR pass-3 + # nitpick — adding it would let a leaked deploy SA exfiltrate + # via mail without unlocking any deploy capability. Sid = "SES" Effect = "Allow" Action = [ @@ -268,7 +274,6 @@ resource "aws_iam_policy" "compute" { "ses:DeleteEmailIdentity", "ses:GetEmailIdentity", "ses:PutEmailIdentityDkimSigningAttributes", - "ses:SendEmail", "ses:TagResource", "ses:UntagResource", ] From 2a673391b4cff0c3bf9d31d36a4b08d5ad548198 Mon Sep 17 00:00:00 2001 From: Cristian Magherusan-Stanciu Date: Wed, 29 Apr 2026 14:43:42 +0200 Subject: [PATCH 5/5] fix(security): scope CloudFront function reads to cudly-* (CR r4) DescribeFunction and GetFunction support the function resource type per the AWS Service Authorization Reference. Move them out of the wildcard CloudFrontCreateAndRead statement into a new CloudFrontFnRead statement scoped to arn:aws:cloudfront::*:function/cudly-*, matching the ARN scoping already used by CloudFrontFnMutate in policy_compute_b.tf. CreateFunction has no resource type so stays under Resource="*". --- .../aws/ci-cd-permissions/policy_compute.tf | 27 ++++++++++++++----- 1 file changed, 21 insertions(+), 6 deletions(-) diff --git a/terraform/environments/aws/ci-cd-permissions/policy_compute.tf b/terraform/environments/aws/ci-cd-permissions/policy_compute.tf index d7ab8d72..6a194bd0 100644 --- a/terraform/environments/aws/ci-cd-permissions/policy_compute.tf +++ b/terraform/environments/aws/ci-cd-permissions/policy_compute.tf @@ -110,23 +110,38 @@ resource "aws_iam_policy" "compute" { Resource = "*" }, { - # CloudFront create + read actions don't take a resource ARN at - # the API level (a distribution's ARN is only known after create). - # Read actions are also broad because terraform plan needs to - # enumerate resources. Mutating actions are gated below. + # CloudFront create + distribution-read actions don't take a + # resource ARN at the API level (a distribution's ARN is only + # known after create, and CreateFunction has no resource type + # per the AWS Service Authorization Reference). Read actions + # against distributions are also broad because terraform plan + # needs to enumerate resources. Mutating actions are gated + # below; function reads are scoped in CloudFrontFnRead. Sid = "CloudFrontCreateAndRead" Effect = "Allow" Action = [ "cloudfront:CreateDistribution", "cloudfront:CreateFunction", - "cloudfront:DescribeFunction", "cloudfront:GetDistribution", "cloudfront:GetDistributionConfig", - "cloudfront:GetFunction", "cloudfront:ListTagsForResource", ] Resource = "*" }, + { + # CloudFront DescribeFunction/GetFunction support the + # function resource type per the AWS Service Authorization + # Reference, so scope them to CUDly-owned functions to match + # the ARN scoping used by CloudFrontFnMutate in + # policy_compute_b.tf. + Sid = "CloudFrontFnRead" + Effect = "Allow" + Action = [ + "cloudfront:DescribeFunction", + "cloudfront:GetFunction", + ] + Resource = "arn:aws:cloudfront::*:function/cudly-*" + }, { # CloudFront distribution mutations are restricted to distributions # tagged Project=CUDly. The Terraform aws_cloudfront_distribution