From fd8dd25bd75efdff075fefa15559401da01fee9c Mon Sep 17 00:00:00 2001 From: Haseeb Rabbani Date: Tue, 15 Sep 2026 17:16:09 +0400 Subject: [PATCH 1/2] infra: manage GitHub Actions OIDC roles for the AWS Deploy workflow Ports the OIDC bootstrap role (root account) and deploy role (stack account) from oz-terraform, which is being abandoned, into infra/oidc.tf. The roles are shared by every stack in the account, so they are opt-in via github_oidc_enabled and meant for the guardian-prod stack only. The trust policy admits the guardian repo's devnet and testnet environment subject claims (github_oidc_subjects). The bootstrap role is managed through an aws.root_account provider alias. Stack-account credentials cannot assume the root account's Terraform role, so the alias also accepts a named profile (github_oidc_root_account_profile). The deploy role's trust policy derives the bootstrap role ARN from the provider ARN's account instead of the resource so it stays computable at plan time. Existing roles are adopted with terraform import; runbook in infra/README.md. --- docs/SERVER_AWS_DEPLOY.md | 8 ++- infra/README.md | 63 ++++++++++++++++ infra/oidc.tf | 127 +++++++++++++++++++++++++++++++++ infra/outputs.tf | 10 +++ infra/terraform.tfvars.example | 11 +++ infra/variables.tf | 45 ++++++++++++ infra/versions.tf | 25 +++++++ 7 files changed, 287 insertions(+), 2 deletions(-) create mode 100644 infra/oidc.tf diff --git a/docs/SERVER_AWS_DEPLOY.md b/docs/SERVER_AWS_DEPLOY.md index 883ea44c..2bafbad3 100644 --- a/docs/SERVER_AWS_DEPLOY.md +++ b/docs/SERVER_AWS_DEPLOY.md @@ -127,8 +127,12 @@ One-time setup per target (infra): - Each environment must restrict **deployment branches** to `main`. Without that, anyone able to dispatch the workflow could run an edited copy of it from a feature branch and obtain the environment's AWS OIDC identity. -- The OIDC role's trust policy must accept this repository's environment - subject claims (`repo:OpenZeppelin/guardian:environment:devnet` / `:testnet`). +- The OIDC roles are managed by `infra/oidc.tf` on the `guardian-prod` stack + (`github_oidc_enabled = true`); the trust policy admits this repository's + environment subject claims (`repo:OpenZeppelin/guardian:environment:devnet` / + `:testnet`) via `github_oidc_subjects`. Adding an environment means adding + its claim there and applying. See the "GitHub Actions OIDC roles" section of + `infra/README.md`. - The deploy role needs ECR push/pull on `-server` (`ecr:GetAuthorizationToken`, `ecr:DescribeRepositories`, `ecr:BatchCheckLayerAvailability`, `ecr:BatchGetImage`, diff --git a/infra/README.md b/infra/README.md index a10b8b90..d4ce7da4 100644 --- a/infra/README.md +++ b/infra/README.md @@ -242,6 +242,12 @@ aws ecr delete-repository --repository-name "$ECR_REPO_NAME" --force --region "$ | `stack_name` | `guardian` | Base name used to derive stack resource names | | `deployment_stage` | `dev` | Deployment stage profile | | `server_image_uri` | (required) | ECR image URI for the server, preferably pinned to a digest | +| `github_oidc_enabled` | `false` | Manage the GitHub Actions OIDC roles for the AWS Deploy workflow; enable on exactly one stack | +| `github_oidc_provider_arn` | `""` | GitHub OIDC identity provider ARN in the root account (required when enabled) | +| `github_oidc_root_account_role_arn` | `""` | Role assumed in the root account to manage the bootstrap role (required when enabled) | +| `github_oidc_role_name` | `github-actions-solutions-account-guardian-oidc-role` | Bootstrap role name | +| `github_deploy_role_name` | `GithubOIDCGuardianRole` | Deploy role name | +| `github_oidc_subjects` | guardian `devnet`/`testnet` environments | OIDC subject claims allowed to assume the bootstrap role | | `guardian_operator_public_keys` | `[]` | Falcon public keys used to create a stack-scoped operator public keys secret | | `guardian_operator_public_keys_secret_arn` | `""` | Existing operator public keys secret ARN; takes precedence over the managed list | | `guardian_evm_allowed_chain_ids` | `""` | EVM chain IDs used to create a stack-scoped allowed chain IDs secret | @@ -309,6 +315,8 @@ aws ecr delete-repository --repository-name "$ECR_REPO_NAME" --force --region "$ | Output | Description | |--------|-------------| | `alb_dns_name` | ALB DNS name for accessing the server | +| `github_oidc_role_arn` | Bootstrap role ARN for the AWS Deploy workflow (`ROLE_FOR_OIDC`); empty unless `github_oidc_enabled` | +| `github_deploy_role_arn` | Deploy role ARN for the AWS Deploy workflow (`ROLE_TO_ASSUME`); empty unless `github_oidc_enabled` | | `alb_url` | Full URL (http or https) | | `custom_domain_url` | Canonical service URL: https with a certificate, http when Terraform manages only the DNS record | | `alias_domain_url` | Migration-only legacy domain URL | @@ -388,6 +396,61 @@ This Terraform stack is RDS-only. Existing stacks that still run ECS-hosted Post 5. Validate the public Guardian endpoints. 6. Confirm the old Postgres ECS and Cloud Map resources are gone from AWS before considering the cutover complete. +## GitHub Actions OIDC roles + +`oidc.tf` defines the two IAM roles behind the **AWS Deploy** workflow +(`.github/workflows/aws-deploy.yml`): a bootstrap role in the organization's +root account that GitHub's OIDC provider may assume, and a deploy role in the +stack's account that the bootstrap role chains into. The trust policy admits +one subject claim per GitHub environment (`github_oidc_subjects`), so only jobs +running under those environments obtain the identity. + +The roles are shared by every Guardian stack in the account. Enable them on +exactly one stack — `guardian-prod` — and leave `github_oidc_enabled` at its +default `false` everywhere else, or the other stacks will try to create roles +with the same names. + +```bash +export TF_VAR_github_oidc_enabled=true +export TF_VAR_github_oidc_provider_arn="arn:aws:iam:::oidc-provider/token.actions.githubusercontent.com" +export TF_VAR_github_oidc_root_account_profile="" +``` + +`scripts/aws-deploy.sh` passes `TF_VAR_*` through to Terraform, so export +these before `plan`/`deploy` for the `guardian-prod` stack. The bootstrap role +is managed through the `aws.root_account` provider alias, which needs +root-account credentials while everything else (including the script's own +AWS CLI calls) runs as the stack account. Point it at a named profile that +resolves to the root account, or — if the stack credentials are allowed to +assume a root-account role — set `github_oidc_root_account_role_arn` instead. + +### Adopting the existing roles + +The roles already exist in AWS. Import them into the `guardian-prod` state +once, before the first apply with `github_oidc_enabled = true`, so the apply is +a no-op rather than a name collision. `import` requires every required +variable to be set but applies nothing, so `server_image_uri` can be a +placeholder here; the subsequent `plan` resolves the real image as usual: + +```bash +cd infra +STATE=terraform.guardian-prod.prod.tfstate +VARS=(-var "deployment_stage=prod" -var "stack_name=guardian-prod" -var "server_image_uri=import-placeholder") + +terraform import -state="$STATE" "${VARS[@]}" 'aws_iam_role.github_oidc[0]' \ + github-actions-solutions-account-guardian-oidc-role +terraform import -state="$STATE" "${VARS[@]}" 'aws_iam_role_policy.github_oidc_assume_deploy[0]' \ + github-actions-solutions-account-guardian-oidc-role:github-actions-solutions-guardian-assume-role-policy +terraform import -state="$STATE" "${VARS[@]}" 'aws_iam_role.github_deploy[0]' \ + GithubOIDCGuardianRole +terraform import -state="$STATE" "${VARS[@]}" 'aws_iam_role_policy_attachment.github_deploy_admin[0]' \ + GithubOIDCGuardianRole/arn:aws:iam::aws:policy/AdministratorAccess +``` + +Then run `DEPLOY_STAGE=prod STACK_NAME=guardian-prod ./scripts/aws-deploy.sh plan` +and confirm the only changes on the four resources are tags (`Project`, +`ManagedBy`) and any trust-policy edit you intend. + ## Storage encryption key Optional storage-at-rest encryption (see diff --git a/infra/oidc.tf b/infra/oidc.tf new file mode 100644 index 00000000..1359966c --- /dev/null +++ b/infra/oidc.tf @@ -0,0 +1,127 @@ +# GitHub Actions OIDC roles for the AWS Deploy workflow +# ====================================================== +# Two roles back .github/workflows/aws-deploy.yml: a bootstrap role in the +# organization's root account that GitHub's OIDC provider is trusted to assume, +# and a deploy role in this stack's account that the bootstrap role chains +# into. A workflow job only obtains the identity when it runs under one of the +# GitHub environments listed in github_oidc_subjects; those environments +# restrict deployments to main. +# +# The roles are shared by every Guardian stack in the account, so enable them +# on exactly one stack (guardian-prod) with github_oidc_enabled = true. Roles +# that already exist are adopted with terraform import; see infra/README.md. + +locals { + # Account that hosts the OIDC provider and the bootstrap role. Deriving the + # bootstrap role ARN from it (rather than from the resource) keeps the deploy + # role's trust policy computable at plan time even when the bootstrap role + # has pending changes. + github_oidc_root_account_id = var.github_oidc_enabled ? split(":", var.github_oidc_provider_arn)[4] : "" + github_oidc_role_arn = "arn:aws:iam::${local.github_oidc_root_account_id}:role/${var.github_oidc_role_name}" +} + +data "aws_iam_policy_document" "github_oidc_trust" { + count = var.github_oidc_enabled ? 1 : 0 + + statement { + actions = ["sts:AssumeRoleWithWebIdentity"] + + principals { + type = "Federated" + identifiers = [var.github_oidc_provider_arn] + } + + condition { + test = "StringEquals" + variable = "token.actions.githubusercontent.com:aud" + values = ["sts.amazonaws.com"] + } + + condition { + test = "StringLike" + variable = "token.actions.githubusercontent.com:sub" + values = var.github_oidc_subjects + } + } +} + +resource "aws_iam_role" "github_oidc" { + count = var.github_oidc_enabled ? 1 : 0 + provider = aws.root_account + + name = var.github_oidc_role_name + assume_role_policy = data.aws_iam_policy_document.github_oidc_trust[0].json + max_session_duration = 14400 + + tags = { + Environment = local.stage_name + Component = "Generic" + Subcomponent = "OIDC ROLE" + } + + lifecycle { + precondition { + condition = var.github_oidc_provider_arn != "" && (var.github_oidc_root_account_role_arn != "" || var.github_oidc_root_account_profile != "") + error_message = "github_oidc_enabled requires github_oidc_provider_arn and one of github_oidc_root_account_role_arn or github_oidc_root_account_profile." + } + } +} + +data "aws_iam_policy_document" "github_deploy_trust" { + count = var.github_oidc_enabled ? 1 : 0 + + statement { + actions = ["sts:AssumeRole", "sts:TagSession"] + + principals { + type = "AWS" + identifiers = [local.github_oidc_role_arn] + } + } +} + +resource "aws_iam_role" "github_deploy" { + count = var.github_oidc_enabled ? 1 : 0 + + name = var.github_deploy_role_name + assume_role_policy = data.aws_iam_policy_document.github_deploy_trust[0].json + max_session_duration = 14400 + + tags = { + Component = "Generic" + Subcomponent = "Github Actions Role" + } + + # IAM rejects a trust policy naming a principal role that does not exist yet. + depends_on = [aws_iam_role.github_oidc] +} + +# The deploy role currently carries AdministratorAccess, inherited from the +# original definition. The workflow only needs ECR push/pull, ECS describe/ +# register/update, and iam:PassRole on the stack task roles (see +# docs/SERVER_AWS_DEPLOY.md); narrowing it is a separate change. +resource "aws_iam_role_policy_attachment" "github_deploy_admin" { + count = var.github_oidc_enabled ? 1 : 0 + + role = aws_iam_role.github_deploy[0].name + policy_arn = "arn:aws:iam::aws:policy/AdministratorAccess" +} + +resource "aws_iam_role_policy" "github_oidc_assume_deploy" { + count = var.github_oidc_enabled ? 1 : 0 + provider = aws.root_account + + name = "github-actions-solutions-guardian-assume-role-policy" + role = aws_iam_role.github_oidc[0].id + + policy = jsonencode({ + Version = "2012-10-17" + Statement = [ + { + Effect = "Allow" + Action = ["sts:AssumeRole", "sts:TagSession"] + Resource = aws_iam_role.github_deploy[0].arn + } + ] + }) +} diff --git a/infra/outputs.tf b/infra/outputs.tf index 61703942..0a8a3d2f 100644 --- a/infra/outputs.tf +++ b/infra/outputs.tf @@ -312,3 +312,13 @@ output "server_log_errors_alarm_name" { description = "Name of the alarm on ERROR-level server log lines for this stack" value = var.cloudwatch_log_alarms_enabled ? aws_cloudwatch_metric_alarm.server_log_errors[0].alarm_name : "" } + +output "github_oidc_role_arn" { + description = "OIDC bootstrap role ARN for the AWS Deploy workflow (GitHub environment variable ROLE_FOR_OIDC)" + value = var.github_oidc_enabled ? aws_iam_role.github_oidc[0].arn : "" +} + +output "github_deploy_role_arn" { + description = "Deploy role ARN for the AWS Deploy workflow (GitHub environment variable ROLE_TO_ASSUME)" + value = var.github_oidc_enabled ? aws_iam_role.github_deploy[0].arn : "" +} diff --git a/infra/terraform.tfvars.example b/infra/terraform.tfvars.example index 06d0ccd8..57e9a93b 100644 --- a/infra/terraform.tfvars.example +++ b/infra/terraform.tfvars.example @@ -132,3 +132,14 @@ server_image_uri = "123456789012.dkr.ecr.us-east-1.amazonaws.com/guardian-server # server_task_family = "guardian-server" # server_container_name = "guardian-server" # server_log_group_name = "/ecs/guardian-server" + +# Optional: GitHub Actions OIDC roles for .github/workflows/aws-deploy.yml. +# Shared by every stack in the account; enable on exactly one stack. +# github_oidc_enabled = true +# github_oidc_provider_arn = "arn:aws:iam:::oidc-provider/token.actions.githubusercontent.com" +# github_oidc_root_account_profile = "" # or: +# github_oidc_root_account_role_arn = "arn:aws:iam:::role/Terraform" +# github_oidc_subjects = [ +# "repo:OpenZeppelin/guardian:environment:devnet", +# "repo:OpenZeppelin/guardian:environment:testnet", +# ] diff --git a/infra/variables.tf b/infra/variables.tf index 2aa6ec3a..74c5f1dd 100644 --- a/infra/variables.tf +++ b/infra/variables.tf @@ -798,3 +798,48 @@ variable "server_log_group_name" { type = string default = "" } + +variable "github_oidc_enabled" { + description = "Manage the GitHub Actions OIDC roles used by .github/workflows/aws-deploy.yml. The roles are shared by every stack in the account, so enable this on exactly one stack" + type = bool + default = false +} + +variable "github_oidc_provider_arn" { + description = "ARN of the GitHub Actions OIDC identity provider in the root account (arn:aws:iam:::oidc-provider/token.actions.githubusercontent.com). Required when github_oidc_enabled is true" + type = string + default = "" +} + +variable "github_oidc_root_account_role_arn" { + description = "Role assumed in the root account to manage the OIDC bootstrap role. When github_oidc_enabled is true, set this or github_oidc_root_account_profile" + type = string + default = "" +} + +variable "github_oidc_root_account_profile" { + description = "Named AWS CLI profile that already resolves to root-account credentials, used instead of github_oidc_root_account_role_arn when the stack credentials cannot assume a root-account role" + type = string + default = "" +} + +variable "github_oidc_role_name" { + description = "Name of the OIDC bootstrap role in the root account" + type = string + default = "github-actions-solutions-account-guardian-oidc-role" +} + +variable "github_deploy_role_name" { + description = "Name of the deploy role in this account that the bootstrap role chains into" + type = string + default = "GithubOIDCGuardianRole" +} + +variable "github_oidc_subjects" { + description = "GitHub OIDC subject claims allowed to assume the bootstrap role; one per GitHub environment of the AWS Deploy workflow" + type = list(string) + default = [ + "repo:OpenZeppelin/guardian:environment:devnet", + "repo:OpenZeppelin/guardian:environment:testnet", + ] +} diff --git a/infra/versions.tf b/infra/versions.tf index 2cd16bd0..5b33575b 100644 --- a/infra/versions.tf +++ b/infra/versions.tf @@ -33,6 +33,31 @@ provider "aws" { } } +# Root-account provider used only for the GitHub OIDC bootstrap role +# (infra/oidc.tf). It reaches the root account either through a named profile +# or by assuming a role from the caller's credentials; with neither set it +# falls back to the caller's own credentials and nothing references it. +provider "aws" { + alias = "root_account" + region = var.aws_region + profile = var.github_oidc_root_account_profile != "" ? var.github_oidc_root_account_profile : null + + dynamic "assume_role" { + for_each = var.github_oidc_root_account_role_arn != "" ? [1] : [] + + content { + role_arn = var.github_oidc_root_account_role_arn + } + } + + default_tags { + tags = { + Project = "guardian" + ManagedBy = "terraform" + } + } +} + provider "cloudflare" { api_token = var.cloudflare_api_token } From df8d8120c5f8977994946bd3d3fe3379cf649f5a Mon Sep 17 00:00:00 2001 From: Haseeb Rabbani Date: Wed, 16 Sep 2026 09:29:56 +0400 Subject: [PATCH 2/2] infra: scope the deploy role and harden the OIDC trust policy Address review: replace AdministratorAccess on the deploy role with an inline policy limited to the ECR repositories, ECS services, task definitions, and task roles of the stacks in github_deploy_stack_names; match OIDC subject claims with StringEquals; assert the root-account provider resolves to the account in github_oidc_provider_arn; document both root-account credential paths. --- docs/SERVER_AWS_DEPLOY.md | 13 ++-- infra/README.md | 25 +++++-- infra/oidc.tf | 120 ++++++++++++++++++++++++++++++--- infra/terraform.tfvars.example | 1 + infra/variables.tf | 8 ++- 5 files changed, 143 insertions(+), 24 deletions(-) diff --git a/docs/SERVER_AWS_DEPLOY.md b/docs/SERVER_AWS_DEPLOY.md index 2bafbad3..a8660b51 100644 --- a/docs/SERVER_AWS_DEPLOY.md +++ b/docs/SERVER_AWS_DEPLOY.md @@ -133,13 +133,12 @@ One-time setup per target (infra): `:testnet`) via `github_oidc_subjects`. Adding an environment means adding its claim there and applying. See the "GitHub Actions OIDC roles" section of `infra/README.md`. -- The deploy role needs ECR push/pull on `-server` - (`ecr:GetAuthorizationToken`, `ecr:DescribeRepositories`, - `ecr:BatchCheckLayerAvailability`, `ecr:BatchGetImage`, - `ecr:InitiateLayerUpload`, `ecr:UploadLayerPart`, `ecr:CompleteLayerUpload`, - `ecr:PutImage`), `ecs:DescribeServices`, `ecs:DescribeTaskDefinition`, - `ecs:RegisterTaskDefinition`, `ecs:UpdateService`, and `iam:PassRole` on the - stack's task and task-execution roles. +- The deploy role's permissions are managed in `infra/oidc.tf` too: ECR + push/pull on `-server`, `ecs:DescribeServices` / `ecs:UpdateService` + on `-server`, task-definition describe/register, and `iam:PassRole` + on the stack's task and task-execution roles, for every stack listed in + `github_deploy_stack_names`. Add a stack there before deploying it from the + workflow. - The ECR repository must already exist; `scripts/aws-deploy.sh build` creates it on a new stack. diff --git a/infra/README.md b/infra/README.md index d4ce7da4..c44d0659 100644 --- a/infra/README.md +++ b/infra/README.md @@ -244,10 +244,12 @@ aws ecr delete-repository --repository-name "$ECR_REPO_NAME" --force --region "$ | `server_image_uri` | (required) | ECR image URI for the server, preferably pinned to a digest | | `github_oidc_enabled` | `false` | Manage the GitHub Actions OIDC roles for the AWS Deploy workflow; enable on exactly one stack | | `github_oidc_provider_arn` | `""` | GitHub OIDC identity provider ARN in the root account (required when enabled) | -| `github_oidc_root_account_role_arn` | `""` | Role assumed in the root account to manage the bootstrap role (required when enabled) | +| `github_oidc_root_account_role_arn` | `""` | Role assumed from the stack credentials to manage the bootstrap role; required when enabled unless `github_oidc_root_account_profile` is set | +| `github_oidc_root_account_profile` | `""` | Named AWS CLI profile resolving to root-account credentials; alternative to `github_oidc_root_account_role_arn` when the stack credentials cannot assume a root-account role | | `github_oidc_role_name` | `github-actions-solutions-account-guardian-oidc-role` | Bootstrap role name | | `github_deploy_role_name` | `GithubOIDCGuardianRole` | Deploy role name | -| `github_oidc_subjects` | guardian `devnet`/`testnet` environments | OIDC subject claims allowed to assume the bootstrap role | +| `github_oidc_subjects` | guardian `devnet`/`testnet` environments | OIDC subject claims allowed to assume the bootstrap role (exact match) | +| `github_deploy_stack_names` | `["guardian", "guardian-prod"]` | Stacks the deploy role may roll out; scopes its ECR/ECS/PassRole permissions | | `guardian_operator_public_keys` | `[]` | Falcon public keys used to create a stack-scoped operator public keys secret | | `guardian_operator_public_keys_secret_arn` | `""` | Existing operator public keys secret ARN; takes precedence over the managed list | | `guardian_evm_allowed_chain_ids` | `""` | EVM chain IDs used to create a stack-scoped allowed chain IDs secret | @@ -408,7 +410,10 @@ running under those environments obtain the identity. The roles are shared by every Guardian stack in the account. Enable them on exactly one stack — `guardian-prod` — and leave `github_oidc_enabled` at its default `false` everywhere else, or the other stacks will try to create roles -with the same names. +with the same names. The deploy role's inline policy is scoped to the ECR +repositories, ECS services, task definitions, and task roles of the stacks in +`github_deploy_stack_names` (default resource naming), so add a stack there +before deploying it from the workflow. ```bash export TF_VAR_github_oidc_enabled=true @@ -443,13 +448,19 @@ terraform import -state="$STATE" "${VARS[@]}" 'aws_iam_role_policy.github_oidc_a github-actions-solutions-account-guardian-oidc-role:github-actions-solutions-guardian-assume-role-policy terraform import -state="$STATE" "${VARS[@]}" 'aws_iam_role.github_deploy[0]' \ GithubOIDCGuardianRole -terraform import -state="$STATE" "${VARS[@]}" 'aws_iam_role_policy_attachment.github_deploy_admin[0]' \ - GithubOIDCGuardianRole/arn:aws:iam::aws:policy/AdministratorAccess ``` Then run `DEPLOY_STAGE=prod STACK_NAME=guardian-prod ./scripts/aws-deploy.sh plan` -and confirm the only changes on the four resources are tags (`Project`, -`ManagedBy`) and any trust-policy edit you intend. +and confirm the only changes on the imported roles are tags (`Project`, +`ManagedBy`), any trust-policy edit you intend, and the new scoped inline +policy. A role that previously carried a managed policy (the original +definition used `AdministratorAccess`) keeps it until you detach it by hand; +Terraform does not manage attachments it did not create: + +```bash +aws iam detach-role-policy --role-name GithubOIDCGuardianRole \ + --policy-arn arn:aws:iam::aws:policy/AdministratorAccess +``` ## Storage encryption key diff --git a/infra/oidc.tf b/infra/oidc.tf index 1359966c..6ec1e669 100644 --- a/infra/oidc.tf +++ b/infra/oidc.tf @@ -8,7 +8,8 @@ # restrict deployments to main. # # The roles are shared by every Guardian stack in the account, so enable them -# on exactly one stack (guardian-prod) with github_oidc_enabled = true. Roles +# on exactly one stack (guardian-prod) with github_oidc_enabled = true and list +# every stack the workflow may roll out in github_deploy_stack_names. Roles # that already exist are adopted with terraform import; see infra/README.md. locals { @@ -18,6 +19,37 @@ locals { # has pending changes. github_oidc_root_account_id = var.github_oidc_enabled ? split(":", var.github_oidc_provider_arn)[4] : "" github_oidc_role_arn = "arn:aws:iam::${local.github_oidc_root_account_id}:role/${var.github_oidc_role_name}" + + # Resources the deploy role may touch, derived from the default stack naming + # in data.tf for each stack in github_deploy_stack_names. + github_deploy_account_id = data.aws_caller_identity.current.account_id + github_deploy_ecr_repository_arns = [ + for stack in var.github_deploy_stack_names : + "arn:aws:ecr:${var.aws_region}:${local.github_deploy_account_id}:repository/${stack}-server" + ] + github_deploy_ecs_service_arns = [ + for stack in var.github_deploy_stack_names : + "arn:aws:ecs:${var.aws_region}:${local.github_deploy_account_id}:service/${stack}-cluster/${stack}-server" + ] + github_deploy_task_definition_arns = [ + for stack in var.github_deploy_stack_names : + "arn:aws:ecs:${var.aws_region}:${local.github_deploy_account_id}:task-definition/${stack}-server:*" + ] + github_deploy_task_role_arns = flatten([ + for stack in var.github_deploy_stack_names : [ + "arn:aws:iam::${local.github_deploy_account_id}:role/${stack}-ecs-task-execution", + "arn:aws:iam::${local.github_deploy_account_id}:role/${stack}-ecs-task", + ] + ]) +} + +# Guards against root-account credentials that belong to a different account +# than the one encoded in github_oidc_provider_arn; the bootstrap role would +# otherwise be created in one account while the deploy role trusts a role ARN +# in another. +data "aws_caller_identity" "github_oidc_root" { + count = var.github_oidc_enabled ? 1 : 0 + provider = aws.root_account } data "aws_iam_policy_document" "github_oidc_trust" { @@ -37,8 +69,9 @@ data "aws_iam_policy_document" "github_oidc_trust" { values = ["sts.amazonaws.com"] } + # Exact match: the subjects are full environment claims, never patterns. condition { - test = "StringLike" + test = "StringEquals" variable = "token.actions.githubusercontent.com:sub" values = var.github_oidc_subjects } @@ -64,6 +97,11 @@ resource "aws_iam_role" "github_oidc" { condition = var.github_oidc_provider_arn != "" && (var.github_oidc_root_account_role_arn != "" || var.github_oidc_root_account_profile != "") error_message = "github_oidc_enabled requires github_oidc_provider_arn and one of github_oidc_root_account_role_arn or github_oidc_root_account_profile." } + + precondition { + condition = data.aws_caller_identity.github_oidc_root[0].account_id == local.github_oidc_root_account_id + error_message = "The aws.root_account provider resolves to account ${data.aws_caller_identity.github_oidc_root[0].account_id}, but github_oidc_provider_arn belongs to account ${local.github_oidc_root_account_id}." + } } } @@ -96,15 +134,79 @@ resource "aws_iam_role" "github_deploy" { depends_on = [aws_iam_role.github_oidc] } -# The deploy role currently carries AdministratorAccess, inherited from the -# original definition. The workflow only needs ECR push/pull, ECS describe/ -# register/update, and iam:PassRole on the stack task roles (see -# docs/SERVER_AWS_DEPLOY.md); narrowing it is a separate change. -resource "aws_iam_role_policy_attachment" "github_deploy_admin" { +# Least-privilege policy for what the AWS Deploy workflow actually does: log in +# to ECR, mirror an image into the stack repository, read the service and its +# task definition, register a new revision, and update the service. +data "aws_iam_policy_document" "github_deploy" { + count = var.github_oidc_enabled ? 1 : 0 + + statement { + sid = "EcrLogin" + actions = ["ecr:GetAuthorizationToken"] + resources = ["*"] + } + + statement { + sid = "EcrMirrorImage" + actions = [ + "ecr:DescribeRepositories", + "ecr:DescribeImages", + "ecr:ListImages", + "ecr:BatchGetImage", + "ecr:GetDownloadUrlForLayer", + "ecr:BatchCheckLayerAvailability", + "ecr:InitiateLayerUpload", + "ecr:UploadLayerPart", + "ecr:CompleteLayerUpload", + "ecr:PutImage", + ] + resources = local.github_deploy_ecr_repository_arns + } + + statement { + sid = "EcsService" + actions = [ + "ecs:DescribeServices", + "ecs:UpdateService", + ] + resources = local.github_deploy_ecs_service_arns + } + + # Describe/RegisterTaskDefinition do not support resource-level permissions. + statement { + sid = "EcsTaskDefinition" + actions = [ + "ecs:DescribeTaskDefinition", + "ecs:RegisterTaskDefinition", + ] + resources = ["*"] + } + + statement { + sid = "EcsTagTaskDefinition" + actions = ["ecs:TagResource"] + resources = local.github_deploy_task_definition_arns + } + + statement { + sid = "PassTaskRoles" + actions = ["iam:PassRole"] + resources = local.github_deploy_task_role_arns + + condition { + test = "StringEquals" + variable = "iam:PassedToService" + values = ["ecs-tasks.amazonaws.com"] + } + } +} + +resource "aws_iam_role_policy" "github_deploy" { count = var.github_oidc_enabled ? 1 : 0 - role = aws_iam_role.github_deploy[0].name - policy_arn = "arn:aws:iam::aws:policy/AdministratorAccess" + name = "guardian-aws-deploy" + role = aws_iam_role.github_deploy[0].id + policy = data.aws_iam_policy_document.github_deploy[0].json } resource "aws_iam_role_policy" "github_oidc_assume_deploy" { diff --git a/infra/terraform.tfvars.example b/infra/terraform.tfvars.example index 57e9a93b..c5c5603d 100644 --- a/infra/terraform.tfvars.example +++ b/infra/terraform.tfvars.example @@ -143,3 +143,4 @@ server_image_uri = "123456789012.dkr.ecr.us-east-1.amazonaws.com/guardian-server # "repo:OpenZeppelin/guardian:environment:devnet", # "repo:OpenZeppelin/guardian:environment:testnet", # ] +# github_deploy_stack_names = ["guardian", "guardian-prod"] diff --git a/infra/variables.tf b/infra/variables.tf index 74c5f1dd..94956381 100644 --- a/infra/variables.tf +++ b/infra/variables.tf @@ -836,10 +836,16 @@ variable "github_deploy_role_name" { } variable "github_oidc_subjects" { - description = "GitHub OIDC subject claims allowed to assume the bootstrap role; one per GitHub environment of the AWS Deploy workflow" + description = "GitHub OIDC subject claims allowed to assume the bootstrap role, matched exactly; one per GitHub environment of the AWS Deploy workflow" type = list(string) default = [ "repo:OpenZeppelin/guardian:environment:devnet", "repo:OpenZeppelin/guardian:environment:testnet", ] } + +variable "github_deploy_stack_names" { + description = "Stacks (stack_name values) the AWS Deploy workflow may roll out; scopes the deploy role to their ECR repositories, ECS services, task definitions, and task roles under the default resource naming" + type = list(string) + default = ["guardian", "guardian-prod"] +}