Skip to content
Open
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
97 changes: 62 additions & 35 deletions github/script/orchestrate.ts
Original file line number Diff line number Diff line change
Expand Up @@ -34,10 +34,6 @@ interface ContentResponse {
content: string;
}

interface TeamMembershipResponse {
state?: string;
}

async function githubApi<T>(path: string, token: string): Promise<T | null> {
const resp = await fetchWithRetry(`https://api.github.com${path}`, {
headers: {
Expand Down Expand Up @@ -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<void> {
): Promise<string[] | null> {
let codeownersContent = "";

for (const path of CODEOWNERS_PATHS) {
Expand All @@ -101,47 +104,50 @@ 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);
const actorLower = actor.toLowerCase();

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<TeamMembershipResponse>(
`/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<void> {
// 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<string[] | null> {
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 || "";
Expand All @@ -151,12 +157,12 @@ async function checkPermissions(): Promise<void> {
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 }>(
Expand All @@ -165,13 +171,15 @@ async function checkPermissions(): Promise<void> {
);

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;
}

// ---------------------------------------------------------------------------
Expand Down Expand Up @@ -509,7 +517,7 @@ function oidcFailClosed(reason: string): OidcResult {
return { failed: true };
}

async function exchangeOidc(): Promise<OidcResult> {
async function exchangeOidc(codeownersTeams?: string[]): Promise<OidcResult> {
const oidcUrl = process.env.ACTIONS_ID_TOKEN_REQUEST_URL;
const oidcRequestToken = process.env.ACTIONS_ID_TOKEN_REQUEST_TOKEN;

Expand Down Expand Up @@ -548,6 +556,18 @@ async function exchangeOidc(): Promise<OidcResult> {
}
}

// 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(
Expand Down Expand Up @@ -781,8 +801,10 @@ async function trackRun(): Promise<void> {
// ---------------------------------------------------------------------------

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();
Expand All @@ -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));
Expand Down
16 changes: 12 additions & 4 deletions src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down Expand Up @@ -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 {
Expand Down
131 changes: 130 additions & 1 deletion src/oidc.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<typeof createLogger>,
): Promise<boolean> {
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<Result<ExchangeTokenResponse, TokenExchangeError>> {
const oidcToken = extractBearerToken(authHeader);
if (!oidcToken) {
Expand Down Expand Up @@ -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);

Expand Down
Loading