Skip to content

Commit f543242

Browse files
Apply gate: resolve team from GitHub API for team-based roles (#95)
## Summary The apply gate Lambda used the old per-app role pattern (`javabin-ci-app-{repo}`). Updated to resolve team from GitHub API (same as the CI broker) and assume `javabin-ci-team-{team}`. Also includes the boundary ARN construction fix from PR #94. ### Changes - **`shared/github.py`**: Extracted GitHub App auth + team resolution from ci_broker into shared module - **`ci_broker/handler.py`**: Uses `shared.github.resolve_team()` instead of inline copy - **`apply_gate/handler.py`**: Uses `shared.github.resolve_team()` to find team, assumes `javabin-ci-team-{team}` - **`lambdas/main.tf`**: Added SSM read for GitHub App creds to gate role. Switched ci_broker + apply_gate archives to `source{}` blocks to include shared module. - **`registry.py`** + **`platform-data/main.tf`**: Construct boundary ARN instead of data source ## Test plan - [ ] Merge, wait for apply (deploys updated Lambdas) - [ ] Retrigger test app CI — apply gate should resolve team and assume correct role
1 parent 6a02f85 commit f543242

4 files changed

Lines changed: 193 additions & 129 deletions

File tree

terraform/lambda-src/apply_gate/handler.py

Lines changed: 13 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,7 @@
77
88
The signing key lives in SSM. Only this Lambda can read it. CI roles invoke
99
the Lambda but never see the key. Temp credentials are issued via STS
10-
AssumeRole on the app's CI role.
10+
AssumeRole on the team's CI role (resolved from GitHub team membership).
1111
"""
1212

1313
import hashlib
@@ -18,6 +18,7 @@
1818
import time
1919

2020
import boto3
21+
from shared.github import resolve_team
2122

2223
logger = logging.getLogger(__name__)
2324
logger.setLevel(logging.INFO)
@@ -204,9 +205,18 @@ def action_status(event):
204205

205206

206207
def _issue_credentials(repo_name):
207-
"""Assume the app's CI role and return temporary credentials."""
208+
"""Resolve team from GitHub and assume the team's CI role."""
208209
account_id = os.environ.get("ACCOUNT_ID", "")
209-
role_arn = f"arn:aws:iam::{account_id}:role/{PROJECT}-ci-app-{repo_name}"
210+
211+
team = resolve_team(repo_name)
212+
if not team:
213+
raise RuntimeError(
214+
f"Repo '{repo_name}' is not in any GitHub team. "
215+
f"Add it at https://github.com/orgs/javaBin/teams"
216+
)
217+
218+
role_arn = f"arn:aws:iam::{account_id}:role/{PROJECT}-ci-team-{team}"
219+
logger.info("Assuming team role %s for repo %s", role_arn, repo_name)
210220

211221
resp = sts.assume_role(
212222
RoleArn=role_arn,

terraform/lambda-src/ci_broker/handler.py

Lines changed: 2 additions & 124 deletions
Original file line numberDiff line numberDiff line change
@@ -13,26 +13,18 @@
1313
import json
1414
import logging
1515
import os
16-
import time
17-
import urllib.request
1816

1917
import boto3
18+
from shared.github import resolve_team
2019

2120
logger = logging.getLogger(__name__)
2221
logger.setLevel(logging.INFO)
2322

24-
ssm = boto3.client("ssm")
2523
sts = boto3.client("sts")
2624

2725
ACCOUNT_ID = os.environ.get("AWS_ACCOUNT_ID", "")
2826
PROJECT = os.environ.get("PROJECT", "javabin")
2927
GITHUB_ORG = os.environ.get("GITHUB_ORG", "javaBin")
30-
GITHUB_APP_ID_PARAM = os.environ.get("GITHUB_APP_ID_PARAM", "/javabin/platform/github-app-id")
31-
GITHUB_APP_KEY_PARAM = os.environ.get("GITHUB_APP_KEY_PARAM", "/javabin/platform/github-app-key")
32-
33-
# Cache GitHub App token across invocations (valid for 1 hour)
34-
_token_cache = {"token": None, "expires_at": 0}
35-
_ssm_cache = {}
3628

3729
PLAN_DURATION = 3600 # 1 hour for plan
3830
DEPLOY_DURATION = 900 # 15 minutes for deploy
@@ -42,120 +34,6 @@
4234
"deploy": f"{PROJECT}-ci-deploy-",
4335
}
4436

45-
EXCLUDED_TEAMS = {"platform"}
46-
47-
48-
def _get_ssm(param_name):
49-
if param_name not in _ssm_cache:
50-
resp = ssm.get_parameter(Name=param_name, WithDecryption=True)
51-
_ssm_cache[param_name] = resp["Parameter"]["Value"]
52-
return _ssm_cache[param_name]
53-
54-
55-
def _github_app_token():
56-
"""Generate a GitHub App installation token (cached for 50 minutes)."""
57-
now = time.time()
58-
if _token_cache["token"] and _token_cache["expires_at"] > now:
59-
return _token_cache["token"]
60-
61-
import subprocess
62-
import tempfile
63-
64-
app_id = _get_ssm(GITHUB_APP_ID_PARAM)
65-
private_key = _get_ssm(GITHUB_APP_KEY_PARAM)
66-
67-
# Build JWT (Header.Payload.Signature)
68-
import base64
69-
import hashlib
70-
import hmac
71-
72-
header = base64.urlsafe_b64encode(json.dumps(
73-
{"alg": "RS256", "typ": "JWT"}).encode()).rstrip(b"=").decode()
74-
75-
iat = int(now) - 60
76-
exp = iat + 600
77-
payload = base64.urlsafe_b64encode(json.dumps(
78-
{"iss": app_id, "iat": iat, "exp": exp}).encode()).rstrip(b"=").decode()
79-
80-
signing_input = f"{header}.{payload}"
81-
82-
# Sign with RS256 using openssl (available in Lambda runtime)
83-
with tempfile.NamedTemporaryFile(mode="w", suffix=".pem", delete=False) as f:
84-
f.write(private_key)
85-
key_file = f.name
86-
87-
result = subprocess.run(
88-
["openssl", "dgst", "-sha256", "-sign", key_file],
89-
input=signing_input.encode(),
90-
capture_output=True,
91-
)
92-
os.unlink(key_file)
93-
94-
if result.returncode != 0:
95-
raise RuntimeError(f"JWT signing failed: {result.stderr.decode()}")
96-
97-
signature = base64.urlsafe_b64encode(result.stdout).rstrip(b"=").decode()
98-
jwt_token = f"{signing_input}.{signature}"
99-
100-
# Exchange JWT for installation token
101-
# First, find the installation ID for the org
102-
req = urllib.request.Request(
103-
f"https://api.github.com/app/installations",
104-
headers={
105-
"Authorization": f"Bearer {jwt_token}",
106-
"Accept": "application/vnd.github+json",
107-
},
108-
)
109-
with urllib.request.urlopen(req) as resp:
110-
installations = json.loads(resp.read())
111-
112-
install_id = None
113-
for inst in installations:
114-
if inst.get("account", {}).get("login") == GITHUB_ORG:
115-
install_id = inst["id"]
116-
break
117-
118-
if not install_id:
119-
raise RuntimeError(f"No GitHub App installation found for {GITHUB_ORG}")
120-
121-
req = urllib.request.Request(
122-
f"https://api.github.com/app/installations/{install_id}/access_tokens",
123-
method="POST",
124-
headers={
125-
"Authorization": f"Bearer {jwt_token}",
126-
"Accept": "application/vnd.github+json",
127-
},
128-
)
129-
with urllib.request.urlopen(req) as resp:
130-
token_resp = json.loads(resp.read())
131-
132-
_token_cache["token"] = token_resp["token"]
133-
_token_cache["expires_at"] = now + 3000 # Cache for ~50 minutes
134-
return _token_cache["token"]
135-
136-
137-
def _resolve_team(repo_name):
138-
"""Resolve which team a repo belongs to via GitHub API."""
139-
token = _github_app_token()
140-
req = urllib.request.Request(
141-
f"https://api.github.com/repos/{GITHUB_ORG}/{repo_name}/teams",
142-
headers={
143-
"Authorization": f"token {token}",
144-
"Accept": "application/vnd.github+json",
145-
},
146-
)
147-
try:
148-
with urllib.request.urlopen(req) as resp:
149-
teams = json.loads(resp.read())
150-
except urllib.error.HTTPError as e:
151-
logger.error("GitHub API error for %s: %s", repo_name, e)
152-
return None
153-
154-
for team in teams:
155-
if team["slug"] not in EXCLUDED_TEAMS:
156-
return team["slug"]
157-
return None
158-
15937

16038
def _assume_role(role_arn, session_name, duration):
16139
"""Assume an IAM role and return temporary credentials."""
@@ -183,7 +61,7 @@ def handler(event, context):
18361
return {"error": f"Invalid action: {action}. Must be plan or deploy", "approved": False}
18462

18563
# Resolve team from GitHub
186-
team = _resolve_team(repo)
64+
team = resolve_team(repo)
18765
if not team:
18866
logger.warning("Repo %s does not belong to any team", repo)
18967
return {
Lines changed: 143 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,143 @@
1+
"""GitHub App authentication and team resolution.
2+
3+
Shared by ci_broker and apply_gate Lambdas. Uses the platform GitHub App
4+
to generate installation tokens and resolve repo→team membership.
5+
"""
6+
7+
import base64
8+
import json
9+
import logging
10+
import os
11+
import subprocess
12+
import tempfile
13+
import time
14+
import urllib.error
15+
import urllib.request
16+
17+
import boto3
18+
19+
logger = logging.getLogger(__name__)
20+
21+
ssm = boto3.client("ssm")
22+
23+
GITHUB_ORG = os.environ.get("GITHUB_ORG", "javaBin")
24+
GITHUB_APP_ID_PARAM = os.environ.get(
25+
"GITHUB_APP_ID_PARAM", "/javabin/platform/github-app-id"
26+
)
27+
GITHUB_APP_KEY_PARAM = os.environ.get(
28+
"GITHUB_APP_KEY_PARAM", "/javabin/platform/github-app-key"
29+
)
30+
31+
EXCLUDED_TEAMS = {"platform"}
32+
33+
# Cache across invocations
34+
_token_cache = {"token": None, "expires_at": 0}
35+
_ssm_cache = {}
36+
37+
38+
def _get_ssm(param_name):
39+
if param_name not in _ssm_cache:
40+
resp = ssm.get_parameter(Name=param_name, WithDecryption=True)
41+
_ssm_cache[param_name] = resp["Parameter"]["Value"]
42+
return _ssm_cache[param_name]
43+
44+
45+
def github_app_token():
46+
"""Generate a GitHub App installation token (cached for 50 minutes)."""
47+
now = time.time()
48+
if _token_cache["token"] and _token_cache["expires_at"] > now:
49+
return _token_cache["token"]
50+
51+
app_id = _get_ssm(GITHUB_APP_ID_PARAM)
52+
private_key = _get_ssm(GITHUB_APP_KEY_PARAM)
53+
54+
# Build JWT (Header.Payload.Signature)
55+
header = base64.urlsafe_b64encode(json.dumps(
56+
{"alg": "RS256", "typ": "JWT"}).encode()).rstrip(b"=").decode()
57+
58+
iat = int(now) - 60
59+
exp = iat + 600
60+
payload = base64.urlsafe_b64encode(json.dumps(
61+
{"iss": app_id, "iat": iat, "exp": exp}).encode()).rstrip(b"=").decode()
62+
63+
signing_input = f"{header}.{payload}"
64+
65+
# Sign with RS256 using openssl (available in Lambda runtime)
66+
with tempfile.NamedTemporaryFile(mode="w", suffix=".pem", delete=False) as f:
67+
f.write(private_key)
68+
key_file = f.name
69+
70+
result = subprocess.run(
71+
["openssl", "dgst", "-sha256", "-sign", key_file],
72+
input=signing_input.encode(),
73+
capture_output=True,
74+
)
75+
os.unlink(key_file)
76+
77+
if result.returncode != 0:
78+
raise RuntimeError(f"JWT signing failed: {result.stderr.decode()}")
79+
80+
signature = base64.urlsafe_b64encode(result.stdout).rstrip(b"=").decode()
81+
jwt_token = f"{signing_input}.{signature}"
82+
83+
# Exchange JWT for installation token
84+
req = urllib.request.Request(
85+
"https://api.github.com/app/installations",
86+
headers={
87+
"Authorization": f"Bearer {jwt_token}",
88+
"Accept": "application/vnd.github+json",
89+
},
90+
)
91+
with urllib.request.urlopen(req) as resp:
92+
installations = json.loads(resp.read())
93+
94+
install_id = None
95+
for inst in installations:
96+
if inst.get("account", {}).get("login") == GITHUB_ORG:
97+
install_id = inst["id"]
98+
break
99+
100+
if not install_id:
101+
raise RuntimeError(f"No GitHub App installation found for {GITHUB_ORG}")
102+
103+
req = urllib.request.Request(
104+
f"https://api.github.com/app/installations/{install_id}/access_tokens",
105+
method="POST",
106+
headers={
107+
"Authorization": f"Bearer {jwt_token}",
108+
"Accept": "application/vnd.github+json",
109+
},
110+
)
111+
with urllib.request.urlopen(req) as resp:
112+
token_resp = json.loads(resp.read())
113+
114+
_token_cache["token"] = token_resp["token"]
115+
_token_cache["expires_at"] = now + 3000 # Cache for ~50 minutes
116+
return _token_cache["token"]
117+
118+
119+
def resolve_team(repo_name):
120+
"""Resolve which team a repo belongs to via GitHub API.
121+
122+
Returns the team slug, or None if the repo isn't in any team.
123+
Excludes platform-internal teams (e.g. 'platform').
124+
"""
125+
token = github_app_token()
126+
req = urllib.request.Request(
127+
f"https://api.github.com/repos/{GITHUB_ORG}/{repo_name}/teams",
128+
headers={
129+
"Authorization": f"token {token}",
130+
"Accept": "application/vnd.github+json",
131+
},
132+
)
133+
try:
134+
with urllib.request.urlopen(req) as resp:
135+
teams = json.loads(resp.read())
136+
except urllib.error.HTTPError as e:
137+
logger.error("GitHub API error for %s: %s", repo_name, e)
138+
return None
139+
140+
for team in teams:
141+
if team["slug"] not in EXCLUDED_TEAMS:
142+
return team["slug"]
143+
return None

terraform/platform/lambdas/main.tf

Lines changed: 35 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1266,7 +1266,19 @@ data "archive_file" "apply_gate" {
12661266
type = "zip"
12671267
output_path = "${path.module}/builds/apply_gate.zip"
12681268
output_file_mode = "0644"
1269-
source_dir = "${local.lambda_src_path}/apply_gate"
1269+
1270+
source {
1271+
content = file("${local.lambda_src_path}/apply_gate/handler.py")
1272+
filename = "handler.py"
1273+
}
1274+
source {
1275+
content = file("${local.lambda_src_path}/shared/__init__.py")
1276+
filename = "shared/__init__.py"
1277+
}
1278+
source {
1279+
content = file("${local.lambda_src_path}/shared/github.py")
1280+
filename = "shared/github.py"
1281+
}
12701282
}
12711283

12721284
resource "aws_iam_role" "apply_gate" {
@@ -1311,6 +1323,15 @@ resource "aws_iam_role_policy" "apply_gate" {
13111323
Action = "sts:AssumeRole"
13121324
Resource = "arn:aws:iam::${var.aws_account_id}:role/${var.project}-ci-team-*"
13131325
},
1326+
{
1327+
Sid = "ReadGitHubAppCredentials"
1328+
Effect = "Allow"
1329+
Action = "ssm:GetParameter"
1330+
Resource = [
1331+
"arn:aws:ssm:${var.region}:${var.aws_account_id}:parameter/${var.project}/platform/github-app-id",
1332+
"arn:aws:ssm:${var.region}:${var.aws_account_id}:parameter/${var.project}/platform/github-app-key",
1333+
]
1334+
},
13141335
]
13151336
})
13161337
}
@@ -1349,7 +1370,19 @@ data "archive_file" "ci_broker" {
13491370
type = "zip"
13501371
output_path = "${path.module}/builds/ci_broker.zip"
13511372
output_file_mode = "0644"
1352-
source_dir = "${local.lambda_src_path}/ci_broker"
1373+
1374+
source {
1375+
content = file("${local.lambda_src_path}/ci_broker/handler.py")
1376+
filename = "handler.py"
1377+
}
1378+
source {
1379+
content = file("${local.lambda_src_path}/shared/__init__.py")
1380+
filename = "shared/__init__.py"
1381+
}
1382+
source {
1383+
content = file("${local.lambda_src_path}/shared/github.py")
1384+
filename = "shared/github.py"
1385+
}
13531386
}
13541387

13551388
resource "aws_iam_role" "ci_broker" {

0 commit comments

Comments
 (0)