Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
14 changes: 10 additions & 4 deletions .github/workflows/docker-build.yml
Original file line number Diff line number Diff line change
Expand Up @@ -79,21 +79,27 @@ jobs:

- name: Determine image tags
id: tags
env:
TEAM: ${{ steps.broker.outputs.team }}
REPO_NAME: ${{ github.event.repository.name }}
REF_NAME: ${{ github.ref_name }}
REF: ${{ github.ref }}
REGISTRY: ${{ steps.ecr.outputs.registry }}
run: |
REPO="${{ steps.ecr.outputs.registry }}/${{ github.event.repository.name }}"
REPO="${REGISTRY}/${TEAM}-${REPO_NAME}"
SHA_TAG="sha-${GITHUB_SHA::8}"
echo "primary_tag=${SHA_TAG}" >> "$GITHUB_OUTPUT"
echo "repo=${REPO}" >> "$GITHUB_OUTPUT"

TAGS="${REPO}:${SHA_TAG}"

if [ "${{ github.ref_name }}" = "main" ] || [ "${{ github.ref_name }}" = "master" ]; then
if [ "${REF_NAME}" = "main" ] || [ "${REF_NAME}" = "master" ]; then
DATE_TAG="main-$(date -u +%Y%m%d-%H%M)"
TAGS="${TAGS},${REPO}:${DATE_TAG},${REPO}:latest"
fi

if [[ "${{ github.ref }}" == refs/tags/v* ]]; then
TAGS="${TAGS},${REPO}:${{ github.ref_name }}"
if echo "${REF}" | grep -q '^refs/tags/v'; then
TAGS="${TAGS},${REPO}:${REF_NAME}"
fi

echo "tags=${TAGS}" >> "$GITHUB_OUTPUT"
Expand Down
2 changes: 1 addition & 1 deletion .github/workflows/ecs-deploy.yml
Original file line number Diff line number Diff line change
Expand Up @@ -68,6 +68,6 @@ jobs:

- name: Deploy to ECS
env:
SERVICE: ${{ inputs.service_name || github.event.repository.name }}
SERVICE: ${{ inputs.service_name || format('{0}-{1}', steps.broker.outputs.team, github.event.repository.name) }}
CLUSTER: ${{ inputs.cluster_name }}
run: sh .platform/scripts/ecs-deploy.sh
6 changes: 4 additions & 2 deletions scripts/registry.py
Original file line number Diff line number Diff line change
Expand Up @@ -60,7 +60,9 @@
"ecs_cluster_name": "data.aws_ecs_cluster.platform_main.cluster_name",
"execution_role_arn": "data.aws_iam_role.platform_ecs_execution.arn",
"route53_zone_id": "data.aws_route53_zone.platform_main.zone_id",
"developer_boundary_arn": "data.aws_iam_policy.platform_developer_boundary.arn",
# Boundary ARN constructed from account ID — no data source needed.
# Avoids iam:GetPolicy permission requirement on the boundary policy.
"developer_boundary_arn": "NOT_USED",
},
},

Expand Down Expand Up @@ -128,7 +130,7 @@
"team": "yaml:team",
"region": "env:AWS_REGION",
"aws_account_id": "env:AWS_ACCOUNT_ID",
"permissions_boundary_arn": "ref:platform.developer_boundary_arn",
"permissions_boundary_arn": f"expr:arn:aws:iam::${{env:AWS_ACCOUNT_ID}}:policy/{PROJECT}-developer-boundary",
"trusted_services": "list:yaml:compute.trusted_service|default:ecs-tasks.amazonaws.com",
"additional_policy_jsons": "collect:access_policy_json",
},
Expand Down
16 changes: 13 additions & 3 deletions terraform/lambda-src/apply_gate/handler.py
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@

The signing key lives in SSM. Only this Lambda can read it. CI roles invoke
the Lambda but never see the key. Temp credentials are issued via STS
AssumeRole on the app's CI role.
AssumeRole on the team's CI role (resolved from GitHub team membership).
"""

import hashlib
Expand All @@ -18,6 +18,7 @@
import time

import boto3
from shared.github import resolve_team

logger = logging.getLogger(__name__)
logger.setLevel(logging.INFO)
Expand Down Expand Up @@ -204,9 +205,18 @@ def action_status(event):


def _issue_credentials(repo_name):
"""Assume the app's CI role and return temporary credentials."""
"""Resolve team from GitHub and assume the team's CI role."""
account_id = os.environ.get("ACCOUNT_ID", "")
role_arn = f"arn:aws:iam::{account_id}:role/{PROJECT}-ci-app-{repo_name}"

team = resolve_team(repo_name)
if not team:
raise RuntimeError(
f"Repo '{repo_name}' is not in any GitHub team. "
f"Add it at https://github.com/orgs/javaBin/teams"
)

role_arn = f"arn:aws:iam::{account_id}:role/{PROJECT}-ci-team-{team}"
logger.info("Assuming team role %s for repo %s", role_arn, repo_name)

resp = sts.assume_role(
RoleArn=role_arn,
Expand Down
126 changes: 2 additions & 124 deletions terraform/lambda-src/ci_broker/handler.py
Original file line number Diff line number Diff line change
Expand Up @@ -13,26 +13,18 @@
import json
import logging
import os
import time
import urllib.request

import boto3
from shared.github import resolve_team

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
Expand All @@ -42,120 +34,6 @@
"deploy": f"{PROJECT}-ci-deploy-",
}

EXCLUDED_TEAMS = {"platform"}


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."""
Expand Down Expand Up @@ -183,7 +61,7 @@ def handler(event, context):
return {"error": f"Invalid action: {action}. Must be plan or deploy", "approved": False}

# Resolve team from GitHub
team = _resolve_team(repo)
team = resolve_team(repo)
if not team:
logger.warning("Repo %s does not belong to any team", repo)
return {
Expand Down
143 changes: 143 additions & 0 deletions terraform/lambda-src/shared/github.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,143 @@
"""GitHub App authentication and team resolution.

Shared by ci_broker and apply_gate Lambdas. Uses the platform GitHub App
to generate installation tokens and resolve repo→team membership.
"""

import base64
import json
import logging
import os
import subprocess
import tempfile
import time
import urllib.error
import urllib.request

import boto3

logger = logging.getLogger(__name__)

ssm = boto3.client("ssm")

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"
)

EXCLUDED_TEAMS = {"platform"}

# Cache across invocations
_token_cache = {"token": None, "expires_at": 0}
_ssm_cache = {}


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"]

app_id = _get_ssm(GITHUB_APP_ID_PARAM)
private_key = _get_ssm(GITHUB_APP_KEY_PARAM)

# Build JWT (Header.Payload.Signature)
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
req = urllib.request.Request(
"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.

Returns the team slug, or None if the repo isn't in any team.
Excludes platform-internal teams (e.g. 'platform').
"""
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
Loading