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
219 changes: 219 additions & 0 deletions .github/scripts/check-pr-permissions.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,219 @@
// Check PR author permissions for pull_request_target events.
// Used by the e2e workflow to gate CI runs on external PRs.
//
// Permission check order:
// 1. Trusted bots (dependabot, renovate)
// 2. Org/team membership
// 3. Repository collaborator (write/admin)
// 4. ok-to-test label (maintainer approval for external contributors)
//
// Security: on non-labeled events the ok-to-test label is removed
// to force re-approval after code changes.
module.exports = async ({ github, context, core }) => {
if (!context || !context.payload || !context.payload.pull_request) {
core.setFailed(
"Invalid GitHub context: missing required pull_request information",
);
return;
}

const actor = context.payload.pull_request.user.login;
const repoOwner = context.repo.owner;
const repoName = context.repo.repo;
const targetOrg = context.repo.owner;

core.info(`๐Ÿ” Starting permission check for user: @${actor}`);
core.info(`๐Ÿ“‹ Repository: ${repoOwner}/${repoName}`);
core.info(`๐Ÿข Target organization: ${targetOrg}`);

// Security: On non-labeled events (opened, reopened, synchronize),
// remove the ok-to-test label if present. This prevents an external
// contributor from pushing malicious code after a maintainer approved via label.
if (context.payload.action !== "labeled") {
const currentLabels = context.payload.pull_request.labels.map(
(l) => l.name,
);
if (currentLabels.includes("ok-to-test")) {
core.info(
`๐Ÿ”’ Removing ok-to-test label due to '${context.payload.action}' event โ€” re-approval required.`,
);
try {
await github.rest.issues.removeLabel({
owner: repoOwner,
repo: repoName,
issue_number: context.payload.pull_request.number,
name: "ok-to-test",
});
core.info(` Label removed successfully.`);
} catch (err) {
// 404 is expected when multiple matrix jobs race to remove the same label
if (err.status === 404) {
core.info(` Label already removed (likely by another matrix job).`);
} else {
core.warning(` Failed to remove ok-to-test label: ${err.message}`);
}
}
}
}

// Condition 1: Check if the user is a trusted bot.
const trustedBots = ["dependabot[bot]", "renovate[bot]"];
core.info(`๐Ÿค– Checking if @${actor} is a trusted bot...`);
core.info(` Trusted bots list: ${trustedBots.join(", ")}`);

if (trustedBots.includes(actor)) {
core.info(
`โœ… Condition met: User @${actor} is a trusted bot. Proceeding.`,
);
return;
}
core.info(` โŒ User @${actor} is not a trusted bot.`);

// Condition 2: Check for public membership in the target organization.
core.info(`\n๐Ÿ‘ฅ Condition 2: Checking organization and team membership...`);
core.info(
`User @${actor} is not a trusted bot. Checking for membership in '${targetOrg}'...`,
);
try {
// Optional: check membership in one or more org teams (set TARGET_TEAM_SLUGS as comma-separated slugs in workflow env)
const teamSlugsEnv = process.env.TARGET_TEAM_SLUGS || "";
const teamSlugs = teamSlugsEnv
.split(",")
.map((s) => s.trim())
.filter(Boolean);

core.info(`๐Ÿ”ง TARGET_TEAM_SLUGS environment variable: "${teamSlugsEnv}"`);
core.info(`๐Ÿ“ Parsed team slugs: [${teamSlugs.join(", ")}]`);

if (teamSlugs.length > 0) {
core.info(
`๐Ÿ” Checking team membership for ${teamSlugs.length} team(s)...`,
);
for (const team_slug of teamSlugs) {
core.info(` Checking team: ${team_slug}...`);
try {
const membership =
await github.rest.teams.getMembershipForUserInOrg({
org: targetOrg,
team_slug,
username: actor,
});
core.info(
` API response for team '${team_slug}': ${JSON.stringify(membership.data)}`,
);
if (
membership &&
membership.data &&
membership.data.state === "active"
) {
core.info(
`โœ… Condition met: User @${actor} is a member of team '${team_slug}' in '${targetOrg}'. Proceeding.`,
);
return;
} else {
core.info(
` โš ๏ธ Team membership found but state is not 'active': ${membership.data.state}`,
);
}
} catch (err) {
core.info(
` โŒ User @${actor} is not a member of team '${team_slug}' (or team not found). Error: ${err.message}`,
);
}
}
core.info(
`โ“˜ User @${actor} is not a member of any configured teams in '${targetOrg}'. Falling back to org membership checks.`,
);
} else {
core.info(
`โ„น๏ธ No teams configured in TARGET_TEAM_SLUGS. Skipping team membership checks.`,
);
}
core.info(
`๐Ÿข Checking organization membership for @${actor} in '${targetOrg}'...`,
);
try {
core.info(` Attempting checkMembershipForUser API call...`);
await github.rest.orgs.checkMembershipForUser({
org: targetOrg,
username: actor,
});
core.info(
`โœ… Condition met: User @${actor} is a member of '${targetOrg}'. Proceeding.`,
);
return;
} catch (err) {
core.info(` โŒ Private membership check failed: ${err.message}`);
core.info(` Attempting checkPublicMembershipForUser API call...`);
try {
await github.rest.orgs.checkPublicMembershipForUser({
org: targetOrg,
username: actor,
});
core.info(
`โœ… Condition met: User @${actor} is a public member of '${targetOrg}'. Proceeding.`,
);
return;
} catch (publicErr) {
core.info(
` โŒ Public membership check failed: ${publicErr.message}`,
);
throw publicErr;
}
}
} catch (error) {
core.info(
`โ“˜ User @${actor} is not a public member of '${targetOrg}'. Checking repository permissions as a fallback.`,
);
}

// Condition 3: Check for write/admin permission on the repository.
core.info(
`\n๐Ÿ” Condition 3: Checking repository collaborator permissions...`,
);
try {
core.info(` Attempting getCollaboratorPermissionLevel API call...`);
const response = await github.rest.repos.getCollaboratorPermissionLevel({
owner: repoOwner,
repo: repoName,
username: actor,
});

const permission = response.data.permission;
core.info(
` User @${actor} has '${permission}' permission on ${repoOwner}/${repoName}`,
);

if (permission === "admin" || permission === "write") {
core.info(
`โœ… Condition met: User @${actor} has '${permission}' repository permission. Proceeding.`,
);
return;
} else {
core.info(
` โŒ Permission '${permission}' is insufficient (requires 'write' or 'admin')`,
);
}
} catch (error) {
core.info(` โŒ Collaborator permission check failed: ${error.message}`);
}

// Condition 4: Check for ok-to-test label (for external contributors approved by maintainers).
// Only users with repo write access can add labels, so this is inherently gated.
core.info(`\n๐Ÿท๏ธ Condition 4: Checking for ok-to-test label...`);
if (
context.payload.action === "labeled" &&
context.payload.label &&
context.payload.label.name === "ok-to-test"
) {
core.info(
`โœ… Condition met: ok-to-test label applied by @${context.actor}. Proceeding with tests.`,
);
return;
}

core.info(` โŒ No ok-to-test label event detected.`);
core.setFailed(
`โŒ Permission check failed. User @${actor} did not meet any required conditions (trusted bot, org/team member, repo write access, or ok-to-test label).`,
);
};
Loading
Loading
โšก