Skip to content

check_permissions: inherited_role does not exist in the GitHub API, so custom repository roles are always denied #50068

Description

@Etienne-M

Summary

checkRepositoryPermission in actions/setup/js/check_permissions_utils.cjs reads a inherited_role field from the response of GET /repos/{owner}/{repo}/collaborators/{username}/permission. That field does not exist in the GitHub REST API.

As a result, the custom-org-role fallback introduced in #45641 is dead code in production, and every user whose highest role is a custom organization repository role is unconditionally denied, even when their effective base permission clearly satisfies on.roles.

Reproduction

A user with the custom org role Project Lead (base permission write) triggering a workflow with roles: [admin, maintainer, write]:

Checking if user 'octocat' has required permissions for example-org/example-repo
Required permissions: admin, maintainer, write
Repository permission level: write (role: Project Lead)
Warning: User permission 'Project Lead' does not meet requirements: admin, maintainer, write

The user has write, but is denied.

Root cause analysis

1. The API never returns inherited_role

Live response for the affected user:

$ gh api repos/example-org/example-repo/collaborators/octocat/permission
{
  "permission": "write",
  "user": {
    "login": "octocat",
    ...
    "permissions": {
      "admin": false,
      "maintain": false,
      "push": true,
      "triage": true,
      "pull": true
    },
    "role_name": "Project Lead"
  },
  "role_name": "Project Lead"
}

No inherited_role. This matches the documented schema for Get repository permissions for a user, whose only top-level properties are permission, role_name, and user:

{
  "title": "Repository Collaborator Permission",
  "type": "object",
  "properties": {
    "permission": { "type": "string" },
    "role_name": { "type": "string" },
    "user": { "anyOf": [ { "type": "null" }, { "title": "Collaborator", ... } ] }
  },
  "required": ["permission", "role_name", "user"]
}

The name inherited_role appears to be a confusion with base_role, which exists only on the custom repository role object returned by GET /orgs/{org}/custom-repository-roles — a different endpoint.

2. The dead branch

In checkRepositoryPermission (actions/setup/js/check_permissions_utils.cjs):

const rawInheritedRole = repoPermissionData.inherited_role;
const inheritedRole = rawInheritedRole == null ? "" : typeof rawInheritedRole === "string" ? rawInheritedRole : "";
...
const inheritedStandardRole = isCustomRole && STANDARD_ROLES.has(normalizedInheritedRole) ? normalizedInheritedRole : "";

rawInheritedRole is always undefined, so inheritedStandardRole is always "". The match loop only ever compares required roles against effectiveRole (the raw custom role name) and inheritedStandardRole (always empty) — so a custom role can only ever be authorized by listing its literal name in on.roles.

3. Why tests didn't catch it

actions/setup/js/check_permissions_utils.test.cjs hand-mocks the non-existent field, so the fallback appears to work:

data: { permission: "write", role_name: "Security Champions", inherited_role: "write" }

The tests assert against a fixture shape the API never produces.

Proposed fix

Resolve the custom role's real base role from the endpoint that actually publishes it: List custom repository roles in an organization.

GET /orgs/{org}/custom-repository-roles

Its response contains exactly the field the current code is looking for, under its real name:

{
  "total_count": 1,
  "custom_roles": [
    {
      "id": 8030,
      "name": "Project Lead",
      "base_role": "write",
      "permissions": ["..."],
      "organization": { "login": "example-org", "...": "..." },
      "created_at": "...",
      "updated_at": "..."
    }
  ]
}

base_role is required in the schema and constrained to the enum read | triage | write | maintain — so once the role is found, the mapping into STANDARD_ROLES is total and needs no guessing. This is a strictly better source than the legacy permission string, which the collaborator docs state collapses maintainwrite and triageread, and it preserves the security property that motivated #45641: never over-grant from a coarse field.

Note base_role cannot be admin — the enum tops out at maintain. A custom role therefore can never satisfy roles: [admin], which is correct behaviour and should be asserted in tests.

Implementation plan

  1. actions/setup/js/check_permissions_utils.cjs — in checkRepositoryPermission:

    • Remove rawInheritedRole / inheritedRole / normalizedInheritedRole and the inherited_role entry in the @type annotation.
    • Add resolveCustomRoleBaseRole(org, roleName) that calls github.rest.orgs.listCustomRepoRoles({ org }), matches custom_roles[].name against role_name case-insensitively, and returns the normalized base_role (maintain stays maintain). Return "" when the role is not found or the request fails.
    • Replace inheritedStandardRole with this resolved value, keeping the existing isCustomRole guard so the lookup applies only to custom roles (standard roles keep matching on role_name).
    • Keep failing closed when the resolved role is "".
    • Update the debug lines to report the resolved base_role instead of inherited=, so the decision stays traceable under ACTIONS_RUNNER_DEBUG=true.
    • Update the explanatory comment above the match loop, which currently references "inherited standard role from custom-role metadata".
  2. actions/setup/js/check_permissions_utils.test.cjs — rewrite the custom-role tests against realistic fixtures:

    • Replace every inherited_role: "..." mock with a listCustomRepoRoles mock returning { total_count, custom_roles: [{ name, base_role }] }.
    • Add a regression test using the real-world payload above (permission: "write", a custom role_name) resolved via base_role: "write", asserting authorized: true for ["admin", "maintain", "write"].
    • Add a test where base_role is maintain and assert authorization for ["maintain"] but denial for ["admin"].
    • Assert case-insensitive matching of role_name against custom_roles[].name.
    • Keep the fail-closed test, with the role absent from the returned list.
    • Retain existing coverage for: custom role authorized by literal name in on.roles; empty role_name not treated as a custom role.
  3. Verification — confirm no other call site or compiled artifact references the removed field:

    grep -rn "inherited_role\|inheritedRole" .
  4. Run make agent-finish.

Impact

Any organization using custom repository roles cannot use on.roles gating at all — the workflow silently refuses to run for legitimate maintainers. The only current workaround is to use all in on.roles.

Environment

Activity

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Type

No type

Projects

No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions