shahar1 commented on code in PR #66: URL: https://github.com/apache/airflow-ci-infra/pull/66#discussion_r4361039488
########## terraform/runs-on-access/main.tf: ########## @@ -0,0 +1,217 @@ +# Licensed to the Apache Software Foundation (ASF) under one +# or more contributor license agreements. See the NOTICE file +# distributed with this work for additional information +# regarding copyright ownership. The ASF licenses this file +# to you under the Apache License, Version 2.0 (the +# "License"); you may not use this file except in compliance +# with the License. You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, +# software distributed under the License is distributed on an +# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +# KIND, either express or implied. See the License for the +# specific language governing permissions and limitations +# under the License. + +locals { + account_id = data.aws_caller_identity.current.account_id + stack_arn = "arn:aws:cloudformation:us-east-2:${local.account_id}:stack/runs-on/*" + state = "arn:aws:s3:::airflow-ci-tfstate/tf-state/runs-on.tfstate" + + # Immutable OIDC subject (owner and repository IDs next to their names), so a renamed or + # re-created repository cannot match. Fork pull requests get no OIDC token at all, and the + # runs-on-prod environment deploys only from main after a required reviewer approves (.asf.yaml). + github_subject = "repo:apache@47359/airflow-ci-infra@321691041" + github_subjects = { + plan = ["${local.github_subject}:pull_request", "${local.github_subject}:ref:refs/heads/main"] + apply = ["${local.github_subject}:environment:runs-on-prod"] + } + + # IAM names the RunsOn template generates for the runs-on stack: <stack>-<logical id>-<suffix>. + # Matching the logical-ID prefixes rather than "runs-on-*" keeps every other role in the account, + # including the ones defined here, out of reach of the CloudFormation service role. + stack_role_prefixes = [ + "RunsOn", "EC2Instance", "ManagedLaunchTemplate", "SchedulerInvoke", "StackConfigMaterializer", + "NetworkFirewallEndpointResolver", "AlertTopicSlackWebhook", + ] + stack_iam_resources = concat( + [for prefix in local.stack_role_prefixes : "arn:aws:iam::${local.account_id}:role/runs-on-${prefix}*"], + [ + "arn:aws:iam::${local.account_id}:instance-profile/runs-on-EC2InstanceProfile*", + "arn:aws:iam::${local.account_id}:role/aws-service-role/*", + ], + ) +} + +data "aws_caller_identity" "current" {} + +# Shared with apache/airflow's GithubActionApacheAirflow role, so it is read here, never managed. +data "aws_iam_openid_connect_provider" "github" { + url = "https://token.actions.githubusercontent.com" +} + +data "aws_iam_policy_document" "github_trust" { + for_each = local.github_subjects + + statement { + actions = ["sts:AssumeRoleWithWebIdentity"] + principals { + type = "Federated" + identifiers = [data.aws_iam_openid_connect_provider.github.arn] + } + condition { + test = "StringEquals" + variable = "token.actions.githubusercontent.com:aud" + values = ["sts.amazonaws.com"] + } + condition { + test = "StringEquals" + variable = "token.actions.githubusercontent.com:sub" + values = each.value + } + } +} + +resource "aws_iam_role" "terraform" { + for_each = local.github_subjects + + name = "runs-on-terraform-${each.key}" + description = "terraform ${each.key} of terraform/runs-on from apache/airflow-ci-infra GitHub Actions" + assume_role_policy = data.aws_iam_policy_document.github_trust[each.key].json +} + +# What terraform plan needs: refresh the stack, prove the license-key parameter exists and read +# the state. Plans run with -lock=false, so this policy grants no writes. +data "aws_iam_policy_document" "read" { + statement { + actions = ["cloudformation:DescribeStacks", "cloudformation:DescribeStackEvents", "cloudformation:GetTemplate"] + resources = [local.stack_arn] + } + statement { + actions = ["ssm:GetParameter"] + resources = ["arn:aws:ssm:us-east-2:${local.account_id}:parameter/runs-on/license-key"] + } + # The aws/ssm key policy lets any principal in the account decrypt through SSM, so + # GetParameter alone would also return the plain license key. Terraform never asks for it. + statement { + effect = "Deny" + actions = ["kms:Decrypt"] + resources = ["*"] + } + statement { + actions = ["s3:ListBucket"] + resources = ["arn:aws:s3:::airflow-ci-tfstate"] + condition { + test = "StringEquals" + variable = "s3:prefix" + values = ["tf-state/runs-on.tfstate"] + } + } + statement { + actions = ["s3:GetObject"] + resources = [local.state] + } +} + +resource "aws_iam_role_policy" "read" { + for_each = aws_iam_role.terraform + + name = "terraform-read" + role = each.value.name + policy = data.aws_iam_policy_document.read.json +} + +# The apply role can only resubmit the stack from RunsOn's published templates. CloudFormation +# then acts as the stack's service role below, so this role holds no infrastructure permissions. +data "aws_iam_policy_document" "apply" { + statement { + actions = ["cloudformation:UpdateStack"] + resources = [local.stack_arn] + condition { + test = "StringLike" + variable = "cloudformation:TemplateUrl" + values = ["https://runs-on.s3.eu-west-1.amazonaws.com/cloudformation/template-v*.yaml"] + } + } + statement { + actions = ["iam:PassRole"] + resources = [aws_iam_role.cloudformation.arn] + condition { + test = "StringEquals" + variable = "iam:PassedToService" + values = ["cloudformation.amazonaws.com"] + } + } + statement { + actions = ["s3:PutObject"] + resources = [local.state] + } + statement { + actions = ["s3:GetObject", "s3:PutObject", "s3:DeleteObject"] + resources = ["${local.state}.tflock"] + } +} + +resource "aws_iam_role_policy" "apply" { + name = "terraform-apply" + role = aws_iam_role.terraform["apply"].name + policy = data.aws_iam_policy_document.apply.json +} + +data "aws_iam_policy_document" "cloudformation_trust" { + statement { + actions = ["sts:AssumeRole"] + principals { + type = "Service" + identifiers = ["cloudformation.amazonaws.com"] + } + } +} + +resource "aws_iam_role" "cloudformation" { + name = "runs-on-cloudformation" + description = "Service role CloudFormation uses to update the runs-on stack" + assume_role_policy = data.aws_iam_policy_document.cloudformation_trust.json +} + +# RunsOn documents only "admin rights" for deploying its stack: the template creates IAM, VPC, +# ECS, Lambda, API Gateway, SQS, DynamoDB, S3, SNS and Budgets resources, and the set changes +# between releases. AdministratorAccess follows that; the denies below keep the role away from +# everything in the account that is not the stack's own. +resource "aws_iam_role_policy_attachment" "cloudformation" { + role = aws_iam_role.cloudformation.name + policy_arn = "arn:aws:iam::aws:policy/AdministratorAccess" +} + +data "aws_iam_policy_document" "cloudformation_guardrails" { + statement { + sid = "OnlyTheStackIam" + effect = "Deny" + actions = [ + "iam:Add*", "iam:Attach*", "iam:Change*", "iam:Create*", "iam:Deactivate*", "iam:Delete*", + "iam:Detach*", "iam:Enable*", "iam:PassRole", "iam:Put*", "iam:Remove*", "iam:Reset*", + "iam:Set*", "iam:Tag*", "iam:Untag*", "iam:Update*", "iam:Upload*", + ] + not_resources = local.stack_iam_resources Review Comment: Agreed, and it was worse than this. The v3.3.2 template has a second free-form parameter, `RunnerCustomPolicy`, which goes into `EC2InstanceRole`'s `ManagedPolicyArns` (t.yaml L158/L2011). `RunnerCustomPolicy=…/AdministratorAccess` would put admin on every CI runner, through IMDS, while it runs job code. Fixed in 8a3f8aa: - **New guardrail `OnlyTheTemplateManagedPolicies`:** the service role is denied `iam:AttachRolePolicy` unless `iam:PolicyARN` is one of the two managed policies the template attaches itself: `AmazonSSMManagedEC2InstanceDefaultPolicy` and `service-role/AmazonECSTaskExecutionRolePolicy`. These are also the only two attached on the live stack. - With `aws iam simulate-custom-policy`, attaching `AdministratorAccess` to `runs-on-RunsOnServiceRole-*` or `runs-on-EC2InstanceRole-*` is now an explicit deny. The two template policies are still allowed. - `iam:PutRolePolicy` is deliberately not in it. That action has no `iam:PolicyARN` key, so a negated condition would deny all ~20 inline policies of the template. - A permissions boundary isn't an option: the template can't set one on the roles it creates. - **Exact template versions:** `cloudformation:TemplateUrl` now uses `StringEquals` over exact URLs built from `runs_on_versions = ["v3.3.2"]`. Older published templates, such as v3.3.1, v3.0.0 and v2.8.0, which still return 200, are now an implicit deny. An upgrade adds the new version there and has an admin apply this module first. The README covers that, including keeping the previous version listed during an upgrade so a rollback still works. On `AppRegistry`: IAM can't fence it, because `ecs:RegisterTaskDefinition` has no image condition key. In v3.3.2 the image is `${AppRegistry}:${ImageTag}`, with `ImageTag` pinned to a digest (`v3.3.2@sha256:53df…`, t.yaml L354/L7835). A different registry therefore can't serve a different image under that reference. Any other parameter change still has to pass PR review and the `runs-on-prod` approval, which shows the plan. --- Drafted-by: Claude Code (Opus 5.5) (no human review before posting) ########## terraform/runs-on-access/main.tf: ########## @@ -0,0 +1,217 @@ +# Licensed to the Apache Software Foundation (ASF) under one +# or more contributor license agreements. See the NOTICE file +# distributed with this work for additional information +# regarding copyright ownership. The ASF licenses this file +# to you under the Apache License, Version 2.0 (the +# "License"); you may not use this file except in compliance +# with the License. You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, +# software distributed under the License is distributed on an +# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +# KIND, either express or implied. See the License for the +# specific language governing permissions and limitations +# under the License. + +locals { + account_id = data.aws_caller_identity.current.account_id + stack_arn = "arn:aws:cloudformation:us-east-2:${local.account_id}:stack/runs-on/*" + state = "arn:aws:s3:::airflow-ci-tfstate/tf-state/runs-on.tfstate" + + # Immutable OIDC subject (owner and repository IDs next to their names), so a renamed or + # re-created repository cannot match. Fork pull requests get no OIDC token at all, and the + # runs-on-prod environment deploys only from main after a required reviewer approves (.asf.yaml). + github_subject = "repo:apache@47359/airflow-ci-infra@321691041" + github_subjects = { + plan = ["${local.github_subject}:pull_request", "${local.github_subject}:ref:refs/heads/main"] + apply = ["${local.github_subject}:environment:runs-on-prod"] + } + + # IAM names the RunsOn template generates for the runs-on stack: <stack>-<logical id>-<suffix>. + # Matching the logical-ID prefixes rather than "runs-on-*" keeps every other role in the account, + # including the ones defined here, out of reach of the CloudFormation service role. + stack_role_prefixes = [ + "RunsOn", "EC2Instance", "ManagedLaunchTemplate", "SchedulerInvoke", "StackConfigMaterializer", + "NetworkFirewallEndpointResolver", "AlertTopicSlackWebhook", + ] + stack_iam_resources = concat( + [for prefix in local.stack_role_prefixes : "arn:aws:iam::${local.account_id}:role/runs-on-${prefix}*"], + [ + "arn:aws:iam::${local.account_id}:instance-profile/runs-on-EC2InstanceProfile*", + "arn:aws:iam::${local.account_id}:role/aws-service-role/*", + ], + ) +} + +data "aws_caller_identity" "current" {} + +# Shared with apache/airflow's GithubActionApacheAirflow role, so it is read here, never managed. +data "aws_iam_openid_connect_provider" "github" { + url = "https://token.actions.githubusercontent.com" +} + +data "aws_iam_policy_document" "github_trust" { + for_each = local.github_subjects + + statement { + actions = ["sts:AssumeRoleWithWebIdentity"] + principals { + type = "Federated" + identifiers = [data.aws_iam_openid_connect_provider.github.arn] + } + condition { + test = "StringEquals" + variable = "token.actions.githubusercontent.com:aud" + values = ["sts.amazonaws.com"] + } + condition { + test = "StringEquals" + variable = "token.actions.githubusercontent.com:sub" + values = each.value + } + } +} + +resource "aws_iam_role" "terraform" { + for_each = local.github_subjects + + name = "runs-on-terraform-${each.key}" + description = "terraform ${each.key} of terraform/runs-on from apache/airflow-ci-infra GitHub Actions" + assume_role_policy = data.aws_iam_policy_document.github_trust[each.key].json +} + +# What terraform plan needs: refresh the stack, prove the license-key parameter exists and read +# the state. Plans run with -lock=false, so this policy grants no writes. +data "aws_iam_policy_document" "read" { + statement { + actions = ["cloudformation:DescribeStacks", "cloudformation:DescribeStackEvents", "cloudformation:GetTemplate"] + resources = [local.stack_arn] + } + statement { + actions = ["ssm:GetParameter"] + resources = ["arn:aws:ssm:us-east-2:${local.account_id}:parameter/runs-on/license-key"] + } + # The aws/ssm key policy lets any principal in the account decrypt through SSM, so + # GetParameter alone would also return the plain license key. Terraform never asks for it. + statement { + effect = "Deny" + actions = ["kms:Decrypt"] + resources = ["*"] + } + statement { + actions = ["s3:ListBucket"] + resources = ["arn:aws:s3:::airflow-ci-tfstate"] + condition { + test = "StringEquals" + variable = "s3:prefix" Review Comment: I checked this against the Terraform version the workflow pins (1.16.2), and init won't fail. `internal/backend/remote-state/s3/backend_state.go` L74-77 handles exactly this case: ```go if foo, ok := As[smithy.APIError](err); b.workspaceKeyPrefix == defaultWorkspaceKeyPrefix && ok && foo.ErrorCode() == "AccessDenied" { log.Warn("Unable to list non-default workspaces", ...) return wss[:1], nil ``` `state.tf` keeps the default `workspace_key_prefix` (`env:`), so a denied `env:/` listing only logs a warning and Terraform continues with the default workspace. A trace of `init` and `plan` against the live state shows these S3 calls: `ListObjectsV2` on `env:/` (workspace discovery), plus `HeadObject` and `GetObject` on the state key. The bucket has no objects under `env:/`. So I left the policy unchanged rather than widen `ListBucket`; widening it would only silence that warning. I'm leaving this thread open in case you'd still prefer the warning gone. If so, it's a one-line addition of `"env:/"` to the condition values. --- Drafted-by: Claude Code (Opus 5.5) (no human review before posting) ########## terraform/runs-on-access/main.tf: ########## @@ -0,0 +1,217 @@ +# Licensed to the Apache Software Foundation (ASF) under one +# or more contributor license agreements. See the NOTICE file +# distributed with this work for additional information +# regarding copyright ownership. The ASF licenses this file +# to you under the Apache License, Version 2.0 (the +# "License"); you may not use this file except in compliance +# with the License. You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, +# software distributed under the License is distributed on an +# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +# KIND, either express or implied. See the License for the +# specific language governing permissions and limitations +# under the License. + +locals { + account_id = data.aws_caller_identity.current.account_id + stack_arn = "arn:aws:cloudformation:us-east-2:${local.account_id}:stack/runs-on/*" + state = "arn:aws:s3:::airflow-ci-tfstate/tf-state/runs-on.tfstate" + + # Immutable OIDC subject (owner and repository IDs next to their names), so a renamed or + # re-created repository cannot match. Fork pull requests get no OIDC token at all, and the + # runs-on-prod environment deploys only from main after a required reviewer approves (.asf.yaml). + github_subject = "repo:apache@47359/airflow-ci-infra@321691041" + github_subjects = { + plan = ["${local.github_subject}:pull_request", "${local.github_subject}:ref:refs/heads/main"] + apply = ["${local.github_subject}:environment:runs-on-prod"] + } + + # IAM names the RunsOn template generates for the runs-on stack: <stack>-<logical id>-<suffix>. + # Matching the logical-ID prefixes rather than "runs-on-*" keeps every other role in the account, + # including the ones defined here, out of reach of the CloudFormation service role. + stack_role_prefixes = [ + "RunsOn", "EC2Instance", "ManagedLaunchTemplate", "SchedulerInvoke", "StackConfigMaterializer", + "NetworkFirewallEndpointResolver", "AlertTopicSlackWebhook", + ] + stack_iam_resources = concat( + [for prefix in local.stack_role_prefixes : "arn:aws:iam::${local.account_id}:role/runs-on-${prefix}*"], + [ + "arn:aws:iam::${local.account_id}:instance-profile/runs-on-EC2InstanceProfile*", + "arn:aws:iam::${local.account_id}:role/aws-service-role/*", + ], + ) +} + +data "aws_caller_identity" "current" {} + +# Shared with apache/airflow's GithubActionApacheAirflow role, so it is read here, never managed. +data "aws_iam_openid_connect_provider" "github" { + url = "https://token.actions.githubusercontent.com" +} + +data "aws_iam_policy_document" "github_trust" { + for_each = local.github_subjects + + statement { + actions = ["sts:AssumeRoleWithWebIdentity"] + principals { + type = "Federated" + identifiers = [data.aws_iam_openid_connect_provider.github.arn] + } + condition { + test = "StringEquals" + variable = "token.actions.githubusercontent.com:aud" + values = ["sts.amazonaws.com"] + } + condition { + test = "StringEquals" + variable = "token.actions.githubusercontent.com:sub" + values = each.value + } + } +} + +resource "aws_iam_role" "terraform" { + for_each = local.github_subjects + + name = "runs-on-terraform-${each.key}" + description = "terraform ${each.key} of terraform/runs-on from apache/airflow-ci-infra GitHub Actions" + assume_role_policy = data.aws_iam_policy_document.github_trust[each.key].json +} + +# What terraform plan needs: refresh the stack, prove the license-key parameter exists and read +# the state. Plans run with -lock=false, so this policy grants no writes. +data "aws_iam_policy_document" "read" { + statement { + actions = ["cloudformation:DescribeStacks", "cloudformation:DescribeStackEvents", "cloudformation:GetTemplate"] + resources = [local.stack_arn] + } + statement { + actions = ["ssm:GetParameter"] + resources = ["arn:aws:ssm:us-east-2:${local.account_id}:parameter/runs-on/license-key"] + } + # The aws/ssm key policy lets any principal in the account decrypt through SSM, so + # GetParameter alone would also return the plain license key. Terraform never asks for it. + statement { + effect = "Deny" + actions = ["kms:Decrypt"] + resources = ["*"] + } + statement { + actions = ["s3:ListBucket"] + resources = ["arn:aws:s3:::airflow-ci-tfstate"] + condition { + test = "StringEquals" + variable = "s3:prefix" + values = ["tf-state/runs-on.tfstate"] + } + } + statement { + actions = ["s3:GetObject"] + resources = [local.state] + } +} + +resource "aws_iam_role_policy" "read" { + for_each = aws_iam_role.terraform + + name = "terraform-read" + role = each.value.name + policy = data.aws_iam_policy_document.read.json +} + +# The apply role can only resubmit the stack from RunsOn's published templates. CloudFormation +# then acts as the stack's service role below, so this role holds no infrastructure permissions. +data "aws_iam_policy_document" "apply" { + statement { + actions = ["cloudformation:UpdateStack"] + resources = [local.stack_arn] + condition { + test = "StringLike" + variable = "cloudformation:TemplateUrl" + values = ["https://runs-on.s3.eu-west-1.amazonaws.com/cloudformation/template-v*.yaml"] + } + } + statement { + actions = ["iam:PassRole"] + resources = [aws_iam_role.cloudformation.arn] + condition { + test = "StringEquals" + variable = "iam:PassedToService" + values = ["cloudformation.amazonaws.com"] + } + } + statement { + actions = ["s3:PutObject"] + resources = [local.state] + } + statement { + actions = ["s3:GetObject", "s3:PutObject", "s3:DeleteObject"] + resources = ["${local.state}.tflock"] + } +} + +resource "aws_iam_role_policy" "apply" { + name = "terraform-apply" + role = aws_iam_role.terraform["apply"].name + policy = data.aws_iam_policy_document.apply.json +} + +data "aws_iam_policy_document" "cloudformation_trust" { + statement { + actions = ["sts:AssumeRole"] + principals { + type = "Service" + identifiers = ["cloudformation.amazonaws.com"] Review Comment: I didn't add these, because I couldn't find them documented for this case. AWS documents `aws:SourceArn` and `aws:SourceAccount` for CloudFormation only on the cross-service confused-deputy page, and only for registry extension roles (`resources.cloudformation.amazonaws.com`) and StackSets. The stack service role page doesn't mention them. If CloudFormation doesn't populate `aws:SourceArn` when it assumes a stack's `RoleARN`, an `ArnLike` condition evaluates false, and every apply would fail the first time it runs. `aws:SourceAccount` adds nothing here, because `iam:PassRole` can't cross accounts. What's left is a principal in this account that has `iam:PassRole *` plus `cloudformation:CreateStack`, and such principals are effectively admins already. The service role's own denies still apply whichever stack it's passed to: no IAM writes outside the RunsOn role names, no `sts:AssumeRole*`, no access to the state bucket, and only the two template managed policies. My proposal is to test the condition once during bootstrap on a throwaway stack (a `WaitConditionHandle`-only stack is free) and add it if CloudFormation populates the key. I'm leaving this open until that's done or you're happy without it. --- Drafted-by: Claude Code (Opus 5.5) (no human review before posting) ########## terraform/runs-on-access/main.tf: ########## @@ -0,0 +1,217 @@ +# Licensed to the Apache Software Foundation (ASF) under one +# or more contributor license agreements. See the NOTICE file +# distributed with this work for additional information +# regarding copyright ownership. The ASF licenses this file +# to you under the Apache License, Version 2.0 (the +# "License"); you may not use this file except in compliance +# with the License. You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, +# software distributed under the License is distributed on an +# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +# KIND, either express or implied. See the License for the +# specific language governing permissions and limitations +# under the License. + +locals { + account_id = data.aws_caller_identity.current.account_id + stack_arn = "arn:aws:cloudformation:us-east-2:${local.account_id}:stack/runs-on/*" + state = "arn:aws:s3:::airflow-ci-tfstate/tf-state/runs-on.tfstate" + + # Immutable OIDC subject (owner and repository IDs next to their names), so a renamed or + # re-created repository cannot match. Fork pull requests get no OIDC token at all, and the + # runs-on-prod environment deploys only from main after a required reviewer approves (.asf.yaml). + github_subject = "repo:apache@47359/airflow-ci-infra@321691041" + github_subjects = { + plan = ["${local.github_subject}:pull_request", "${local.github_subject}:ref:refs/heads/main"] + apply = ["${local.github_subject}:environment:runs-on-prod"] + } + + # IAM names the RunsOn template generates for the runs-on stack: <stack>-<logical id>-<suffix>. + # Matching the logical-ID prefixes rather than "runs-on-*" keeps every other role in the account, + # including the ones defined here, out of reach of the CloudFormation service role. + stack_role_prefixes = [ + "RunsOn", "EC2Instance", "ManagedLaunchTemplate", "SchedulerInvoke", "StackConfigMaterializer", + "NetworkFirewallEndpointResolver", "AlertTopicSlackWebhook", + ] + stack_iam_resources = concat( + [for prefix in local.stack_role_prefixes : "arn:aws:iam::${local.account_id}:role/runs-on-${prefix}*"], + [ + "arn:aws:iam::${local.account_id}:instance-profile/runs-on-EC2InstanceProfile*", + "arn:aws:iam::${local.account_id}:role/aws-service-role/*", + ], + ) +} + +data "aws_caller_identity" "current" {} + +# Shared with apache/airflow's GithubActionApacheAirflow role, so it is read here, never managed. +data "aws_iam_openid_connect_provider" "github" { + url = "https://token.actions.githubusercontent.com" +} + +data "aws_iam_policy_document" "github_trust" { + for_each = local.github_subjects + + statement { + actions = ["sts:AssumeRoleWithWebIdentity"] + principals { + type = "Federated" + identifiers = [data.aws_iam_openid_connect_provider.github.arn] + } + condition { + test = "StringEquals" + variable = "token.actions.githubusercontent.com:aud" + values = ["sts.amazonaws.com"] + } + condition { + test = "StringEquals" + variable = "token.actions.githubusercontent.com:sub" + values = each.value + } + } +} + +resource "aws_iam_role" "terraform" { + for_each = local.github_subjects + + name = "runs-on-terraform-${each.key}" + description = "terraform ${each.key} of terraform/runs-on from apache/airflow-ci-infra GitHub Actions" + assume_role_policy = data.aws_iam_policy_document.github_trust[each.key].json +} + +# What terraform plan needs: refresh the stack, prove the license-key parameter exists and read +# the state. Plans run with -lock=false, so this policy grants no writes. +data "aws_iam_policy_document" "read" { + statement { + actions = ["cloudformation:DescribeStacks", "cloudformation:DescribeStackEvents", "cloudformation:GetTemplate"] + resources = [local.stack_arn] + } + statement { + actions = ["ssm:GetParameter"] + resources = ["arn:aws:ssm:us-east-2:${local.account_id}:parameter/runs-on/license-key"] + } + # The aws/ssm key policy lets any principal in the account decrypt through SSM, so + # GetParameter alone would also return the plain license key. Terraform never asks for it. + statement { + effect = "Deny" + actions = ["kms:Decrypt"] Review Comment: Fixed in 8a3f8aa: the deny now has `kms:ViaService = ssm.us-east-2.amazonaws.com`. In the simulator, Decrypt through SSM is an explicit deny and through S3 it's allowed. The license key is still protected: the `aws/ssm` key policy only allows decryption via SSM, so a direct `kms:Decrypt` has no allow anywhere. For the record, nothing would break today. The bucket default and the state object are both `AES256`, and Terraform sends `AES256` explicitly when `encrypt = true` is set without a `kms_key_id`. --- Drafted-by: Claude Code (Opus 5.5) (no human review before posting) ########## .github/workflows/terraform-runs-on.yml: ########## @@ -0,0 +1,109 @@ +# Licensed to the Apache Software Foundation (ASF) under one +# or more contributor license agreements. See the NOTICE file +# distributed with this work for additional information +# regarding copyright ownership. The ASF licenses this file +# to you under the Apache License, Version 2.0 (the +# "License"); you may not use this file except in compliance +# with the License. You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, +# software distributed under the License is distributed on an +# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +# KIND, either express or implied. See the License for the +# specific language governing permissions and limitations +# under the License. +--- +# Plans terraform/runs-on on pull requests and on main; applies the plan made on main once a +# required reviewer approves the runs-on-prod environment. The AWS roles live in +# terraform/runs-on-access and are applied by hand. +name: Terraform (RunsOn) + +on: # yamllint disable-line rule:truthy + pull_request: + paths: + - "terraform/runs-on/**" + - ".github/workflows/terraform-runs-on.yml" + push: + branches: [main] + paths: + - "terraform/runs-on/**" + - ".github/workflows/terraform-runs-on.yml" + +permissions: {} + +concurrency: + group: terraform-runs-on-${{ github.ref }} + cancel-in-progress: ${{ github.event_name == 'pull_request' }} + +env: + AWS_REGION: us-east-2 + TF_IN_AUTOMATION: "true" + TF_INPUT: "false" + +jobs: + plan: + # Fork pull requests get no OIDC token, so they cannot plan. + if: >- + github.event_name == 'push' || + github.event.pull_request.head.repo.full_name == github.repository + runs-on: ubuntu-24.04 + permissions: + contents: read + id-token: write + defaults: + run: + working-directory: terraform/runs-on + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + persist-credentials: false + - uses: aws-actions/configure-aws-credentials@e6de054238d6b7531b4efff3b6587d9aade6a06c # v6.2.3 + with: + role-to-assume: arn:aws:iam::827901512104:role/runs-on-terraform-plan + aws-region: ${{ env.AWS_REGION }} + - uses: hashicorp/setup-terraform@dfe3c3f87815947d99a8997f908cb6525fc44e9e # v4.0.1 + with: + terraform_version: 1.16.2 + terraform_wrapper: false + - run: terraform init -lockfile=readonly + # The plan role cannot write the lock file; apply takes the lock and refuses a stale plan. + - run: terraform plan -lock=false -out=runs-on.tfplan + - if: github.event_name == 'push' + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 + with: + name: runs-on-tfplan + path: terraform/runs-on/runs-on.tfplan + retention-days: 1 Review Comment: Fixed in 8a3f8aa: `retention-days: 7`. The workflow could already be re-triggered without `workflow_dispatch`. "Re-run all jobs" re-plans on the same commit with the same OIDC subject and uploads a new artifact. A stale plan is safe too, because apply refuses it once the state serial has changed. The README now documents the re-run. It also says to do it only while that run is still the latest on `main`: re-running an older run after a newer one has applied would plan a revert. I didn't go past 7 days. The repo is public, so any signed-in user can download the plan artifact. It contains infrastructure IDs and only the SSM ciphertext. --- Drafted-by: Claude Code (Opus 5.5) (no human review before posting) -- This is an automated message from the Apache Git Service. To respond to the message, please log on to GitHub and use the URL above to go to the specific comment. To unsubscribe, e-mail: [email protected] For queries about this service, please contact Infrastructure at: [email protected]
