You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
We need terraform-plan.yaml and terraform-apply.yaml to authenticate to AWS by assuming a role through GitHub's OIDC provider instead of passing a static access key and secret, because devops-security is the repo that builds the org's GitHub Actions OIDC and is the last repo still using long-lived credentials to reach AWS.
Action Items
Read the current position before changing anything. Both workflows pass aws-access-key-id: ${{ secrets.AWS_ACCESS_KEY_ID }} and aws-secret-access-key: ${{ secrets.AWS_SECRET_ACCESS_KEY }} to aws-actions/configure-aws-credentials@v4, and neither declares permissions: id-token: write at the job or workflow level. Line numbers drift — find the step by its name, Configure AWS Credentials.
Confirm what the secrets actually are. They are the access key of IAM user devops-iam-github-action (key AKIAQQWOSJEPUH74UTOJ, created 2024-02-19, never rotated, still in active use). Verify that nothing outside these two workflows uses that user before planning to delete it — aws iam get-access-key-last-used and CloudTrail on the username are the two places to look.
Do not create the roles with module "aws-gha-oidc-providers". That module creates an aws_iam_openid_connect_provider, and AWS permits exactly one provider per URL per account. arn:aws:iam::035866691871:oidc-provider/token.actions.githubusercontent.com already exists — the incubator instantiation created it — so a second instantiation fails with EntityAlreadyExists. The module is the right shape to copy and the wrong thing to call.
Design two roles, not one. The plan/apply privilege split is the part of the pattern that static keys have no equivalent for, and it is a security win here alongside retiring the keys.
devops-security-tf-plan — read-only, sub scoped to repo:hackforla/devops-security:ref:refs/heads/* and repo:hackforla/devops-security:pull_request.
devops-security-tf-apply — arn:aws:iam::aws:policy/AdministratorAccess, sub scoped to repo:hackforla/devops-security:ref:refs/heads/mainonly. The branch scoping is what keeps this safe: a branch or pull request cannot assume this role at all.
Both trust arn:aws:iam::035866691871:oidc-provider/token.actions.githubusercontent.com with token.actions.githubusercontent.com:aud = sts.amazonaws.com.
AdministratorAccess on the apply role is a decision, not a derivation, and is worth recording as one: the IAM user CI runs as today has a narrower set — managed TerraformIAM, TerraformDynamoDBAccess, DevopsSecurityTerraformBucketAccess, AmazonS3ReadOnlyAccess, plus inline GitHubActionsOIDCThumbprintUpdatePolicy — so this widens the apply path rather than matching it one-for-one. It matches incubator-tf-apply, which is the same shape one repo over. When Research - Restrict incubator apply privileges incubator#138 reports back on narrowing incubator's apply privileges, its conclusions apply here too.
Work out what the plan role needs beyond ReadOnlyAccess before creating it, because a plan is not a read-only operation against this backend.terraform/prod.backend.tfvars sets dynamodb_table = "hfla_ops_terraform_table", so every plan acquires and releases a state lock, which needs dynamodb:PutItem and dynamodb:DeleteItem — neither of which ReadOnlyAccess grants. The existing TerraformDynamoDBAccess policy grants exactly this set and can be attached to the plan role. Note incubator-tf-plan carries only ReadOnlyAccess plus a secrets-read policy and appears to lack lock-write permission entirely, so check how incubator's plan actually behaves rather than copying it — either it runs with locking disabled or something else is going on, and the answer decides whether this role needs the DynamoDB policy or the workflow needs -lock=false.
Settle the two tagging questions before creating the roles, and record the answers on this issue. A tag is hard to change once an audit depends on it.
The exact tag key and value marking these roles as deliberately outside Terraform. No immune or exempt convention exists in any of the three repos today. The only tagging precedent is user_tags on IAM users in terraform/aws-users.tf, which uses quoted title-case keys ("Project", "Access Level") — match that shape or diverge on purpose.
Which audit the tag exempts the roles from, by name. Decision record "Use it or lose it policy for IAM User Accounts" is about IAM users and these are roles, so the tag may be pre-empting an audit that does not exist. If it cannot be named, the tag documents nothing.
Create the two roles by hand in account 035866691871, and capture each role's trust policy, attached policies and tags before and after the change. This is an AWS write with no PR trail, so it follows the same discipline as the org-account work: one change at a time, verified after each. These roles are created manually on purpose — it dissolves the bootstrap problem where the Terraform that creates the role is run by the workflow that needs it — which is what makes the switch below a single PR.
Write down, in terraform/aws-gha-oidc-providers.tf itself, that these two roles exist outside Terraform and why. A reader of that file will otherwise see incubator's roles declared and devops-security's absent and "fix" it, which re-creates the bootstrap problem this decision removed. The note belongs where they will be standing, not only in this issue.
Switch both workflows in one PR. Replace the two secrets.AWS_* inputs with role-to-assume / role-session-name / aws-region: us-west-2, and add permissions: id-token: write alongside the existing contents: read. hackforla/incubator's .github/workflows/terraform-plan.yaml is the working example to copy.
Verification does not happen by itself here, and this is the trap. Both workflows filter on paths: ['**/*.tf'], so a PR that changes only workflow files triggers neither one — the switch would sit unexercised until someone else's Terraform change, possibly weeks later. Include a no-op .tf change in the same PR (a comment line is enough) so the plan runs on the PR and the apply runs on merge.
After the PR merges, confirm the apply run assumed devops-security-tf-apply and succeeded, and that the plan comment appeared on the PR before merge. Both are observable only after the fact, which is why they are listed here rather than as branch checks.
After both runs are confirmed green, retire the credentials: delete access key AKIAQQWOSJEPUH74UTOJ, delete the repository secrets AWS_ACCESS_KEY_ID and AWS_SECRET_ACCESS_KEY, and decide whether the IAM user devops-iam-github-action itself should go. Note the user is not currently declared in Terraform — verified after Bring the remaining IAM users under Terraform #180 merged — so deleting it also removes an unmanaged resource; if it is kept instead, it needs to be brought into terraform/aws-users.tf. Do not do any of this before the runs are green — it is the rollback path.
Resources/Instructions
.github/workflows/terraform-plan.yaml and .github/workflows/terraform-apply.yaml — the two files that change. The credential step is identical in both.
terraform/aws-gha-oidc-providers.tf — where the incubator roles are declared, and where the note about these roles living outside Terraform belongs.
terraform/modules/aws-gha-oidc-providers/ — the module to read for the trust-policy shape. Do not instantiate it; see the action item above.
terraform/prod.backend.tfvars — the S3 backend and the DynamoDB lock table the plan role has to be able to write to.
hackforla/incubator.github/workflows/terraform-plan.yaml and terraform-apply.yaml — the working OIDC example, assuming arn:aws:iam::035866691871:role/incubator-tf-plan.
Overview
We need
terraform-plan.yamlandterraform-apply.yamlto authenticate to AWS by assuming a role through GitHub's OIDC provider instead of passing a static access key and secret, becausedevops-securityis the repo that builds the org's GitHub Actions OIDC and is the last repo still using long-lived credentials to reach AWS.Action Items
aws-access-key-id: ${{ secrets.AWS_ACCESS_KEY_ID }}andaws-secret-access-key: ${{ secrets.AWS_SECRET_ACCESS_KEY }}toaws-actions/configure-aws-credentials@v4, and neither declarespermissions: id-token: writeat the job or workflow level. Line numbers drift — find the step by its name,Configure AWS Credentials.devops-iam-github-action(keyAKIAQQWOSJEPUH74UTOJ, created 2024-02-19, never rotated, still in active use). Verify that nothing outside these two workflows uses that user before planning to delete it —aws iam get-access-key-last-usedand CloudTrail on the username are the two places to look.module "aws-gha-oidc-providers". That module creates anaws_iam_openid_connect_provider, and AWS permits exactly one provider per URL per account.arn:aws:iam::035866691871:oidc-provider/token.actions.githubusercontent.comalready exists — the incubator instantiation created it — so a second instantiation fails withEntityAlreadyExists. The module is the right shape to copy and the wrong thing to call.devops-security-tf-plan— read-only,subscoped torepo:hackforla/devops-security:ref:refs/heads/*andrepo:hackforla/devops-security:pull_request.devops-security-tf-apply—arn:aws:iam::aws:policy/AdministratorAccess,subscoped torepo:hackforla/devops-security:ref:refs/heads/mainonly. The branch scoping is what keeps this safe: a branch or pull request cannot assume this role at all.arn:aws:iam::035866691871:oidc-provider/token.actions.githubusercontent.comwithtoken.actions.githubusercontent.com:aud = sts.amazonaws.com.AdministratorAccesson the apply role is a decision, not a derivation, and is worth recording as one: the IAM user CI runs as today has a narrower set — managedTerraformIAM,TerraformDynamoDBAccess,DevopsSecurityTerraformBucketAccess,AmazonS3ReadOnlyAccess, plus inlineGitHubActionsOIDCThumbprintUpdatePolicy— so this widens the apply path rather than matching it one-for-one. It matchesincubator-tf-apply, which is the same shape one repo over. When Research - Restrict incubator apply privileges incubator#138 reports back on narrowing incubator's apply privileges, its conclusions apply here too.ReadOnlyAccessbefore creating it, because a plan is not a read-only operation against this backend.terraform/prod.backend.tfvarssetsdynamodb_table = "hfla_ops_terraform_table", so every plan acquires and releases a state lock, which needsdynamodb:PutItemanddynamodb:DeleteItem— neither of whichReadOnlyAccessgrants. The existingTerraformDynamoDBAccesspolicy grants exactly this set and can be attached to the plan role. Noteincubator-tf-plancarries onlyReadOnlyAccessplus a secrets-read policy and appears to lack lock-write permission entirely, so check how incubator's plan actually behaves rather than copying it — either it runs with locking disabled or something else is going on, and the answer decides whether this role needs the DynamoDB policy or the workflow needs-lock=false.immuneorexemptconvention exists in any of the three repos today. The only tagging precedent isuser_tagson IAM users interraform/aws-users.tf, which uses quoted title-case keys ("Project","Access Level") — match that shape or diverge on purpose.035866691871, and capture each role's trust policy, attached policies and tags before and after the change. This is an AWS write with no PR trail, so it follows the same discipline as the org-account work: one change at a time, verified after each. These roles are created manually on purpose — it dissolves the bootstrap problem where the Terraform that creates the role is run by the workflow that needs it — which is what makes the switch below a single PR.terraform/aws-gha-oidc-providers.tfitself, that these two roles exist outside Terraform and why. A reader of that file will otherwise see incubator's roles declared and devops-security's absent and "fix" it, which re-creates the bootstrap problem this decision removed. The note belongs where they will be standing, not only in this issue.secrets.AWS_*inputs withrole-to-assume/role-session-name/aws-region: us-west-2, and addpermissions: id-token: writealongside the existingcontents: read.hackforla/incubator's.github/workflows/terraform-plan.yamlis the working example to copy.paths: ['**/*.tf'], so a PR that changes only workflow files triggers neither one — the switch would sit unexercised until someone else's Terraform change, possibly weeks later. Include a no-op.tfchange in the same PR (a comment line is enough) so the plan runs on the PR and the apply runs on merge.devops-security-tf-applyand succeeded, and that the plan comment appeared on the PR before merge. Both are observable only after the fact, which is why they are listed here rather than as branch checks.AKIAQQWOSJEPUH74UTOJ, delete the repository secretsAWS_ACCESS_KEY_IDandAWS_SECRET_ACCESS_KEY, and decide whether the IAM userdevops-iam-github-actionitself should go. Note the user is not currently declared in Terraform — verified after Bring the remaining IAM users under Terraform #180 merged — so deleting it also removes an unmanaged resource; if it is kept instead, it needs to be brought intoterraform/aws-users.tf. Do not do any of this before the runs are green — it is the rollback path.Resources/Instructions
.github/workflows/terraform-plan.yamland.github/workflows/terraform-apply.yaml— the two files that change. The credential step is identical in both.terraform/aws-gha-oidc-providers.tf— where the incubator roles are declared, and where the note about these roles living outside Terraform belongs.terraform/modules/aws-gha-oidc-providers/— the module to read for the trust-policy shape. Do not instantiate it; see the action item above.terraform/prod.backend.tfvars— the S3 backend and the DynamoDB lock table the plan role has to be able to write to.hackforla/incubator.github/workflows/terraform-plan.yamlandterraform-apply.yaml— the working OIDC example, assumingarn:aws:iam::035866691871:role/incubator-tf-plan.aws-actions/configure-aws-credentialsv4→v6 anddflook/terraform-*v1→v3 in these same two files, and rewrites the very step this issue rewrites. Whichever starts first, check the other: if this issue lands first, Bump configure-aws-credentials to v6 and dflook/terraform-* to v3 in the Terraform workflows #170 should close itself as covered for theconfigure-aws-credentialshalf.035866691871, regionus-west-2.