diff --git a/github/script/orchestrate.ts b/github/script/orchestrate.ts index 400c376..791053e 100644 --- a/github/script/orchestrate.ts +++ b/github/script/orchestrate.ts @@ -34,10 +34,6 @@ interface ContentResponse { content: string; } -interface TeamMembershipResponse { - state?: string; -} - async function githubApi(path: string, token: string): Promise { const resp = await fetchWithRetry(`https://api.github.com${path}`, { headers: { @@ -79,13 +75,20 @@ function parseCodeowners(content: string): { return { owners, teamPatterns }; } +// Returns null on success (actor matched individually), or an array of team +// patterns that need server-side membership verification. Calls core.setFailed +// if the CODEOWNERS file is missing (hard stop — nothing more to check). +// +// Team membership is NOT checked here because github.token lacks read:org +// scope. Team patterns are returned so they can be forwarded to the bonk +// server, which uses an unscoped app installation token with org-level access. async function checkCodeowners( owner: string, repo: string, ref: string, actor: string, token: string, -): Promise { +): Promise { let codeownersContent = ""; for (const path of CODEOWNERS_PATHS) { @@ -101,7 +104,9 @@ async function checkCodeowners( } if (!codeownersContent) { - return core.setFailed("CODEOWNERS file not found in .github/, root, or docs/ directory"); + core.setFailed("CODEOWNERS file not found in .github/, root, or docs/ directory"); + // core.setFailed exits the process; this return is for TypeScript + return null; } const { owners, teamPatterns } = parseCodeowners(codeownersContent); @@ -109,39 +114,40 @@ async function checkCodeowners( if (owners.has(actorLower)) { core.info(`User ${actor} is a code owner`); - return; + // Individually listed — no team check needed + return null; } - for (const teamPath of teamPatterns) { - const [org, team] = teamPath.split("/"); - try { - const membership = await githubApi( - `/orgs/${org}/teams/${team}/memberships/${actor}`, - token, - ); - if (membership?.state === "active") { - core.info(`User ${actor} is a member of team @${teamPath}`); - return; - } - } catch (e) { - const message = e instanceof Error ? e.message : String(e); - core.warning(`Could not check team membership for @${teamPath}: ${message}`); - } + if (teamPatterns.length === 0) { + // No individual match and no teams to check + core.setFailed(`User ${actor} is not listed in CODEOWNERS`); + return null; } - core.setFailed(`User ${actor} is not listed in CODEOWNERS`); + // Actor not individually listed; return team patterns for server-side check. + // The OIDC exchange will verify membership and deny the token if the actor + // is not in any of these teams. + core.info( + `User ${actor} not individually listed in CODEOWNERS; ` + + `will verify team membership server-side for: ${teamPatterns.map((t) => `@${t}`).join(", ")}`, + ); + return teamPatterns; } -async function checkPermissions(): Promise { +// Returns team patterns that need server-side membership verification, +// or null if no further check is needed (actor passed or process already exited). +async function checkPermissions(): Promise { const requiredPermission = process.env.REQUIRED_PERMISSION; if (!requiredPermission) { - return core.setFailed("REQUIRED_PERMISSION not set"); + core.setFailed("REQUIRED_PERMISSION not set"); + return null; } - if (requiredPermission === "any") return; + if (requiredPermission === "any") return null; const token = process.env.GH_TOKEN; if (!token) { - return core.setFailed("GH_TOKEN not set"); + core.setFailed("GH_TOKEN not set"); + return null; } const repository = process.env.GITHUB_REPOSITORY || ""; @@ -151,12 +157,12 @@ async function checkPermissions(): Promise { const ref = process.env.DEFAULT_BRANCH || "main"; if (!owner || !repo || !actor) { - return core.setFailed("Missing required context (owner, repo, or actor)"); + core.setFailed("Missing required context (owner, repo, or actor)"); + return null; } if (requiredPermission === "CODEOWNERS") { - await checkCodeowners(owner, repo, ref, actor, token); - return; + return checkCodeowners(owner, repo, ref, actor, token); } const data = await githubApi<{ permission: string }>( @@ -165,13 +171,15 @@ async function checkPermissions(): Promise { ); if (!data) { - return core.setFailed(`Could not check permission for ${actor}`); + core.setFailed(`Could not check permission for ${actor}`); + return null; } const error = checkPermissionLevel(data.permission, requiredPermission, actor); if (error) { core.setFailed(error); } + return null; } // --------------------------------------------------------------------------- @@ -509,7 +517,7 @@ function oidcFailClosed(reason: string): OidcResult { return { failed: true }; } -async function exchangeOidc(): Promise { +async function exchangeOidc(codeownersTeams?: string[]): Promise { const oidcUrl = process.env.ACTIONS_ID_TOKEN_REQUEST_URL; const oidcRequestToken = process.env.ACTIONS_ID_TOKEN_REQUEST_TOKEN; @@ -548,6 +556,18 @@ async function exchangeOidc(): Promise { } } + // Forward CODEOWNERS team patterns for server-side membership verification. + // The server uses an unscoped installation token (org-level access) to check + // whether the actor is a member of any of these teams. If the actor is not + // a member of any team, the server returns 401 and we fail closed. + if (codeownersTeams && codeownersTeams.length > 0) { + exchangeBody.codeowners_teams = codeownersTeams; + const actor = process.env.COMMENT_ACTOR || process.env.REVIEW_ACTOR || process.env.GITHUB_ACTOR; + if (actor) { + exchangeBody.actor = actor; + } + } + let appToken: string; try { const resp = await fetchWithRetry( @@ -781,8 +801,10 @@ async function trackRun(): Promise { // --------------------------------------------------------------------------- async function main() { - // Step 1: Check permissions (must pass before anything else) - await checkPermissions(); + // Step 1: Check permissions (must pass before anything else). + // For CODEOWNERS mode, returns team patterns that need server-side + // membership verification when the actor is not individually listed. + const codeownersTeams = await checkPermissions(); // Step 2: Check setup (may skip remaining steps) const shouldSkip = await checkSetup(); @@ -792,7 +814,12 @@ async function main() { // in parallel (they are independent of each other). resolveVersion(); - const [promptResult, oidcResult] = await Promise.all([buildPrompt(), exchangeOidc()]); + // Pass codeownersTeams to the OIDC exchange so the server can verify team + // membership using an org-level installation token. + const [promptResult, oidcResult] = await Promise.all([ + buildPrompt(), + exchangeOidc(codeownersTeams ?? undefined), + ]); // Set prompt outputs core.setOutput("is_fork", String(promptResult.isFork)); diff --git a/src/index.ts b/src/index.ts index 4910c75..0519992 100644 --- a/src/index.ts +++ b/src/index.ts @@ -128,7 +128,9 @@ stats.get("/actors", async (c) => { return c.json({ error: "Failed to query stats" }, 500); } if (c.req.query("format") === "json") return c.json({ data: result.value }); - return c.text(renderBarChart(result.value, "Mentions per actor (last 7d)", "actor", "event_count")); + return c.text( + renderBarChart(result.value, "Mentions per actor (last 7d)", "actor", "event_count"), + ); }); app.route("/stats", stats); @@ -243,9 +245,15 @@ auth.get("/get_github_app_installation", async (c) => { auth.post("/exchange_github_app_token", async (c) => { const authHeader = c.req.header("Authorization") ?? null; - // Body is optional — callers may include { permissions } to scope the token. - // Accepts a preset name ("NO_PUSH", "WRITE") or a custom permissions object. - let body: { permissions?: import("./oidc").TokenPermissionsInput } = {}; + // Body is optional — callers may include: + // permissions: preset name ("NO_PUSH", "WRITE") or custom permissions object + // codeowners_teams: array of "org/team" strings from CODEOWNERS (for team membership check) + // actor: GitHub username to check (defaults to OIDC claims.actor) + let body: { + permissions?: import("./oidc").TokenPermissionsInput; + codeowners_teams?: string[]; + actor?: string; + } = {}; try { body = await c.req.json(); } catch { diff --git a/src/oidc.ts b/src/oidc.ts index 180d2ed..8acdac0 100644 --- a/src/oidc.ts +++ b/src/oidc.ts @@ -380,15 +380,102 @@ export async function handleGetInstallation( return { installation: null }; } +// Checks whether an actor is a member of a GitHub team using an unscoped +// installation token (no repositoryNames restriction). The unscoped token has +// org-level access, which is required for the team membership API. +// +// The team org MUST match the repo owner to prevent cross-org lookups against +// orgs where the app may not be installed. +// +// Returns true if the actor is an active team member, false otherwise. +// Logs a warning and returns false on API errors (fail-closed). +async function checkTeamMembership( + env: Env, + installationId: number, + repoOwner: string, + teamPath: string, + actor: string, + log: ReturnType, +): Promise { + const slashIdx = teamPath.indexOf("/"); + if (slashIdx === -1) { + log.warn("team_membership_skip_invalid_pattern", { team_path: teamPath }); + return false; + } + const teamOrg = teamPath.slice(0, slashIdx); + const teamSlug = teamPath.slice(slashIdx + 1); + + // Security: only check membership for teams in the same org as the repo. + // Without this, a CODEOWNERS entry like @other-org/team would cause the + // server to make membership calls against an org where the app may not be + // installed (and whose membership should not gate access to this repo). + if (teamOrg.toLowerCase() !== repoOwner.toLowerCase()) { + log.warn("team_membership_skip_cross_org", { + team_org: teamOrg, + repo_owner: repoOwner, + team_path: teamPath, + }); + return false; + } + + // Generate an unscoped token — no repositoryNames restriction — so the token + // has org-level access needed to read team membership. This token is used + // only within this request and never returned to the caller. + let unscopedToken: string; + try { + unscopedToken = await generateInstallationToken(env, installationId); + } catch (err) { + log.errorWithException("team_membership_token_failed", err, { team_path: teamPath }); + return false; + } + + try { + const resp = await fetch( + `https://api.github.com/orgs/${teamOrg}/teams/${teamSlug}/memberships/${actor}`, + { + headers: { + Authorization: `Bearer ${unscopedToken}`, + Accept: "application/vnd.github+json", + "X-GitHub-Api-Version": "2022-11-28", + }, + }, + ); + if (resp.status === 200) { + const data = (await resp.json()) as { state?: string }; + return data.state === "active"; + } + // 404 means not a member (or token lacks org access — both fail-closed). + // Any other status is an unexpected error. + if (resp.status !== 404) { + const text = await resp.text(); + log.warn("team_membership_api_error", { + team_path: teamPath, + status: resp.status, + body: text.slice(0, 200), + }); + } + return false; + } catch (err) { + log.errorWithException("team_membership_fetch_failed", err, { team_path: teamPath }); + return false; + } +} + // Handler for POST /exchange_github_app_token // Exchanges a GitHub Actions OIDC token for a GitHub App installation token. // Callers may pass a `permissions` field in the request body — either a preset // name ("NO_PUSH", "WRITE") or a custom permissions object. Escalation beyond // defaults is silently clamped. +// +// Optional: pass `codeowners_teams` (array of "org/team" strings) and `actor` +// (GitHub username) to perform a server-side team membership check before +// issuing the token. This is used by the CODEOWNERS permission mode when the +// actor is not individually listed in CODEOWNERS but belongs to a listed team. +// The check is only performed when both fields are present and non-empty. export async function handleExchangeToken( env: Env, authHeader: string | null, - body?: { permissions?: TokenPermissionsInput }, + body?: { permissions?: TokenPermissionsInput; codeowners_teams?: string[]; actor?: string }, ): Promise> { const oidcToken = extractBearerToken(authHeader); if (!oidcToken) { @@ -427,6 +514,48 @@ export async function handleExchangeToken( } const { id: installationId, source: installationSource } = installationResult.value; + // Server-side CODEOWNERS team membership check. + // When the action's checkCodeowners() finds team patterns but the actor is + // not individually listed, it forwards those patterns here. The server uses + // an unscoped installation token (org-level access) to check membership — + // something the action's github.token cannot do. + const teamPatterns = body?.codeowners_teams; + const teamActor = body?.actor ?? claims.actor; + if (Array.isArray(teamPatterns) && teamPatterns.length > 0 && teamActor) { + let isMember = false; + for (const teamPath of teamPatterns) { + if (typeof teamPath !== "string" || !teamPath.includes("/")) continue; + const memberResult = await checkTeamMembership( + env, + installationId, + owner, + teamPath, + teamActor, + exchangeLog, + ); + if (memberResult) { + exchangeLog.info("codeowners_team_membership_verified", { + actor: teamActor, + team_path: teamPath, + }); + isMember = true; + break; + } + } + if (!isMember) { + exchangeLog.warn("codeowners_team_membership_denied", { + actor: teamActor, + teams: teamPatterns, + }); + return Result.err( + new AuthorizationError({ + message: `${teamActor} is not a member of any CODEOWNERS team`, + reason: "no_write_access", + }), + ); + } + } + // Generate scoped token — use caller-provided permissions (clamped to defaults) const permissions = resolvePermissions(body?.permissions);