Skip to content

Commit da70907

Browse files
authored
[CRCR] Add multi-issuer OIDC support to jwt_helper for Buildkite (#8453)
## Summary Adds multi-issuer OIDC support to the CRCR relay, enabling downstream repos running on **any supported CI platform** (currently GitHub Actions + Buildkite) to authenticate callbacks. ### Architecture ``` ci_providers.yml (in test-infra) allowlist.yml (in pytorch/pytorch) ┌─────────────────────┐ ┌─────────────────┐ │ buildkite: │ │ L1: [...] │ │ vllm/ci: vllm/vllm│ │ L2: [...] │ │ # gitlab: │ │ L3: {device: ..} │ │ # grp/proj: o/r │ │ L4: [...] │ └────────┬────────────┘ └────────┬────────┘ │ CI_PROVIDERS_URL │ ALLOWLIST_URL ▼ ▼ ┌─────────────────────────────────────────────────────────┐ │ CRCR Lambda │ │ jwt_helper.py → verify OIDC token (any issuer) │ │ allowlist.py → check repo trust level │ │ lambda_function → orchestrate │ └─────────────────────────────────────────────────────────┘ ``` ### Changes | File | What | |------|------| | `config/ci_providers.yml` | **New** — external CI provider pipeline-to-repo mapping, extensible per provider | | `utils/jwt_helper.py` | Multi-issuer OIDC: detect issuer → select JWKS → extract repo. Loads mappings from `ci_providers.yml` via URL + Redis cache | | `utils/config.py` | Add optional `ci_providers_url` field | | `utils/redis_helper.py` | Add `get_cached_ci_providers` / `set_cached_ci_providers` with separate Redis key | | `callback/lambda_function.py` | Call `load_ci_providers(config)` before token verification | | `utils/allowlist.py` | Unchanged (reverted) | | `tests/test_jwt_helper.py` | Expanded from 5 → 17 tests | ### Onboarding a new Buildkite repo Edit `config/ci_providers.yml` — no Lambda redeployment needed: ```yaml buildkite: vllm/ci: vllm-project/vllm acme/build: acme/repo ``` ### Onboarding a new CI provider (future) 1. Add the provider's JWKS endpoint to `_ISSUER_CONFIG` in `jwt_helper.py` 2. Add an `_extract_repo_<provider>` function 3. Add a section reader in `load_ci_provider_mappings` 4. Add the provider section to `ci_providers.yml` Closes #8326 ## Test plan - [x] All 17 unit tests pass locally - [ ] CI passes (lintrunner, python-tests) - [ ] Set `CI_PROVIDERS_URL` env var and deploy to staging - [ ] Verify with a test Buildkite OIDC token
1 parent 5f99905 commit da70907

7 files changed

Lines changed: 611 additions & 51 deletions

File tree

aws/lambda/cross_repo_ci_relay/callback/lambda_function.py

Lines changed: 5 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -46,11 +46,11 @@ def lambda_handler(event, context):
4646
config = get_config()
4747
body = json.loads(body_bytes) if body_bytes else {}
4848

49-
# OIDC is the only identity check Relay performs. The callback body is
50-
# passed through to HUD untouched — HUD owns schema/business validation.
51-
# Relay reports the OIDC-verified repo to HUD separately as
52-
# `verified_repo` so HUD has a trusted source of truth for the
53-
# caller's identity.
49+
# Load external CI provider repo mappings (Buildkite, etc.) so
50+
# verify_oidc_token can resolve non-GitHub OIDC tokens. Cached
51+
# in Redis; no-op when CI_PROVIDERS_URL is not configured.
52+
jwt_helper.load_ci_providers(config)
53+
5454
oidc_claims = jwt_helper.verify_oidc_token(headers.get("authorization", ""))
5555
verified_repo = oidc_claims["repository"]
5656

Lines changed: 43 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,43 @@
1+
# External CI Provider → GitHub Repo Mapping for CRCR OIDC
2+
#
3+
# CI platforms that don't carry a native "repository" OIDC claim need an
4+
# explicit mapping from their pipeline identity to a GitHub owner/repo.
5+
#
6+
# IMPORTANT: Buildkite mappings use immutable organization_id/pipeline_id
7+
# (UUIDs) rather than slugs. Slugs are renameable and a released slug
8+
# can be claimed by a different organization; IDs are permanent.
9+
# Find IDs via: buildkite-agent oidc request-token --audience <aud>
10+
#
11+
# SECURITY: By default, any build in a mapped pipeline can authenticate.
12+
# If a pipeline runs fork-PR builds, use required_claims to restrict
13+
# which builds are authorized (e.g. only builds on specific branches).
14+
# Pipelines that do NOT expose OIDC to fork builds are safe without
15+
# required_claims.
16+
#
17+
# The Lambda fetches this file at runtime (via CI_PROVIDERS_URL) and
18+
# caches it in Redis, so changes here take effect without redeployment.
19+
20+
buildkite:
21+
# Simple format (pipeline does not run fork builds):
22+
# organization_id/pipeline_id: owner/repo
23+
#
24+
# Constrained format (pipeline may run fork builds):
25+
# organization_id/pipeline_id:
26+
# repo: owner/repo
27+
# required_claims:
28+
# build_branch: [main, nightly] # only these branches can auth
29+
# cluster_id: specific-cluster-uuid
30+
31+
# Example entries:
32+
# 018e4f2a-1b2c-3d4e/018e5a6b-7c8d-9e0f: vllm-project/vllm
33+
#
34+
# 018e4f2a-1b2c-3d4e/018e9a0b-1c2d-3e4f:
35+
# repo: vllm-project/vllm
36+
# required_claims:
37+
# build_branch: [main, release]
38+
39+
# gitlab:
40+
# group/project: owner/repo
41+
42+
# jenkins:
43+
# job-name: owner/repo

aws/lambda/cross_repo_ci_relay/tests/test_callback_handler_lambda.py

Lines changed: 10 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -48,26 +48,29 @@ def test_invalid_json_body_returns_400(self, mock_get_config):
4848
response = lambda_handler(_event(body="not-json"), {})
4949
self.assertEqual(response["statusCode"], 400)
5050

51+
@patch("callback.lambda_function.jwt_helper.load_ci_providers")
5152
@patch("callback.lambda_function.get_config")
52-
def test_missing_authorization_header_returns_401(self, mock_get_config):
53+
def test_missing_authorization_header_returns_401(self, mock_get_config, _):
5354
response = lambda_handler(_event(headers={}), {})
5455
self.assertEqual(response["statusCode"], 401)
5556
self.assertIn("Missing", json.loads(response["body"])["detail"])
5657

58+
@patch("callback.lambda_function.jwt_helper.load_ci_providers")
5759
@patch("callback.lambda_function.get_config")
5860
@patch("callback.lambda_function.jwt_helper.verify_oidc_token")
59-
def test_oidc_failure_returns_401(self, mock_oidc, mock_get_config):
61+
def test_oidc_failure_returns_401(self, mock_oidc, mock_get_config, _):
6062
mock_oidc.side_effect = HTTPException(401, "Invalid authorization token")
6163

6264
response = lambda_handler(_event(), {})
6365

6466
self.assertEqual(response["statusCode"], 401)
6567

68+
@patch("callback.lambda_function.jwt_helper.load_ci_providers")
6669
@patch("callback.lambda_function.get_config")
6770
@patch("callback.lambda_function.jwt_helper.verify_oidc_token")
6871
@patch("callback.lambda_function.callback_handler.handle")
6972
def test_happy_path_forwards_body_and_verified_repo(
70-
self, mock_handle, mock_oidc, mock_get_config
73+
self, mock_handle, mock_oidc, mock_get_config, _
7174
):
7275
mock_oidc.return_value = {"repository": "org/repo"}
7376
mock_handle.return_value = {"ok": True, "status": "completed"}
@@ -83,13 +86,13 @@ def test_happy_path_forwards_body_and_verified_repo(
8386
self.assertEqual(args[1], {"status": "completed", "head_sha": "abc123"})
8487
self.assertEqual(args[2], "org/repo")
8588

89+
@patch("callback.lambda_function.jwt_helper.load_ci_providers")
8690
@patch("callback.lambda_function.get_config")
8791
@patch("callback.lambda_function.jwt_helper.verify_oidc_token")
8892
@patch("callback.lambda_function.callback_handler.handle")
8993
def test_hud_error_from_handler_is_forwarded(
90-
self, mock_handle, mock_oidc, mock_get_config
94+
self, mock_handle, mock_oidc, mock_get_config, _
9195
):
92-
# HUD's HTTP status propagates out of Relay (transparent proxy).
9396
mock_oidc.return_value = {"repository": "org/repo"}
9497
mock_handle.side_effect = HTTPException(503, "HUD unreachable")
9598

@@ -98,11 +101,12 @@ def test_hud_error_from_handler_is_forwarded(
98101
self.assertEqual(response["statusCode"], 503)
99102
self.assertEqual(json.loads(response["body"])["detail"], "HUD unreachable")
100103

104+
@patch("callback.lambda_function.jwt_helper.load_ci_providers")
101105
@patch("callback.lambda_function.get_config")
102106
@patch("callback.lambda_function.jwt_helper.verify_oidc_token")
103107
@patch("callback.lambda_function.callback_handler.handle")
104108
def test_unhandled_exception_returns_500(
105-
self, mock_handle, mock_oidc, mock_get_config
109+
self, mock_handle, mock_oidc, mock_get_config, _
106110
):
107111
mock_oidc.return_value = {"repository": "org/repo"}
108112
mock_handle.side_effect = Exception("boom")

0 commit comments

Comments
 (0)