From 4561d834b62f35d5180554fcffec8ba515638275 Mon Sep 17 00:00:00 2001 From: Alexander Amiri Date: Thu, 12 Mar 2026 15:41:18 +0100 Subject: [PATCH 1/3] Harden CI roles: split infra plan/apply, fix registry security IAM: - New javabin-ci-infra-plan role: ReadOnlyAccess + scoped writes for state locking, plan upload, Bedrock review, SSM read. Used by plan job. - Restrict javabin-ci-infra trust to refs/heads/main only (not PRs). Previously any PR could assume the full *:* infra role during plan. - Add SSM read (Google SA creds) to javabin-ci-registry role for hero sync. Platform CI: - Plan job now uses javabin-ci-infra-plan (read-only). - Apply job keeps javabin-ci-infra (write, main-only). Registry repo (via gh CLI): - Squash merge only, auto-merge enabled. - Required status check: "Validate Registration". --- .github/workflows/platform-ci.yml | 4 +- terraform/platform/iam/main.tf | 134 +++++++++++++++++++++++++++--- 2 files changed, 125 insertions(+), 13 deletions(-) diff --git a/.github/workflows/platform-ci.yml b/.github/workflows/platform-ci.yml index 82872a8..51ffe91 100644 --- a/.github/workflows/platform-ci.yml +++ b/.github/workflows/platform-ci.yml @@ -57,9 +57,9 @@ jobs: - name: Configure AWS credentials via OIDC uses: aws-actions/configure-aws-credentials@v5 with: - role-to-assume: arn:aws:iam::${{ env.AWS_ACCOUNT_ID }}:role/javabin-ci-infra + role-to-assume: arn:aws:iam::${{ env.AWS_ACCOUNT_ID }}:role/javabin-ci-infra-plan aws-region: ${{ env.AWS_REGION }} - role-session-name: javabin-platform-${{ github.run_id }} + role-session-name: javabin-platform-plan-${{ github.run_id }} - name: Terraform Init working-directory: ${{ env.TF_ROOT }} diff --git a/terraform/platform/iam/main.tf b/terraform/platform/iam/main.tf index 9a2b0e1..1d7f6b2 100644 --- a/terraform/platform/iam/main.tf +++ b/terraform/platform/iam/main.tf @@ -20,9 +20,109 @@ data "aws_iam_openid_connect_provider" "github" { ################################################################################ ################################################################################ -# 1. javabin-ci-infra — Platform repo only (plan + apply) +# 1a. javabin-ci-infra-plan — Platform repo plan + review (read-only) # -# Trust: GitHub OIDC, only repo:javaBin/platform on main branch. +# Trust: GitHub OIDC, repo:javaBin/platform on ANY ref (PRs need plan too). +# Permissions: ReadOnlyAccess + specific writes for plan operations: +# - S3: upload plan artifacts +# - DynamoDB: Terraform state locking +# - Bedrock: LLM plan review (inline in plan job) +# - SSM: read Slack webhook for HIGH risk alerts +# +# This role CANNOT create, modify, or delete infrastructure. +################################################################################ + +resource "aws_iam_role" "ci_infra_plan" { + name = "${var.project}-ci-infra-plan" + permissions_boundary = aws_iam_policy.developer_boundary.arn + + assume_role_policy = jsonencode({ + Version = "2012-10-17" + Statement = [ + { + Effect = "Allow" + Principal = { + Federated = data.aws_iam_openid_connect_provider.github.arn + } + Action = "sts:AssumeRoleWithWebIdentity" + Condition = { + StringEquals = { + "token.actions.githubusercontent.com:aud" = "sts.amazonaws.com" + } + StringLike = { + # Any ref — PRs and main both need plan + "token.actions.githubusercontent.com:sub" = "repo:${var.github_org}/platform:*" + } + } + } + ] + }) + + tags = { + Name = "${var.project}-ci-infra-plan" + } +} + +resource "aws_iam_role_policy_attachment" "ci_infra_plan_readonly" { + role = aws_iam_role.ci_infra_plan.name + policy_arn = "arn:aws:iam::aws:policy/ReadOnlyAccess" +} + +resource "aws_iam_role_policy" "ci_infra_plan_extras" { + name = "plan-specific-writes" + role = aws_iam_role.ci_infra_plan.id + + policy = jsonencode({ + Version = "2012-10-17" + Statement = [ + { + Sid = "TerraformStateLocking" + Effect = "Allow" + Action = [ + "dynamodb:GetItem", + "dynamodb:PutItem", + "dynamodb:DeleteItem", + ] + Resource = "arn:aws:dynamodb:${var.region}:${var.aws_account_id}:table/${var.project}-terraform-infra-lock" + }, + { + Sid = "UploadPlanArtifacts" + Effect = "Allow" + Action = [ + "s3:PutObject", + ] + Resource = "arn:aws:s3:::${var.project}-ci-plan-artifacts-${var.aws_account_id}/*" + }, + { + Sid = "BedrockForReview" + Effect = "Allow" + Action = [ + "bedrock:InvokeModel", + "bedrock:Converse", + ] + Resource = "arn:aws:bedrock:${var.region}:${var.aws_account_id}:inference-profile/eu.anthropic.*" + }, + { + Sid = "SSMReadSlackWebhook" + Effect = "Allow" + Action = "ssm:GetParameter" + Resource = "arn:aws:ssm:${var.region}:${var.aws_account_id}:parameter/${var.project}/slack/*" + }, + { + # Pricing API for cost estimation during review + Sid = "PricingRead" + Effect = "Allow" + Action = "pricing:GetProducts" + Resource = "*" + }, + ] + }) +} + +################################################################################ +# 1b. javabin-ci-infra — Platform repo apply only +# +# Trust: GitHub OIDC, ONLY main branch (not PRs). # Permissions: Allow *:* with permission boundary + explicit deny on operations # that should never happen via CI (organizations, IAM users, destructive security ops). ################################################################################ @@ -45,8 +145,8 @@ resource "aws_iam_role" "ci_infra" { "token.actions.githubusercontent.com:aud" = "sts.amazonaws.com" } StringLike = { - # Allow main branch pushes and PR merge refs (plan runs on PRs too) - "token.actions.githubusercontent.com:sub" = "repo:${var.github_org}/platform:*" + # Main branch ONLY — apply never runs on PRs + "token.actions.githubusercontent.com:sub" = "repo:${var.github_org}/platform:ref:refs/heads/main" } } } @@ -569,17 +669,29 @@ resource "aws_iam_role" "ci_registry" { } } -resource "aws_iam_role_policy" "ci_registry_lambda" { - name = "invoke-team-provisioner" +resource "aws_iam_role_policy" "ci_registry" { + name = "registry-operations" role = aws_iam_role.ci_registry.id policy = jsonencode({ Version = "2012-10-17" - Statement = [{ - Effect = "Allow" - Action = "lambda:InvokeFunction" - Resource = "arn:aws:lambda:${var.region}:${var.aws_account_id}:function:${var.project}-team-provisioner" - }] + Statement = [ + { + Sid = "InvokeTeamProvisioner" + Effect = "Allow" + Action = "lambda:InvokeFunction" + Resource = "arn:aws:lambda:${var.region}:${var.aws_account_id}:function:${var.project}-team-provisioner" + }, + { + Sid = "SSMReadGoogleCreds" + Effect = "Allow" + Action = "ssm:GetParameter" + Resource = [ + "arn:aws:ssm:${var.region}:${var.aws_account_id}:parameter/${var.project}/platform/google-admin-sa", + "arn:aws:ssm:${var.region}:${var.aws_account_id}:parameter/${var.project}/platform/google-admin-email", + ] + }, + ] }) } From 8c8784eebfe7dd8ecf1391ccead9a63bfbe6bdb6 Mon Sep 17 00:00:00 2001 From: Alexander Amiri Date: Thu, 12 Mar 2026 15:47:48 +0100 Subject: [PATCH 2/3] Add import block for pre-created ci-infra-plan role The role was created via CLI to bootstrap the plan job (chicken-and-egg: the plan job needs the role, but the role is created by the plan). Terraform will import it on first apply. Remove imports.tf after. --- terraform/platform/iam/imports.tf | 6 ++++++ 1 file changed, 6 insertions(+) create mode 100644 terraform/platform/iam/imports.tf diff --git a/terraform/platform/iam/imports.tf b/terraform/platform/iam/imports.tf new file mode 100644 index 0000000..b011275 --- /dev/null +++ b/terraform/platform/iam/imports.tf @@ -0,0 +1,6 @@ +# One-time import: ci-infra-plan role was pre-created via CLI to bootstrap +# the plan job. Remove this file after the first successful apply. +import { + to = aws_iam_role.ci_infra_plan + id = "javabin-ci-infra-plan" +} From 346a0ad6225b9328fe21c11ea970b890ada5905d Mon Sep 17 00:00:00 2001 From: Alexander Amiri Date: Thu, 12 Mar 2026 15:57:07 +0100 Subject: [PATCH 3/3] =?UTF-8?q?Move=20imports.tf=20to=20root=20module=20?= =?UTF-8?q?=E2=80=94=20import=20blocks=20not=20allowed=20in=20submodules?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- terraform/platform/{iam => }/imports.tf | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) rename terraform/platform/{iam => }/imports.tf (80%) diff --git a/terraform/platform/iam/imports.tf b/terraform/platform/imports.tf similarity index 80% rename from terraform/platform/iam/imports.tf rename to terraform/platform/imports.tf index b011275..d3515b5 100644 --- a/terraform/platform/iam/imports.tf +++ b/terraform/platform/imports.tf @@ -1,6 +1,6 @@ # One-time import: ci-infra-plan role was pre-created via CLI to bootstrap # the plan job. Remove this file after the first successful apply. import { - to = aws_iam_role.ci_infra_plan + to = module.iam.aws_iam_role.ci_infra_plan id = "javabin-ci-infra-plan" }