diff --git a/terraform/environments/aws/ci-cd-permissions/policy_compute.tf b/terraform/environments/aws/ci-cd-permissions/policy_compute.tf index 2a7d91db..6a194bd0 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,28 +99,73 @@ 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 + 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:DeleteDistribution", - "cloudfront:DeleteFunction", - "cloudfront:DescribeFunction", "cloudfront:GetDistribution", "cloudfront:GetDistributionConfig", - "cloudfront:GetFunction", "cloudfront:ListTagsForResource", - "cloudfront:PublishFunction", + ] + 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 + # 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:TagResource", "cloudfront:UntagResource", "cloudfront:UpdateDistribution", - "cloudfront:UpdateFunction", ] Resource = "*" + Condition = { + StringEquals = { + "aws:ResourceTag/Project" = "CUDly" + } + } }, { Sid = "CloudWatchAlarms" @@ -133,29 +181,58 @@ 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. + # 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 = [ "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" @@ -199,6 +276,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 = [ @@ -206,31 +289,52 @@ resource "aws_iam_policy" "compute" { "ses:DeleteEmailIdentity", "ses:GetEmailIdentity", "ses:PutEmailIdentityDkimSigningAttributes", - "ses:SendEmail", "ses:TagResource", "ses:UntagResource", ] 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. + # 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 = [ - "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. + # 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", @@ -239,6 +343,11 @@ resource "aws_iam_policy" "compute" { "kms:UpdateKeyDescription", ] Resource = "*" + Condition = { + StringEquals = { + "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 new file mode 100644 index 00000000..f2a4e50d --- /dev/null +++ b/terraform/environments/aws/ci-cd-permissions/policy_compute_b.tf @@ -0,0 +1,72 @@ +# 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). 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" + 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 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", ]