diff --git a/.github/workflows/docker-build.yml b/.github/workflows/docker-build.yml index bc64382..224606f 100644 --- a/.github/workflows/docker-build.yml +++ b/.github/workflows/docker-build.yml @@ -41,22 +41,34 @@ jobs: steps: - uses: actions/checkout@v6 - - name: Resolve team from GitHub - id: team - env: - GH_TOKEN: ${{ github.token }} - run: | - TEAM=$(gh api "/repos/${{ github.repository }}/teams" \ - --jq '[.[] | select(.slug != "platform-owners")] | .[0].slug // empty' 2>/dev/null || true) - if [ -z "$TEAM" ]; then echo "ERROR: repo not in a team"; exit 1; fi - echo "team=$TEAM" >> "$GITHUB_OUTPUT" + - name: Generate GitHub App token + id: app-token + uses: actions/create-github-app-token@v2 + with: + app-id: ${{ secrets.PLATFORM_APP_ID }} + private-key: ${{ secrets.PLATFORM_APP_PRIVATE_KEY }} + owner: javaBin - - name: Configure AWS credentials via OIDC + - name: Checkout platform scripts + uses: actions/checkout@v6 + with: + repository: javaBin/platform + token: ${{ steps.app-token.outputs.token }} + path: .platform + sparse-checkout: scripts + + - name: Assume broker role via OIDC uses: aws-actions/configure-aws-credentials@v6 with: - role-to-assume: arn:aws:iam::${{ inputs.aws_account_id }}:role/javabin-ci-deploy-${{ steps.team.outputs.team }} + role-to-assume: arn:aws:iam::${{ inputs.aws_account_id }}:role/javabin-ci-app-broker aws-region: ${{ inputs.aws_region }} + - name: Get deploy credentials from broker + id: broker + env: + PROJECT: javabin + run: sh .platform/scripts/invoke-ci-broker.sh deploy + - name: Login to ECR id: ecr uses: aws-actions/amazon-ecr-login@v2 diff --git a/.github/workflows/ecs-deploy.yml b/.github/workflows/ecs-deploy.yml index 8f7f694..0c81a30 100644 --- a/.github/workflows/ecs-deploy.yml +++ b/.github/workflows/ecs-deploy.yml @@ -50,22 +50,18 @@ jobs: sparse-checkout: scripts path: .platform - - name: Resolve team from GitHub - id: team - env: - GH_TOKEN: ${{ github.token }} - run: | - TEAM=$(gh api "/repos/${{ github.repository }}/teams" \ - --jq '[.[] | select(.slug != "platform-owners")] | .[0].slug // empty' 2>/dev/null || true) - if [ -z "$TEAM" ]; then echo "ERROR: repo not in a team"; exit 1; fi - echo "team=$TEAM" >> "$GITHUB_OUTPUT" - - - name: Configure AWS credentials via OIDC + - name: Assume broker role via OIDC uses: aws-actions/configure-aws-credentials@v6 with: - role-to-assume: arn:aws:iam::${{ inputs.aws_account_id }}:role/javabin-ci-deploy-${{ steps.team.outputs.team }} + role-to-assume: arn:aws:iam::${{ inputs.aws_account_id }}:role/javabin-ci-app-broker aws-region: ${{ inputs.aws_region }} + - name: Get deploy credentials from broker + id: broker + env: + PROJECT: javabin + run: sh .platform/scripts/invoke-ci-broker.sh deploy + - name: Deploy to ECS env: SERVICE: ${{ inputs.service_name || github.event.repository.name }} diff --git a/.github/workflows/tf-plan.yml b/.github/workflows/tf-plan.yml index ce6f053..004a669 100644 --- a/.github/workflows/tf-plan.yml +++ b/.github/workflows/tf-plan.yml @@ -55,27 +55,18 @@ jobs: terraform_version: "1.7" terraform_wrapper: false - - name: Resolve team from GitHub - id: team - env: - GH_TOKEN: ${{ github.token }} - run: | - TEAM=$(gh api "/repos/${{ github.repository }}/teams" \ - --jq '[.[] | select(.slug != "platform-owners")] | .[0].slug // empty' 2>/dev/null || true) - if [ -z "$TEAM" ]; then - echo "ERROR: repo does not belong to any GitHub team" - echo "Add it at https://github.com/orgs/javaBin/teams" - exit 1 - fi - echo "Resolved team: $TEAM" - echo "team=$TEAM" >> "$GITHUB_OUTPUT" - - - name: Configure AWS credentials via OIDC + - name: Assume broker role via OIDC uses: aws-actions/configure-aws-credentials@v6 with: - role-to-assume: arn:aws:iam::${{ inputs.aws_account_id }}:role/javabin-ci-team-${{ steps.team.outputs.team }} + role-to-assume: arn:aws:iam::${{ inputs.aws_account_id }}:role/javabin-ci-app-broker aws-region: ${{ inputs.aws_region }} + - name: Get team credentials from broker + id: broker + env: + PROJECT: javabin + run: sh .platform/scripts/invoke-ci-broker.sh plan + - name: Generate GitHub App token id: app-token uses: actions/create-github-app-token@v2 diff --git a/scripts/ensure-tf-boilerplate.sh b/scripts/ensure-tf-boilerplate.sh index 8a4ac64..6b5e7d2 100755 --- a/scripts/ensure-tf-boilerplate.sh +++ b/scripts/ensure-tf-boilerplate.sh @@ -37,7 +37,7 @@ if [ ! -f "$TF_ROOT/backend.tf" ]; then terraform { backend "s3" { bucket = "${PROJECT}-terraform-state-${ACCOUNT_ID}" - key = "apps/${REPO_NAME}/terraform.tfstate" + key = "apps/${TEAM}/${REPO_NAME}/terraform.tfstate" region = "${REGION}" dynamodb_table = "${PROJECT}-terraform-app-locks" encrypt = true diff --git a/scripts/expand-modules.py b/scripts/expand-modules.py index a9550de..763a5aa 100644 --- a/scripts/expand-modules.py +++ b/scripts/expand-modules.py @@ -664,7 +664,7 @@ def main(): os.path.join(tf_root, "backend.tf"), BACKEND_TEMPLATE.format( project=PROJECT, account_id=account_id, - service=service, region=region, + service=service, team=app_team, region=region, ), ) write_file( diff --git a/scripts/extract-review-risk.sh b/scripts/extract-review-risk.sh index e4d955d..3731a8a 100644 --- a/scripts/extract-review-risk.sh +++ b/scripts/extract-review-risk.sh @@ -24,6 +24,5 @@ echo "risk_level=${RISK}" >> "$GITHUB_OUTPUT" echo "LLM review risk: ${RISK}" if [ "$RISK" = "FAILED" ]; then - echo "LLM review failed — blocking pipeline." - exit 1 + echo "WARNING: LLM review failed — treated as HIGH risk (override available)." fi diff --git a/scripts/invoke-ci-broker.sh b/scripts/invoke-ci-broker.sh new file mode 100755 index 0000000..048aa6f --- /dev/null +++ b/scripts/invoke-ci-broker.sh @@ -0,0 +1,49 @@ +#!/bin/sh +# Invoke the CI broker Lambda to get team-scoped credentials. +# +# Usage: invoke-ci-broker.sh +# action: "plan" or "deploy" +# +# Env: GITHUB_REPOSITORY (org/repo) +# Output: Exports AWS_ACCESS_KEY_ID, AWS_SECRET_ACCESS_KEY, AWS_SESSION_TOKEN +# Sets team={slug} in GITHUB_OUTPUT + +set -e + +ACTION="${1:?Usage: invoke-ci-broker.sh }" +REPO="${GITHUB_REPOSITORY##*/}" + +echo "Requesting ${ACTION} credentials for ${REPO}..." + +RESPONSE=$(aws lambda invoke \ + --function-name "${PROJECT:-javabin}-ci-broker" \ + --payload "$(printf '{"repo":"%s","action":"%s"}' "$REPO" "$ACTION")" \ + --cli-binary-format raw-in-base64-out \ + /tmp/broker-response.json 2>&1) + +RESULT=$(cat /tmp/broker-response.json) +APPROVED=$(echo "$RESULT" | jq -r '.approved // false') + +if [ "$APPROVED" != "true" ]; then + ERROR=$(echo "$RESULT" | jq -r '.error // "Unknown error"') + echo "ERROR: CI broker rejected request: $ERROR" + exit 1 +fi + +TEAM=$(echo "$RESULT" | jq -r '.team') +echo "Team: $TEAM" +echo "team=${TEAM}" >> "${GITHUB_OUTPUT:-/dev/null}" + +# Export credentials for subsequent steps +{ + echo "AWS_ACCESS_KEY_ID=$(echo "$RESULT" | jq -r '.credentials.AccessKeyId')" + echo "AWS_SECRET_ACCESS_KEY=$(echo "$RESULT" | jq -r '.credentials.SecretAccessKey')" + echo "AWS_SESSION_TOKEN=$(echo "$RESULT" | jq -r '.credentials.SessionToken')" +} >> "${GITHUB_ENV:-/dev/null}" + +# Mask credentials in logs +echo "::add-mask::$(echo "$RESULT" | jq -r '.credentials.AccessKeyId')" +echo "::add-mask::$(echo "$RESULT" | jq -r '.credentials.SecretAccessKey')" +echo "::add-mask::$(echo "$RESULT" | jq -r '.credentials.SessionToken')" + +echo "Credentials issued for team=${TEAM} action=${ACTION}" diff --git a/scripts/registry.py b/scripts/registry.py index b69eb64..a0267f4 100644 --- a/scripts/registry.py +++ b/scripts/registry.py @@ -352,7 +352,7 @@ terraform {{ backend "s3" {{ bucket = "{project}-terraform-state-{account_id}" - key = "apps/{service}/terraform.tfstate" + key = "apps/{team}/{service}/terraform.tfstate" region = "{region}" dynamodb_table = "{project}-terraform-app-locks" encrypt = true diff --git a/terraform/lambda-src/ci_broker/handler.py b/terraform/lambda-src/ci_broker/handler.py new file mode 100644 index 0000000..e8a4a2d --- /dev/null +++ b/terraform/lambda-src/ci_broker/handler.py @@ -0,0 +1,214 @@ +"""CI credential broker — validates repo→team membership via GitHub API +and returns temporary STS credentials for the team-scoped IAM role. + +This Lambda is the enforcement point for team-based access control. +CI workflows assume a lightweight broker role (can only invoke this Lambda), +then receive team-scoped credentials if the repo belongs to a registered team. + +Actions: + - plan: Returns credentials for javabin-ci-team-{team} (Terraform plan/apply) + - deploy: Returns credentials for javabin-ci-deploy-{team} (Docker build/ECS deploy) +""" + +import json +import logging +import os +import time +import urllib.request + +import boto3 + +logger = logging.getLogger(__name__) +logger.setLevel(logging.INFO) + +ssm = boto3.client("ssm") +sts = boto3.client("sts") + +ACCOUNT_ID = os.environ.get("AWS_ACCOUNT_ID", "") +PROJECT = os.environ.get("PROJECT", "javabin") +GITHUB_ORG = os.environ.get("GITHUB_ORG", "javaBin") +GITHUB_APP_ID_PARAM = os.environ.get("GITHUB_APP_ID_PARAM", "/javabin/platform/github-app-id") +GITHUB_APP_KEY_PARAM = os.environ.get("GITHUB_APP_KEY_PARAM", "/javabin/platform/github-app-key") + +# Cache GitHub App token across invocations (valid for 1 hour) +_token_cache = {"token": None, "expires_at": 0} +_ssm_cache = {} + +PLAN_DURATION = 3600 # 1 hour for plan +DEPLOY_DURATION = 900 # 15 minutes for deploy + +ROLE_MAP = { + "plan": f"{PROJECT}-ci-team-", + "deploy": f"{PROJECT}-ci-deploy-", +} + +EXCLUDED_TEAMS = {"platform-owners"} + + +def _get_ssm(param_name): + if param_name not in _ssm_cache: + resp = ssm.get_parameter(Name=param_name, WithDecryption=True) + _ssm_cache[param_name] = resp["Parameter"]["Value"] + return _ssm_cache[param_name] + + +def _github_app_token(): + """Generate a GitHub App installation token (cached for 50 minutes).""" + now = time.time() + if _token_cache["token"] and _token_cache["expires_at"] > now: + return _token_cache["token"] + + import subprocess + import tempfile + + app_id = _get_ssm(GITHUB_APP_ID_PARAM) + private_key = _get_ssm(GITHUB_APP_KEY_PARAM) + + # Build JWT (Header.Payload.Signature) + import base64 + import hashlib + import hmac + + header = base64.urlsafe_b64encode(json.dumps( + {"alg": "RS256", "typ": "JWT"}).encode()).rstrip(b"=").decode() + + iat = int(now) - 60 + exp = iat + 600 + payload = base64.urlsafe_b64encode(json.dumps( + {"iss": app_id, "iat": iat, "exp": exp}).encode()).rstrip(b"=").decode() + + signing_input = f"{header}.{payload}" + + # Sign with RS256 using openssl (available in Lambda runtime) + with tempfile.NamedTemporaryFile(mode="w", suffix=".pem", delete=False) as f: + f.write(private_key) + key_file = f.name + + result = subprocess.run( + ["openssl", "dgst", "-sha256", "-sign", key_file], + input=signing_input.encode(), + capture_output=True, + ) + os.unlink(key_file) + + if result.returncode != 0: + raise RuntimeError(f"JWT signing failed: {result.stderr.decode()}") + + signature = base64.urlsafe_b64encode(result.stdout).rstrip(b"=").decode() + jwt_token = f"{signing_input}.{signature}" + + # Exchange JWT for installation token + # First, find the installation ID for the org + req = urllib.request.Request( + f"https://api.github.com/app/installations", + headers={ + "Authorization": f"Bearer {jwt_token}", + "Accept": "application/vnd.github+json", + }, + ) + with urllib.request.urlopen(req) as resp: + installations = json.loads(resp.read()) + + install_id = None + for inst in installations: + if inst.get("account", {}).get("login") == GITHUB_ORG: + install_id = inst["id"] + break + + if not install_id: + raise RuntimeError(f"No GitHub App installation found for {GITHUB_ORG}") + + req = urllib.request.Request( + f"https://api.github.com/app/installations/{install_id}/access_tokens", + method="POST", + headers={ + "Authorization": f"Bearer {jwt_token}", + "Accept": "application/vnd.github+json", + }, + ) + with urllib.request.urlopen(req) as resp: + token_resp = json.loads(resp.read()) + + _token_cache["token"] = token_resp["token"] + _token_cache["expires_at"] = now + 3000 # Cache for ~50 minutes + return _token_cache["token"] + + +def _resolve_team(repo_name): + """Resolve which team a repo belongs to via GitHub API.""" + token = _github_app_token() + req = urllib.request.Request( + f"https://api.github.com/repos/{GITHUB_ORG}/{repo_name}/teams", + headers={ + "Authorization": f"token {token}", + "Accept": "application/vnd.github+json", + }, + ) + try: + with urllib.request.urlopen(req) as resp: + teams = json.loads(resp.read()) + except urllib.error.HTTPError as e: + logger.error("GitHub API error for %s: %s", repo_name, e) + return None + + for team in teams: + if team["slug"] not in EXCLUDED_TEAMS: + return team["slug"] + return None + + +def _assume_role(role_arn, session_name, duration): + """Assume an IAM role and return temporary credentials.""" + resp = sts.assume_role( + RoleArn=role_arn, + RoleSessionName=session_name, + DurationSeconds=duration, + ) + creds = resp["Credentials"] + return { + "AccessKeyId": creds["AccessKeyId"], + "SecretAccessKey": creds["SecretAccessKey"], + "SessionToken": creds["SessionToken"], + } + + +def handler(event, context): + repo = event.get("repo", "") + action = event.get("action", "plan") + + if not repo: + return {"error": "repo is required", "approved": False} + + if action not in ROLE_MAP: + return {"error": f"Invalid action: {action}. Must be plan or deploy", "approved": False} + + # Resolve team from GitHub + team = _resolve_team(repo) + if not team: + logger.warning("Repo %s does not belong to any team", repo) + return { + "approved": False, + "error": f"Repo '{repo}' is not in any GitHub team. " + f"Add it at https://github.com/orgs/{GITHUB_ORG}/teams", + } + + # Assume the team-scoped role + role_prefix = ROLE_MAP[action] + role_arn = f"arn:aws:iam::{ACCOUNT_ID}:role/{role_prefix}{team}" + duration = PLAN_DURATION if action == "plan" else DEPLOY_DURATION + + try: + credentials = _assume_role(role_arn, f"ci-broker-{repo}", duration) + except Exception as e: + logger.error("Failed to assume %s: %s", role_arn, e) + return { + "approved": False, + "error": f"Failed to assume role for team '{team}': {e}", + } + + logger.info("Issued %s credentials for repo=%s team=%s role=%s", action, repo, team, role_arn) + return { + "approved": True, + "team": team, + "credentials": credentials, + } diff --git a/terraform/platform/iam/main.tf b/terraform/platform/iam/main.tf index 9872ff4..81aa642 100644 --- a/terraform/platform/iam/main.tf +++ b/terraform/platform/iam/main.tf @@ -256,15 +256,62 @@ resource "aws_iam_role_policy" "ci_infra_deny" { } ################################################################################ -# 2. javabin-ci-team-{team} — Per-team Terraform role (plan + apply) +# 1d. javabin-ci-app-broker — Lightweight OIDC role for invoking broker Lambda # -# Trust: GitHub OIDC with TWO conditions: -# - sub matches ANY repo in the org (team membership checked by workflow) -# - job_workflow_ref pins to the platform's tf-plan.yml on main +# Trust: GitHub OIDC, any org repo, pinned to platform workflow refs. +# Permissions: ONLY invoke the ci-broker Lambda. No AWS resource access. +# The broker Lambda validates team membership and returns scoped credentials. +################################################################################ + +resource "aws_iam_role" "ci_app_broker" { + name = "${var.project}-ci-app-broker" + 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 = { + "token.actions.githubusercontent.com:sub" = "repo:${var.github_org}/*:*" + "token.actions.githubusercontent.com:job_workflow_ref" = [ + "${var.github_org}/platform/.github/workflows/tf-plan.yml@refs/heads/main", + "${var.github_org}/platform/.github/workflows/docker-build.yml@refs/heads/main", + "${var.github_org}/platform/.github/workflows/ecs-deploy.yml@refs/heads/main", + ] + } + } + }] + }) +} + +resource "aws_iam_role_policy" "ci_app_broker" { + name = "invoke-ci-broker-only" + role = aws_iam_role.ci_app_broker.id + + policy = jsonencode({ + Version = "2012-10-17" + Statement = [{ + Effect = "Allow" + Action = "lambda:InvokeFunction" + Resource = var.ci_broker_function_arn + }] + }) +} + +################################################################################ +# 2. javabin-ci-team-{team} — Per-team Terraform role (plan + apply) # -# Security model: only our controlled workflows can assume these roles. -# The workflow resolves team from GitHub API and assumes the correct role. -# App repos cannot write their own workflows to assume this role. +# Trust: ONLY the CI broker Lambda and apply-gate Lambda can assume these roles. +# No direct OIDC trust — the broker validates repo→team membership via GitHub +# API before issuing credentials. This is the hard enforcement point. # # Permissions: Allow *:* with team-based tag isolation. Resources must be # tagged with the correct team name. @@ -280,24 +327,12 @@ resource "aws_iam_role" "ci_team" { Version = "2012-10-17" Statement = [ { - Sid = "AllowPlanViaOIDC" + Sid = "AllowViaCIBroker" 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 repo in the org — team membership enforced by the workflow - "token.actions.githubusercontent.com:sub" = "repo:${var.github_org}/*:*" - "token.actions.githubusercontent.com:job_workflow_ref" = [ - "${var.github_org}/platform/.github/workflows/tf-plan.yml@refs/heads/main", - ] - } + AWS = var.ci_broker_role_arn } + Action = "sts:AssumeRole" }, { Sid = "AllowApplyViaGateLambda" @@ -426,8 +461,8 @@ resource "aws_iam_role_policy" "ci_team_deny" { ################################################################################ # 3. javabin-ci-deploy-{team} — Per-team deploy role (build + push + deploy) # -# Trust: GitHub OIDC pinned to docker-build and ecs-deploy workflows on main. -# Any org repo can assume via our controlled workflows; team resolved at runtime. +# Trust: ONLY the CI broker Lambda can assume these roles. +# Same enforcement model as team roles — broker validates team membership. # Permissions: Scoped to ECR push, ECS update, CloudWatch logs, SSM read. ################################################################################ @@ -441,23 +476,12 @@ resource "aws_iam_role" "ci_deploy" { Version = "2012-10-17" Statement = [ { + Sid = "AllowViaCIBroker" 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 = { - "token.actions.githubusercontent.com:sub" = "repo:${var.github_org}/*:*" - "token.actions.githubusercontent.com:job_workflow_ref" = [ - "${var.github_org}/platform/.github/workflows/docker-build.yml@refs/heads/main", - "${var.github_org}/platform/.github/workflows/ecs-deploy.yml@refs/heads/main", - ] - } + AWS = var.ci_broker_role_arn } + Action = "sts:AssumeRole" } ] }) diff --git a/terraform/platform/iam/variables.tf b/terraform/platform/iam/variables.tf index 1c6797d..95c0ca0 100644 --- a/terraform/platform/iam/variables.tf +++ b/terraform/platform/iam/variables.tf @@ -26,6 +26,16 @@ variable "github_org" { } variable "apply_gate_role_arn" { - description = "ARN of the apply-gate Lambda role (for ci_app trust policy)" + description = "ARN of the apply-gate Lambda role (for team role trust policy)" + type = string +} + +variable "ci_broker_role_arn" { + description = "ARN of the CI broker Lambda role (for team/deploy role trust policy)" + type = string +} + +variable "ci_broker_function_arn" { + description = "ARN of the CI broker Lambda function (for OIDC broker role invoke permission)" type = string } diff --git a/terraform/platform/lambdas/main.tf b/terraform/platform/lambdas/main.tf index 3def898..973497c 100644 --- a/terraform/platform/lambdas/main.tf +++ b/terraform/platform/lambdas/main.tf @@ -721,14 +721,15 @@ resource "aws_lambda_function" "team_provisioner" { } resource "aws_lambda_function" "password_set" { - function_name = "${var.project}-password-set" - role = aws_iam_role.password_set.arn - handler = "handler.handler" - runtime = "python3.12" - timeout = 30 - memory_size = 128 - filename = data.archive_file.password_set.output_path - source_code_hash = data.archive_file.password_set.output_base64sha256 + function_name = "${var.project}-password-set" + role = aws_iam_role.password_set.arn + handler = "handler.handler" + runtime = "python3.12" + timeout = 30 + memory_size = 128 + reserved_concurrent_executions = 5 # Rate limiting — prevents brute force on password-set endpoint + filename = data.archive_file.password_set.output_path + source_code_hash = data.archive_file.password_set.output_base64sha256 environment { variables = { @@ -1051,10 +1052,10 @@ resource "aws_iam_role_policy" "apply_gate" { Resource = "arn:aws:s3:::${var.project}-ci-plan-artifacts-${var.aws_account_id}/*" }, { - Sid = "AssumeAppRoles" + Sid = "AssumeTeamRoles" Effect = "Allow" Action = "sts:AssumeRole" - Resource = "arn:aws:iam::${var.aws_account_id}:role/${var.project}-ci-app-*" + Resource = "arn:aws:iam::${var.aws_account_id}:role/${var.project}-ci-team-*" }, ] }) @@ -1085,3 +1086,83 @@ resource "aws_lambda_function" "apply_gate" { } } } + +################################################################################ +# CI Broker — validates repo→team via GitHub API, returns STS credentials +################################################################################ + +data "archive_file" "ci_broker" { + type = "zip" + output_path = "${path.module}/builds/ci_broker.zip" + output_file_mode = "0644" + source_dir = "${local.lambda_src_path}/ci_broker" +} + +resource "aws_iam_role" "ci_broker" { + name = "${var.project}-ci-broker" + permissions_boundary = "arn:aws:iam::${var.aws_account_id}:policy/${var.project}-developer-boundary" + + assume_role_policy = jsonencode({ + Version = "2012-10-17" + Statement = [{ + Effect = "Allow" + Principal = { Service = "lambda.amazonaws.com" } + Action = "sts:AssumeRole" + }] + }) +} + +resource "aws_iam_role_policy" "ci_broker" { + name = "${var.project}-ci-broker" + role = aws_iam_role.ci_broker.id + + policy = jsonencode({ + Version = "2012-10-17" + Statement = [ + { + Sid = "ReadGitHubAppCredentials" + Effect = "Allow" + Action = "ssm:GetParameter" + Resource = [ + "arn:aws:ssm:${var.region}:${var.aws_account_id}:parameter/${var.project}/platform/github-app-id", + "arn:aws:ssm:${var.region}:${var.aws_account_id}:parameter/${var.project}/platform/github-app-key", + ] + }, + { + Sid = "AssumeTeamAndDeployRoles" + Effect = "Allow" + Action = "sts:AssumeRole" + Resource = [ + "arn:aws:iam::${var.aws_account_id}:role/${var.project}-ci-team-*", + "arn:aws:iam::${var.aws_account_id}:role/${var.project}-ci-deploy-*", + ] + }, + ] + }) +} + +resource "aws_iam_role_policy_attachment" "ci_broker_logs" { + role = aws_iam_role.ci_broker.name + policy_arn = "arn:aws:iam::aws:policy/service-role/AWSLambdaBasicExecutionRole" +} + +resource "aws_lambda_function" "ci_broker" { + function_name = "${var.project}-ci-broker" + role = aws_iam_role.ci_broker.arn + handler = "handler.handler" + runtime = "python3.12" + timeout = 30 + memory_size = 128 + filename = data.archive_file.ci_broker.output_path + source_code_hash = data.archive_file.ci_broker.output_base64sha256 + + environment { + variables = { + AWS_ACCOUNT_ID = var.aws_account_id + PROJECT = var.project + GITHUB_ORG = "javaBin" + GITHUB_APP_ID_PARAM = "/${var.project}/platform/github-app-id" + GITHUB_APP_KEY_PARAM = "/${var.project}/platform/github-app-key" + } + } +} diff --git a/terraform/platform/lambdas/outputs.tf b/terraform/platform/lambdas/outputs.tf index 45aa9be..1c969b9 100644 --- a/terraform/platform/lambdas/outputs.tf +++ b/terraform/platform/lambdas/outputs.tf @@ -42,3 +42,13 @@ output "apply_gate_role_arn" { description = "ARN of the apply-gate Lambda IAM role" value = aws_iam_role.apply_gate.arn } + +output "ci_broker_role_arn" { + description = "ARN of the CI broker Lambda IAM role" + value = aws_iam_role.ci_broker.arn +} + +output "ci_broker_function_arn" { + description = "ARN of the CI broker Lambda function" + value = aws_lambda_function.ci_broker.arn +} diff --git a/terraform/platform/main.tf b/terraform/platform/main.tf index 231b360..5705f71 100644 --- a/terraform/platform/main.tf +++ b/terraform/platform/main.tf @@ -29,13 +29,15 @@ module "ingress" { } module "iam" { - source = "./iam" - project = var.project - region = var.region - aws_account_id = var.aws_account_id - registered_teams = var.registered_teams - github_org = var.github_org - apply_gate_role_arn = module.lambdas.apply_gate_role_arn + source = "./iam" + project = var.project + region = var.region + aws_account_id = var.aws_account_id + registered_teams = var.registered_teams + github_org = var.github_org + apply_gate_role_arn = module.lambdas.apply_gate_role_arn + ci_broker_role_arn = module.lambdas.ci_broker_role_arn + ci_broker_function_arn = module.lambdas.ci_broker_function_arn } module "compute" {