Diagnostic GitHub API
The scope is right, the account's role on the repo is read
The bot has a token with the repo scope. It reads the repository, lists the pull requests, fetches the diff, and then the merge comes back 403. Somebody checks the token, sees repo ticked, mints a wider one with workflow and admin:org on it for good measure, and gets the same 403 back in the same millisecond. The scopes were never the ceiling. The account those scopes act on behalf of is a collaborator with read on that repository, and no token minted by that account will ever be able to merge anything into it.
A scope bounds what a token may do on the account's behalf. It cannot grant the account access it does not have. So a token carrying repo held by a user whose role on that repository is read is powerless there, and the fix is a role change made by somebody with admin, not a new token.
Read the role rather than deducing it from a refusal. GET /repos/{owner}/{repo} returns a permissions object describing the authenticated user's own access — {"admin": false, "maintain": false, "push": false, "triage": false, "pull": true} — and the highest flag that is true is the role. push: false explains every failed write in one line. For somebody else's access, GET /repos/{owner}/{repo}/collaborators/{username}/permission returns both a legacy permission string and an exact role_name; prefer role_name, because the legacy field collapses maintain into write and triage into read.
The problem in plain words
What makes this one expensive is that every visible signal points at the credential. The failure is a 403, which is the status a missing scope produces. The token is the thing the engineer controls and the thing they just changed. The account's role lives in a settings page they may not be able to open, on a repository that may belong to another team, and it is not mentioned anywhere in the error.
So the loop runs: widen the scopes, mint a fresh token, try again, and get the identical refusal. Then somebody suspects the token type and tries a fine-grained one, which fails differently and adds a second mystery. Then somebody suspects the endpoint and reads the documentation for the merge API, which is correct and unhelpful. Meanwhile the same token is happily reading the repository, listing branches and fetching pull requests, which is taken as proof that access is fine.
The other thing that hides it is that read access is often invisible to the person holding it. Being able to see a private repository, clone it, open issues on it and comment feels like being on the team. On GitHub it is one role below the first role that can push, and the difference only shows up on the first write. A CI account added to twelve repositories with write and a thirteenth with read looks completely uniform until the thirteenth repository is the one being released.
Why it happens
Two independent gates, and only one of them is on the token. A call succeeds when the credential is allowed to make it and the account behind the credential has enough access to the resource. Scopes and fine-grained permissions are the first gate. The repository role is the second. Widening the first cannot open the second, which is why the reminted token changed nothing, and it is also why an over-wide token can sit for months in a job that never noticed it was over-wide.
The permissions object is the authenticated user's own access, not the repository's settings. That is the part people misread. It is not a description of the repository; it is a description of you on that repository, computed per request, and it arrives free on a call the integration is already making. Five booleans in a fixed hierarchy: pull, triage, push, maintain, admin. Every higher role implies the ones below it, so the finding is simply the highest true.
The role that matters is the effective one. It is the union of a direct collaborator grant, every team the account belongs to, and the organization's base permission. That is a strength for diagnosis — the object already accounts for all three, so there is no arithmetic to get wrong — and a limit for repair, because the object reports the effect and never the source. Naming which team or which base permission produced a role needs organization-level reads that a repository-scoped token does not have. State that plainly rather than guessing; the repair is still correct, it just has more than one place to be applied.
The legacy permission string lies by rounding. The collaborator permission endpoint returns permission with four possible values and role_name with the real one. A maintainer comes back as write and a triager comes back as read. If you are checking whether somebody can label an issue, the legacy field will tell you no about a triager who can. Read role_name, fall back to permission only when it is absent, and treat an unrecognised role_name as a custom organization role whose abilities the API does not enumerate.
The refusal is predictable, so it should never be provoked. This is the whole reason the note exists in a read-only section. The role is readable in advance, the minimum role for each action is documented, and a comparison between the two is arithmetic. A tool that establishes "this account cannot merge" by attempting a merge has, in the successful case, merged something. Reading the role costs one request and answers for every action at once.
A 403 whose cause is the role looks nothing like one whose cause is the token, once you have both readings. A missing classic scope arrives with x-accepted-oauth-scopes naming what would have worked, which is its own note; a fine-grained token gets a message that names the token and no header describing it. A role that is too low produces neither. It produces a plain refusal beside a permissions object that already said push: false before you called.
The fix, as a flow
The whole finding is one object that arrives on a call nobody thinks to read. GET /repos returns a permissions map for the authenticated user, and the highest true flag in it is the account's role on that repository. Everything after that is a comparison against a table of what each role unlocks, done locally, so the script can say the merge will be refused without going anywhere near the merge. The scopes are read too, and only so the script can rule them out loudly: a token holding repo against an account holding read is the exact shape that sends people to widen a credential that was never the ceiling.
How to fix it
Read the role off the call you are already making
Point the script at the repository and it reads GET /repos/{owner}/{repo}, pulls the permissions object out and names the highest role that is true. One request, and it is the same request your integration makes to resolve the repository, so in practice this costs nothing you were not already spending.
Name the action instead of describing the symptom
Pass --action merge-pull-request, or label-issue, or push-branch. The script holds the documented minimum role for each and compares. The output is a sentence rather than a table: this account holds read, merging a pull request needs write, so the call will be refused. It is the same answer the 403 gives, arrived at without asking for the 403.
Rule the token out loudly
The script reads x-oauth-scopes off GET /user and prints the combination explicitly when it finds it: a token carrying repo against an account carrying read. That line exists to stop the next hour of work, because that is the exact shape that sends people to mint a wider credential. It also names the token type from its prefix, since a fine-grained token has no scopes to widen at all.
Ask about a specific account when it is not your own
With --user octobot the script adds GET /repos/{owner}/{repo}/collaborators/{username}/permission and reports role_name rather than the legacy permission field, so a maintainer is not reported as a plain writer and a triager is not reported as a reader. An unrecognised role_name is reported as a custom organization role and left unpriced, because the API does not publish what a custom role can do.
Take the repair to the role, and to the right place
The printed repair names the role to raise to and the smallest one that suffices, which is often lower than people assume — labelling and closing issues need triage, not write. It also says what it cannot tell you: whether the current role came from a direct grant, a team or the organization's base permission. Nothing is changed by the script. It prints and exits.
How to check it worked
After the role is raised, the same read reports the new one and the action stops being a prediction of failure.
GITHUB_TOKEN=$READ_ONLY_TOKEN python3 github_repo_role.py octo-org/deploy-tools \
--action merge-pull-request
# read cost: 2 request(s) against the core hourly quota
# token: classic PAT, scopes=repo, workflow
# octo-org/deploy-tools: permissions={admin:false maintain:false push:false triage:false pull:true}
# role: read
# role-insufficient: this account holds 'read' and merge-pull-request needs
# 'write', which is 2 role(s) higher.
# scopes-are-not-the-ceiling: the token carries 'repo', which is as wide as a
# classic token gets. Reminting it wider cannot change this answer.
# repair: have somebody with admin raise this account to 'write' on
# octo-org/deploy-tools, or add it to a team that has it. The permissions
# object reports the effective role and never its source, so the grant may
# need making in a team or in the org's base permission.
The full code
Almost all of this is a comparison between two strings, and the interesting part is that the comparison is enough. The role hierarchy is fixed and documented, every higher role implies the lower ones, and the minimum role for each action is a table. So the script reads one object, resolves it to a role, and answers for any action without touching the endpoint that would have refused it. The two live reads are the repository and the token's own identity; the third, only when you name somebody else, is the collaborator permission endpoint, which is read for role_name rather than for the legacy field that rounds two roles into their neighbours.
"""Read an account's role on one repository instead of provoking a 403.
Read only. GET requests and nothing else. This script is about writes being
refused and it never attempts one: the role is readable in advance on an
ordinary repository read, the minimum role for each action is documented, and
the comparison between them is arithmetic done locally. A tool that proved
"this account cannot merge" by attempting a merge would, in the case where it
was wrong, have merged something.
The point of the note: an OAuth scope bounds what a token may do on the
account's behalf and cannot grant the account access it does not have. A token
carrying `repo` held by an account whose role on that repository is read is
powerless there, and widening the token cannot change that.
What this can and cannot see: the permissions object is the effective role,
already accounting for a direct collaborator grant, every team the account is
in and the organization base permission. It never reports which of those
produced the role. Naming the source needs organization reads a repository
token does not have, so this script reports the effect and says so.
Environment:
GITHUB_TOKEN a token with read access to the repository
"""
import argparse
import json
import logging
import os
import sys
import requests
logging.basicConfig(level=logging.INFO, format="%(asctime)s %(levelname)s %(message)s")
log = logging.getLogger("github_repo_role")
API = "https://api.github.com"
UA = "github-repo-role/1.0"
# Weakest first. Every role implies the ones below it, which is why resolving
# the permissions object is "highest flag that is true" and nothing more.
ROLES = ("none", "read", "triage", "write", "maintain", "admin")
# The booleans GitHub returns, paired with the role each one names. Ordered
# strongest first so the first hit is the answer.
PERMISSION_FLAGS = (
("admin", "admin"),
("maintain", "maintain"),
("push", "write"),
("triage", "triage"),
("pull", "read"),
)
# The legacy `permission` string on the collaborator endpoint has four values
# and rounds two roles into their neighbours: a maintainer reads as write, a
# triager reads as read. role_name carries the real one.
LEGACY_ROUNDING = {
"admin": "admin",
"write": "write",
"read": "read",
"none": "none",
}
# Minimum role for actions people actually get refused on. Kept short and
# documented rather than exhaustive: the value is in the four rows that
# surprise people, not in a transcription of the whole roles table.
ACTION_MINIMUM = {
"read-code": "read",
"clone": "read",
"open-issue": "read",
"comment": "read",
"label-issue": "triage",
"close-issue": "triage",
"assign-issue": "triage",
"request-review": "triage",
"push-branch": "write",
"merge-pull-request": "write",
"create-release": "write",
"dismiss-review": "write",
"manage-repository-settings": "maintain",
"manage-webhooks": "admin",
"add-collaborator": "admin",
"change-visibility": "admin",
}
# Longest prefixes first so a future prefix extending an existing one is not
# swallowed by its shorter neighbour.
TOKEN_PREFIXES = (
("github_pat_", "fine-grained PAT"),
("ghp_", "classic PAT"),
("gho_", "OAuth user token"),
("ghu_", "App user-to-server token"),
("ghs_", "App installation token"),
("ghr_", "App refresh token"),
)
# The widest a classic token gets on repositories. Holding it and still being
# refused is the shape this note exists to interrupt.
WIDEST_CLASSIC_REPO_SCOPE = "repo"
def read_cost(with_user=False):
"""Requests this run will spend against the core quota. Pure."""
return 3 if with_user else 2
def token_kind(token):
"""Name the credential from its prefix. Pure; nothing leaves the machine."""
value = (token or "").strip()
for prefix, name in TOKEN_PREFIXES:
if value.startswith(prefix):
return name
return "unknown"
def scope_list(header_value):
"""Read x-oauth-scopes into a list, keeping absent and empty apart. Pure.
A classic token with nothing ticked sends the header with an empty value; a
fine-grained token or an App token does not send it at all. Collapsing both
to [] would lose the signal that decides whether "widen the scopes" is even
a sentence that applies.
"""
if header_value is None:
return None
return [s.strip() for s in header_value.split(",") if s.strip()]
def role_rank(role):
"""Position in the hierarchy, or -1 for something unrecognised. Pure."""
try:
return ROLES.index(str(role or "none").strip().lower())
except ValueError:
return -1
def role_from_permissions(permissions):
"""The role a permissions object describes. Pure.
Highest true flag wins. An empty or missing object is "unreported" rather
than "none": an unauthenticated read returns no permissions object at all,
and reporting that as no access would be a different and wrong finding.
"""
if not isinstance(permissions, dict) or not permissions:
return "unreported"
for flag, role in PERMISSION_FLAGS:
if permissions.get(flag) is True:
return role
return "none"
def role_from_collaborator(payload):
"""Resolve the collaborator permission endpoint. Pure.
Returns (role, exact, note). role_name is preferred because the legacy
`permission` field rounds maintain down to write and triage down to read.
An unrecognised role_name is a custom organization role: named, not priced,
because the API does not publish what a custom role can do.
"""
if not isinstance(payload, dict):
return ("unreported", False, "no collaborator permission payload was read.")
name = str(payload.get("role_name") or "").strip().lower()
legacy = str(payload.get("permission") or "").strip().lower()
if name and role_rank(name) >= 0:
return (name, True, "role_name reported the exact role.")
if name:
return ("custom:" + name, False,
"role_name is '%s', a custom organization role. Its abilities "
"are defined by the organization and are not published through "
"this API, so nothing here prices it." % name)
if legacy in LEGACY_ROUNDING:
return (LEGACY_ROUNDING[legacy], False,
"only the legacy permission field was present. It rounds "
"maintain to write and triage to read, so a maintainer and a "
"triager are both misreported by it.")
return ("unreported", False, "neither role_name nor permission was present.")
def can(role, action):
"""Does this role reach the documented minimum for this action. Pure."""
needed = ACTION_MINIMUM.get(str(action or "").strip().lower())
if needed is None:
return None
held = role_rank(role)
if held < 0:
return None
return held >= role_rank(needed)
def deficit(role, action):
"""How many roles short this account is for this action. Pure.
0 means it is sufficient. None means the question could not be asked, which
happens for a custom role or an unknown action and is not the same as no.
"""
needed = ACTION_MINIMUM.get(str(action or "").strip().lower())
held = role_rank(role)
if needed is None or held < 0:
return None
return max(0, role_rank(needed) - held)
def blocked_actions(role):
"""Every documented action this role cannot perform. Pure."""
held = role_rank(role)
if held < 0:
return []
return sorted(a for a, need in ACTION_MINIMUM.items() if held < role_rank(need))
def scopes_are_the_ceiling(role, scopes, kind, action):
"""Is widening the credential capable of changing the answer. Pure.
Returns (state, detail). This is the question the reader is actually asking
and it deserves an explicit answer rather than an inference, because the
default guess is yes and the default guess is wrong.
"""
short = deficit(role, action)
if short is None or short == 0:
return ("not-the-question",
"the role is sufficient for this action, so the credential is "
"the next thing to look at rather than the first.")
if scopes is None:
return ("no-scopes-to-widen",
"this credential is a %s and carries no OAuth scopes at all, so "
"there is nothing to widen. Its per-resource permissions are a "
"separate gate and neither gate raises a repository role."
% kind)
if WIDEST_CLASSIC_REPO_SCOPE in scopes:
return ("scopes-are-not-the-ceiling",
"the token carries '%s', which is as wide as a classic token "
"gets on repositories. Reminting it wider cannot change this "
"answer." % WIDEST_CLASSIC_REPO_SCOPE)
return ("two-gates-open",
"the token holds %s and not '%s', so the scope is worth fixing too. "
"Fixing it alone will not help: the role is short as well, and both "
"gates have to open."
% (", ".join(scopes) or "no scopes at all", WIDEST_CLASSIC_REPO_SCOPE))
def verdict(role, action):
"""Classify one account's role against one action. Pure. (state, detail)."""
if str(role).startswith("custom:"):
return ("custom-role",
"the role is a custom organization role, which this script "
"names and does not price. Ask an organization owner what it "
"grants, or compare against the base role it was built from.")
if role == "unreported":
return ("role-unreported",
"no permissions object came back. An unauthenticated read never "
"carries one, so authenticate before reading anything into this.")
if role == "none":
return ("no-access",
"the account has no role on this repository at all. Reads of a "
"private repository will 404 rather than 403, which is a "
"different symptom with the same cause.")
short = deficit(role, action)
if short is None:
return ("action-unknown",
"no documented minimum role is held here for that action, so "
"the role is reported and the comparison is left to you.")
if short == 0:
return ("role-sufficient",
"this account holds '%s' and %s needs '%s', so the role is not "
"what refused the call."
% (role, action, ACTION_MINIMUM[action]))
return ("role-insufficient",
"this account holds '%s' and %s needs '%s', which is %d role(s) "
"higher." % (role, action, ACTION_MINIMUM[action], short))
def repair(state, role, action, subject="this account"):
"""The sentence a reader has to act on. Pure."""
if state == "role-insufficient":
return ("have somebody with admin raise %s to '%s' on this repository, "
"or add it to a team that has it. The permissions object "
"reports the effective role and never its source, so the grant "
"may need making in a team or in the org's base permission."
% (subject, ACTION_MINIMUM[action]))
if state == "no-access":
return ("grant %s a role on the repository. Until then the repository "
"is invisible rather than forbidden if it is private." % subject)
if state == "role-sufficient":
return ("nothing on the role. Read the refusal's headers next: a "
"classic token names what it accepts in x-accepted-oauth-scopes "
"and a fine-grained one names nothing at all.")
if state == "custom-role":
return ("ask an organization owner which base role this custom role was "
"built from, then compare that against the action.")
if state == "role-unreported":
return ("authenticate the read. The permissions object only arrives on "
"an authenticated request.")
return ("name an action with --action to turn the role into a verdict. "
"The role itself is already reported above.")
def get(session, path):
"""One GET. Returns the response object."""
r = session.get(API + path, timeout=30)
if r.status_code == 401:
raise SystemExit("401 from GitHub: GITHUB_TOKEN is missing, malformed "
"or revoked")
return r
def main():
ap = argparse.ArgumentParser(description=__doc__)
ap.add_argument("repo", help="owner/name of the repository")
ap.add_argument("--action", default="merge-pull-request",
help="the action being refused, e.g. merge-pull-request, "
"label-issue, push-branch")
ap.add_argument("--user",
help="report this account's role instead of the token's own")
args = ap.parse_args()
token = os.environ.get("GITHUB_TOKEN")
if not token:
log.error("set GITHUB_TOKEN (a read-only token is enough)")
return 2
if "/" not in args.repo:
log.error("repo should be owner/name")
return 2
log.info("read cost: %d request(s) against the core hourly quota",
read_cost(bool(args.user)))
session = requests.Session()
session.headers.update({
"Authorization": "Bearer " + token,
"Accept": "application/vnd.github+json",
"X-GitHub-Api-Version": "2022-11-28",
# GitHub rejects requests with no User-Agent outright.
"User-Agent": UA,
})
kind = token_kind(token)
me = get(session, "/user")
scopes = scope_list(me.headers.get("x-oauth-scopes"))
log.info("token: %s, scopes=%s", kind,
"none" if scopes is None else (", ".join(scopes) or "empty"))
repo_response = get(session, "/repos/" + args.repo)
if repo_response.status_code != 200:
log.error("%s: HTTP %s reading the repository. A 404 here is its own "
"note; this one starts from a repository you can read.",
args.repo, repo_response.status_code)
return 2
repo = repo_response.json()
permissions = repo.get("permissions") or {}
role = role_from_permissions(permissions)
subject = "this account"
note = None
if args.user:
collab = get(session, "/repos/%s/collaborators/%s/permission"
% (args.repo, args.user))
if collab.status_code == 200:
role, _exact, note = role_from_collaborator(collab.json())
subject = args.user
else:
log.warning("collaborator permission read returned HTTP %s; "
"reporting the token's own role instead",
collab.status_code)
log.info("%s: permissions=%s", args.repo, json.dumps(permissions))
log.info("role: %s", role)
if note:
log.info("role source: %s", note)
state, detail = verdict(role, args.action)
log.info("%s: %s", state, detail)
ceiling_state, ceiling_detail = scopes_are_the_ceiling(
role, scopes, kind, args.action)
log.info("%s: %s", ceiling_state, ceiling_detail)
log.info("repair: %s", repair(state, role, args.action, subject))
blocked = blocked_actions(role)
if blocked:
log.info("also blocked at this role: %s", ", ".join(blocked))
print(json.dumps({
"repository": args.repo,
"subject": subject,
"token_kind": kind,
"scopes": scopes,
"permissions": permissions,
"role": role,
"action": args.action,
"minimum_role": ACTION_MINIMUM.get(args.action),
"roles_short": deficit(role, args.action),
"state": state,
"detail": detail,
"credential_state": ceiling_state,
"credential_detail": ceiling_detail,
"blocked_actions": blocked,
"repair": repair(state, role, args.action, subject),
}, indent=2, default=str))
return 1 if state in ("role-insufficient", "no-access") else 0
if __name__ == "__main__":
sys.exit(main())
/**
* Read an account's role on one repository instead of provoking a 403.
*
* Read only. GET requests and nothing else. The note is about writes being
* refused and this script never attempts one: the role arrives on an ordinary
* repository read, the minimum role for each action is documented, and the
* comparison between them happens locally.
*
* A scope bounds what a token may do on the account's behalf and cannot grant
* the account access it does not have, so a token carrying `repo` held by an
* account with read on that repository is powerless there.
*
* Environment:
* GITHUB_TOKEN a token with read access to the repository
* GITHUB_REPO owner/name
* GITHUB_ACTION the action being refused, default merge-pull-request
* GITHUB_USER report this account's role instead of the token's own
*/
const API = 'https://api.github.com';
const UA = 'github-repo-role/1.0';
/** Weakest first. Every role implies the ones below it. */
export const ROLES = ['none', 'read', 'triage', 'write', 'maintain', 'admin'];
/** The booleans GitHub returns, strongest first, so the first hit is the role. */
export const PERMISSION_FLAGS = [
['admin', 'admin'],
['maintain', 'maintain'],
['push', 'write'],
['triage', 'triage'],
['pull', 'read'],
];
/** The legacy permission string rounds maintain to write and triage to read. */
export const LEGACY_ROUNDING = {
admin: 'admin', write: 'write', read: 'read', none: 'none',
};
/** Minimum role for the actions people actually get refused on. */
export const ACTION_MINIMUM = {
'read-code': 'read',
clone: 'read',
'open-issue': 'read',
comment: 'read',
'label-issue': 'triage',
'close-issue': 'triage',
'assign-issue': 'triage',
'request-review': 'triage',
'push-branch': 'write',
'merge-pull-request': 'write',
'create-release': 'write',
'dismiss-review': 'write',
'manage-repository-settings': 'maintain',
'manage-webhooks': 'admin',
'add-collaborator': 'admin',
'change-visibility': 'admin',
};
/** Longest prefixes first. */
export const TOKEN_PREFIXES = [
['github_pat_', 'fine-grained PAT'],
['ghp_', 'classic PAT'],
['gho_', 'OAuth user token'],
['ghu_', 'App user-to-server token'],
['ghs_', 'App installation token'],
['ghr_', 'App refresh token'],
];
/** The widest a classic token gets on repositories. */
export const WIDEST_CLASSIC_REPO_SCOPE = 'repo';
/** Requests this run will spend against the core quota. Pure. */
export function readCost(withUser = false) {
return withUser ? 3 : 2;
}
/** Name the credential from its prefix. Pure. */
export function tokenKind(token) {
const value = String(token ?? '').trim();
for (const [prefix, name] of TOKEN_PREFIXES) {
if (value.startsWith(prefix)) return name;
}
return 'unknown';
}
/** Read x-oauth-scopes into a list, keeping absent and empty apart. Pure. */
export function scopeList(headerValue) {
if (headerValue === null || headerValue === undefined) return null;
return String(headerValue).split(',').map((s) => s.trim()).filter(Boolean);
}
/** Position in the hierarchy, or -1 for something unrecognised. Pure. */
export function roleRank(role) {
return ROLES.indexOf(String(role ?? 'none').trim().toLowerCase());
}
/** The role a permissions object describes. Pure. */
export function roleFromPermissions(permissions) {
if (!permissions || typeof permissions !== 'object'
|| Object.keys(permissions).length === 0) {
return 'unreported';
}
for (const [flag, role] of PERMISSION_FLAGS) {
if (permissions[flag] === true) return role;
}
return 'none';
}
/** Resolve the collaborator permission endpoint. Pure. [role, exact, note]. */
export function roleFromCollaborator(payload) {
if (!payload || typeof payload !== 'object') {
return ['unreported', false, 'no collaborator permission payload was read.'];
}
const name = String(payload.role_name ?? '').trim().toLowerCase();
const legacy = String(payload.permission ?? '').trim().toLowerCase();
if (name && roleRank(name) >= 0) {
return [name, true, 'role_name reported the exact role.'];
}
if (name) {
return [`custom:${name}`, false, `role_name is '${name}', a custom `
+ 'organization role. Its abilities are defined by the organization and '
+ 'are not published through this API, so nothing here prices it.'];
}
if (Object.prototype.hasOwnProperty.call(LEGACY_ROUNDING, legacy)) {
return [LEGACY_ROUNDING[legacy], false, 'only the legacy permission field '
+ 'was present. It rounds maintain to write and triage to read, so a '
+ 'maintainer and a triager are both misreported by it.'];
}
return ['unreported', false, 'neither role_name nor permission was present.'];
}
/** Does this role reach the documented minimum for this action. Pure. */
export function can(role, action) {
const needed = ACTION_MINIMUM[String(action ?? '').trim().toLowerCase()];
if (needed === undefined) return null;
const held = roleRank(role);
if (held < 0) return null;
return held >= roleRank(needed);
}
/** How many roles short this account is. 0 is sufficient, null unanswerable. */
export function deficit(role, action) {
const needed = ACTION_MINIMUM[String(action ?? '').trim().toLowerCase()];
const held = roleRank(role);
if (needed === undefined || held < 0) return null;
return Math.max(0, roleRank(needed) - held);
}
/** Every documented action this role cannot perform. Pure. */
export function blockedActions(role) {
const held = roleRank(role);
if (held < 0) return [];
return Object.keys(ACTION_MINIMUM)
.filter((a) => held < roleRank(ACTION_MINIMUM[a]))
.sort();
}
/** Is widening the credential capable of changing the answer. Pure. */
export function scopesAreTheCeiling(role, scopes, kind, action) {
const short = deficit(role, action);
if (short === null || short === 0) {
return ['not-the-question', 'the role is sufficient for this action, so the '
+ 'credential is the next thing to look at rather than the first.'];
}
if (scopes === null || scopes === undefined) {
return ['no-scopes-to-widen', `this credential is a ${kind} and carries no `
+ 'OAuth scopes at all, so there is nothing to widen. Its per-resource '
+ 'permissions are a separate gate and neither gate raises a repository role.'];
}
if (scopes.includes(WIDEST_CLASSIC_REPO_SCOPE)) {
return ['scopes-are-not-the-ceiling', `the token carries `
+ `'${WIDEST_CLASSIC_REPO_SCOPE}', which is as wide as a classic token `
+ 'gets on repositories. Reminting it wider cannot change this answer.'];
}
return ['two-gates-open', `the token holds ${scopes.join(', ') || 'no scopes at all'} `
+ `and not '${WIDEST_CLASSIC_REPO_SCOPE}', so the scope is worth fixing too. `
+ 'Fixing it alone will not help: the role is short as well, and both gates '
+ 'have to open.'];
}
/** Classify one account's role against one action. Pure. [state, detail]. */
export function verdict(role, action) {
if (String(role).startsWith('custom:')) {
return ['custom-role', 'the role is a custom organization role, which this '
+ 'script names and does not price. Ask an organization owner what it '
+ 'grants, or compare against the base role it was built from.'];
}
if (role === 'unreported') {
return ['role-unreported', 'no permissions object came back. An '
+ 'unauthenticated read never carries one, so authenticate before reading '
+ 'anything into this.'];
}
if (role === 'none') {
return ['no-access', 'the account has no role on this repository at all. '
+ 'Reads of a private repository will 404 rather than 403, which is a '
+ 'different symptom with the same cause.'];
}
const short = deficit(role, action);
if (short === null) {
return ['action-unknown', 'no documented minimum role is held here for that '
+ 'action, so the role is reported and the comparison is left to you.'];
}
if (short === 0) {
return ['role-sufficient', `this account holds '${role}' and ${action} needs `
+ `'${ACTION_MINIMUM[action]}', so the role is not what refused the call.`];
}
return ['role-insufficient', `this account holds '${role}' and ${action} needs `
+ `'${ACTION_MINIMUM[action]}', which is ${short} role(s) higher.`];
}
/** The sentence a reader has to act on. Pure. */
export function repair(state, role, action, subject = 'this account') {
if (state === 'role-insufficient') {
return `have somebody with admin raise ${subject} to '${ACTION_MINIMUM[action]}' `
+ 'on this repository, or add it to a team that has it. The permissions '
+ 'object reports the effective role and never its source, so the grant may '
+ "need making in a team or in the org's base permission.";
}
if (state === 'no-access') {
return `grant ${subject} a role on the repository. Until then the repository `
+ 'is invisible rather than forbidden if it is private.';
}
if (state === 'role-sufficient') {
return 'nothing on the role. Read the refusal\'s headers next: a classic '
+ 'token names what it accepts in x-accepted-oauth-scopes and a '
+ 'fine-grained one names nothing at all.';
}
if (state === 'custom-role') {
return 'ask an organization owner which base role this custom role was built '
+ 'from, then compare that against the action.';
}
if (state === 'role-unreported') {
return 'authenticate the read. The permissions object only arrives on an '
+ 'authenticated request.';
}
return 'name an action to turn the role into a verdict. The role itself is '
+ 'already reported above.';
}
function headers(token) {
return {
Authorization: `Bearer ${token}`,
Accept: 'application/vnd.github+json',
'X-GitHub-Api-Version': '2022-11-28',
'User-Agent': UA,
};
}
async function main() {
const token = process.env.GITHUB_TOKEN;
const repoName = process.env.GITHUB_REPO;
if (!token || !repoName) {
console.error('set GITHUB_TOKEN (read-only is enough) and GITHUB_REPO');
process.exitCode = 2;
return;
}
const action = process.env.GITHUB_ACTION || 'merge-pull-request';
const user = process.env.GITHUB_USER || '';
console.log(`read cost: ${readCost(Boolean(user))} request(s) against the core hourly quota`);
const me = await fetch(`${API}/user`, { headers: headers(token) });
const kind = tokenKind(token);
const scopes = scopeList(me.headers.get('x-oauth-scopes'));
console.log(`token: ${kind}, scopes=${scopes === null ? 'none' : (scopes.join(', ') || 'empty')}`);
const res = await fetch(`${API}/repos/${repoName}`, { headers: headers(token) });
if (res.status !== 200) {
console.error(`${repoName}: HTTP ${res.status} reading the repository`);
process.exitCode = 2;
return;
}
const repo = await res.json();
const permissions = repo.permissions || {};
let role = roleFromPermissions(permissions);
let subject = 'this account';
if (user) {
const collab = await fetch(
`${API}/repos/${repoName}/collaborators/${user}/permission`,
{ headers: headers(token) },
);
if (collab.status === 200) {
const [resolved, , note] = roleFromCollaborator(await collab.json());
role = resolved;
subject = user;
console.log(`role source: ${note}`);
}
}
console.log(`${repoName}: permissions=${JSON.stringify(permissions)}`);
console.log(`role: ${role}`);
const [state, detail] = verdict(role, action);
console.log(`${state}: ${detail}`);
const [ceilingState, ceilingDetail] = scopesAreTheCeiling(role, scopes, kind, action);
console.log(`${ceilingState}: ${ceilingDetail}`);
console.log(`repair: ${repair(state, role, action, subject)}`);
console.log(JSON.stringify({
repository: repoName,
subject,
token_kind: kind,
scopes,
permissions,
role,
action,
minimum_role: ACTION_MINIMUM[action] ?? null,
roles_short: deficit(role, action),
state,
detail,
credential_state: ceilingState,
blocked_actions: blockedActions(role),
repair: repair(state, role, action, subject),
}, null, 2));
process.exitCode = ['role-insufficient', 'no-access'].includes(state) ? 1 : 0;
}
// Guarded so importing this file from the test runner does not start a run and
// fail the suite even as every test passes.
if (import.meta.url === `file://${process.argv[1]}`) {
main().catch((err) => { console.error(err.message); process.exitCode = 2; });
}
Add a test
The hierarchy is asserted first, because everything else is a comparison against it: the highest true flag is the role, an empty object is unreported rather than none, and an unauthenticated read is therefore never mistaken for an account with no access. Then the two rounding traps, both of which produce a wrong answer that looks right — the legacy permission field reporting a maintainer as a writer and a triager as a reader, and a custom organization role that must be named and left unpriced. The last group is the one the note exists for: a token holding repo beside an account holding read has to come back as scopes-are-not-the-ceiling, in those words, because that is the sentence that stops the next token from being minted.
from github_repo_role import (
ACTION_MINIMUM, ROLES, blocked_actions, can, deficit, read_cost, repair,
role_from_collaborator, role_from_permissions, role_rank, scope_list,
scopes_are_the_ceiling, token_kind, verdict,
)
READ_ONLY = {"admin": False, "maintain": False, "push": False,
"triage": False, "pull": True}
TRIAGE = {"admin": False, "maintain": False, "push": False,
"triage": True, "pull": True}
WRITE = {"admin": False, "maintain": False, "push": True,
"triage": True, "pull": True}
ADMIN = {"admin": True, "maintain": True, "push": True,
"triage": True, "pull": True}
def test_the_hierarchy_runs_weakest_to_strongest():
assert ROLES == ("none", "read", "triage", "write", "maintain", "admin")
assert role_rank("read") < role_rank("triage") < role_rank("write")
assert role_rank("write") < role_rank("maintain") < role_rank("admin")
assert role_rank("nonsense") == -1
def test_the_role_is_the_highest_true_flag():
assert role_from_permissions(READ_ONLY) == "read"
assert role_from_permissions(TRIAGE) == "triage"
assert role_from_permissions(WRITE) == "write"
assert role_from_permissions(ADMIN) == "admin"
def test_an_absent_permissions_object_is_unreported_not_none():
# An unauthenticated read carries no permissions object at all. Reporting
# that as "no access" would be a different and wrong finding.
assert role_from_permissions({}) == "unreported"
assert role_from_permissions(None) == "unreported"
assert role_from_permissions({"admin": False, "pull": False}) == "none"
def test_read_explains_every_refused_write_in_one_flag():
assert READ_ONLY["push"] is False
assert can("read", "merge-pull-request") is False
assert can("read", "push-branch") is False
assert can("read", "read-code") is True
def test_labelling_needs_triage_and_not_write():
# The role people are told to ask for is usually higher than the one they
# need, and this is the row that shows it.
assert ACTION_MINIMUM["label-issue"] == "triage"
assert can("triage", "label-issue") is True
assert can("triage", "merge-pull-request") is False
assert deficit("triage", "merge-pull-request") == 1
def test_the_deficit_counts_roles_not_booleans():
assert deficit("read", "merge-pull-request") == 2
assert deficit("read", "add-collaborator") == 4
assert deficit("admin", "merge-pull-request") == 0
assert deficit("read", "not-an-action") is None
def test_the_legacy_permission_field_rounds_two_roles_away():
exact, is_exact, _ = role_from_collaborator(
{"permission": "write", "role_name": "maintain"})
assert (exact, is_exact) == ("maintain", True)
rounded, is_exact, note = role_from_collaborator({"permission": "write"})
assert rounded == "write" and is_exact is False
assert "rounds maintain to write" in note
# A triager comes back as read through the legacy field, which would deny a
# label they can actually apply.
assert role_from_collaborator({"permission": "read"})[0] == "read"
assert role_from_collaborator({"role_name": "triage"})[0] == "triage"
def test_a_custom_org_role_is_named_and_not_priced():
role, is_exact, note = role_from_collaborator(
{"permission": "read", "role_name": "security-auditor"})
assert role == "custom:security-auditor"
assert is_exact is False and "custom organization role" in note
state, detail = verdict(role, "merge-pull-request")
assert state == "custom-role"
assert "does not price" in detail or "not priced" in detail
def test_a_repo_scope_beside_a_read_role_is_the_headline():
state, detail = scopes_are_the_ceiling(
"read", ["repo", "workflow"], "classic PAT", "merge-pull-request")
assert state == "scopes-are-not-the-ceiling"
assert "cannot change this answer" in detail
def test_a_fine_grained_token_has_no_scopes_to_widen():
state, detail = scopes_are_the_ceiling(
"read", None, "fine-grained PAT", "merge-pull-request")
assert state == "no-scopes-to-widen"
assert "nothing to widen" in detail
def test_a_narrow_scope_and_a_low_role_are_both_reported():
state, detail = scopes_are_the_ceiling(
"read", ["public_repo"], "classic PAT", "merge-pull-request")
assert state == "two-gates-open"
assert "both" in detail
def test_a_sufficient_role_sends_the_reader_to_the_credential():
state, _ = scopes_are_the_ceiling(
"write", ["repo"], "classic PAT", "merge-pull-request")
assert state == "not-the-question"
assert verdict("write", "merge-pull-request")[0] == "role-sufficient"
def test_the_verdict_and_its_repair_hang_together():
state, detail = verdict("read", "merge-pull-request")
assert state == "role-insufficient"
assert "2 role(s) higher" in detail
fix = repair(state, "read", "merge-pull-request", "octobot")
assert "octobot" in fix and "'write'" in fix
assert "never its source" in fix
def test_no_access_is_kept_apart_from_a_low_role():
state, detail = verdict("none", "merge-pull-request")
assert state == "no-access"
assert "404" in detail
assert verdict("unreported", "merge-pull-request")[0] == "role-unreported"
def test_the_blocked_list_grows_as_the_role_shrinks():
assert "merge-pull-request" in blocked_actions("read")
assert "label-issue" in blocked_actions("read")
assert "label-issue" not in blocked_actions("triage")
assert blocked_actions("admin") == []
def test_scopes_absent_and_scopes_empty_are_different_readings():
assert scope_list(None) is None
assert scope_list("") == []
assert scope_list("repo, workflow") == ["repo", "workflow"]
def test_the_credential_type_comes_from_its_prefix():
assert token_kind("ghp_x") == "classic PAT"
assert token_kind("github_pat_x") == "fine-grained PAT"
assert token_kind("ghs_x") == "App installation token"
assert token_kind("nope") == "unknown"
def test_the_run_costs_two_reads_or_three():
assert read_cost() == 2
assert read_cost(True) == 3
import { test } from 'node:test';
import assert from 'node:assert/strict';
import {
ACTION_MINIMUM, ROLES, blockedActions, can, deficit, readCost, repair,
roleFromCollaborator, roleFromPermissions, roleRank, scopeList,
scopesAreTheCeiling, tokenKind, verdict,
} from './github-repo-role.mjs';
const READ_ONLY = {
admin: false, maintain: false, push: false, triage: false, pull: true,
};
const TRIAGE = {
admin: false, maintain: false, push: false, triage: true, pull: true,
};
const WRITE = {
admin: false, maintain: false, push: true, triage: true, pull: true,
};
test('the hierarchy runs weakest to strongest', () => {
assert.deepEqual(ROLES, ['none', 'read', 'triage', 'write', 'maintain', 'admin']);
assert.ok(roleRank('read') < roleRank('triage'));
assert.ok(roleRank('write') < roleRank('admin'));
assert.equal(roleRank('nonsense'), -1);
});
test('the role is the highest true flag', () => {
assert.equal(roleFromPermissions(READ_ONLY), 'read');
assert.equal(roleFromPermissions(TRIAGE), 'triage');
assert.equal(roleFromPermissions(WRITE), 'write');
});
test('an absent permissions object is unreported not none', () => {
assert.equal(roleFromPermissions({}), 'unreported');
assert.equal(roleFromPermissions(null), 'unreported');
assert.equal(roleFromPermissions({ admin: false, pull: false }), 'none');
});
test('read explains every refused write in one flag', () => {
assert.equal(READ_ONLY.push, false);
assert.equal(can('read', 'merge-pull-request'), false);
assert.equal(can('read', 'read-code'), true);
});
test('labelling needs triage and not write', () => {
assert.equal(ACTION_MINIMUM['label-issue'], 'triage');
assert.equal(can('triage', 'label-issue'), true);
assert.equal(can('triage', 'merge-pull-request'), false);
assert.equal(deficit('triage', 'merge-pull-request'), 1);
});
test('the deficit counts roles not booleans', () => {
assert.equal(deficit('read', 'merge-pull-request'), 2);
assert.equal(deficit('read', 'add-collaborator'), 4);
assert.equal(deficit('admin', 'merge-pull-request'), 0);
assert.equal(deficit('read', 'not-an-action'), null);
});
test('the legacy permission field rounds two roles away', () => {
assert.deepEqual(
roleFromCollaborator({ permission: 'write', role_name: 'maintain' }).slice(0, 2),
['maintain', true],
);
const [rounded, exact, note] = roleFromCollaborator({ permission: 'write' });
assert.equal(rounded, 'write');
assert.equal(exact, false);
assert.match(note, /rounds maintain to write/);
assert.equal(roleFromCollaborator({ role_name: 'triage' })[0], 'triage');
});
test('a custom org role is named and not priced', () => {
const [role, exact, note] = roleFromCollaborator(
{ permission: 'read', role_name: 'security-auditor' },
);
assert.equal(role, 'custom:security-auditor');
assert.equal(exact, false);
assert.match(note, /custom organization role/);
assert.equal(verdict(role, 'merge-pull-request')[0], 'custom-role');
});
test('a repo scope beside a read role is the headline', () => {
const [state, detail] = scopesAreTheCeiling(
'read', ['repo', 'workflow'], 'classic PAT', 'merge-pull-request');
assert.equal(state, 'scopes-are-not-the-ceiling');
assert.match(detail, /cannot change this answer/);
});
test('a fine grained token has no scopes to widen', () => {
const [state, detail] = scopesAreTheCeiling(
'read', null, 'fine-grained PAT', 'merge-pull-request');
assert.equal(state, 'no-scopes-to-widen');
assert.match(detail, /nothing to widen/);
});
test('a narrow scope and a low role are both reported', () => {
const [state, detail] = scopesAreTheCeiling(
'read', ['public_repo'], 'classic PAT', 'merge-pull-request');
assert.equal(state, 'two-gates-open');
assert.match(detail, /both/);
});
test('a sufficient role sends the reader to the credential', () => {
assert.equal(
scopesAreTheCeiling('write', ['repo'], 'classic PAT', 'merge-pull-request')[0],
'not-the-question',
);
assert.equal(verdict('write', 'merge-pull-request')[0], 'role-sufficient');
});
test('the verdict and its repair hang together', () => {
const [state, detail] = verdict('read', 'merge-pull-request');
assert.equal(state, 'role-insufficient');
assert.match(detail, /2 role\(s\) higher/);
const fix = repair(state, 'read', 'merge-pull-request', 'octobot');
assert.match(fix, /octobot/);
assert.match(fix, /never its source/);
});
test('no access is kept apart from a low role', () => {
const [state, detail] = verdict('none', 'merge-pull-request');
assert.equal(state, 'no-access');
assert.match(detail, /404/);
assert.equal(verdict('unreported', 'merge-pull-request')[0], 'role-unreported');
});
test('the blocked list grows as the role shrinks', () => {
assert.ok(blockedActions('read').includes('merge-pull-request'));
assert.ok(blockedActions('read').includes('label-issue'));
assert.ok(!blockedActions('triage').includes('label-issue'));
assert.deepEqual(blockedActions('admin'), []);
});
test('scopes absent and scopes empty are different readings', () => {
assert.equal(scopeList(null), null);
assert.deepEqual(scopeList(''), []);
assert.deepEqual(scopeList('repo, workflow'), ['repo', 'workflow']);
});
test('the credential type comes from its prefix', () => {
assert.equal(tokenKind('ghp_x'), 'classic PAT');
assert.equal(tokenKind('github_pat_x'), 'fine-grained PAT');
assert.equal(tokenKind('nope'), 'unknown');
});
test('the run costs two reads or three', () => {
assert.equal(readCost(), 2);
assert.equal(readCost(true), 3);
});
FAQ
Why does adding more scopes to the token not help?
Because scopes and repository roles are two different gates and the call has to pass both. A scope is a limit the account places on a token acting for it; it can only ever be a subset of what the account itself can do. If the account has read on that repository, a token it mints can at most read that repository, no matter how many boxes were ticked. That is also why a token with <code>repo</code> works beautifully on eleven repositories and is refused on the twelfth: the token is identical in every call, and the role behind it is not.
How do I tell this apart from a missing scope, given both are 403?
Read both halves. A classic token that is short a scope gets <code>x-accepted-oauth-scopes</code> on the refusal naming what would have worked, and the token's own <code>x-oauth-scopes</code> is on the same response, so the diff is right there. A role that is too low produces no such header. The decisive reading is the <code>permissions</code> object on <code>GET /repos/{owner}/{repo}</code>: if <code>push</code> is <code>false</code>, no scope change will make a write succeed, and you can know that before making the call that would fail.
Should the script not try the merge to be sure?
No, and this is the case where that matters most. The whole section holds tokens that can reach real repositories, and a script that establishes “this account cannot merge” by attempting a merge has merged something whenever it was wrong. It is also unnecessary: the role is readable in advance, the minimum role for each action is documented, and the comparison is local. The script reads, compares, prints the repair and exits. Nothing it does can change the state of a repository.
The permissions object says read, but I am in a team that has write. Which is right?
The permissions object. It is the effective access, already combining the direct collaborator grant, every team you belong to and the organization's base permission, computed for you on that repository at that moment. If it says read, then whatever you believe about the team either does not apply to that repository or has not taken effect. What the object cannot do is tell you which of the three produced the answer, so the repair may need making in a team rather than on the repository, and a read-only token cannot narrow that down further.
Why does the collaborator endpoint call a maintainer a writer?
Because the <code>permission</code> field predates the roles it now has to describe. It has four values — admin, write, read, none — and GitHub has six roles, so maintain is reported as <code>write</code> and triage is reported as <code>read</code>. That rounding is only a nuisance until you use it to decide something: it will tell you a triager cannot label an issue, when triage is exactly the role that can. Read <code>role_name</code> instead, and treat a value that is not one of the six as a custom organization role whose abilities are not published.
Related field notes
- When the token really is the problem, two headers say so
- A fine-grained token's refusal names no permission
- No access at all returns 404, not 403
Sources
Every figure in this note is traced to one of these. Prices are list rates and change — check them for your own region before acting.
- Collaborators — GitHub REST API
- Repositories — GitHub REST API
- Repository roles for an organization — GitHub Docs
- Scopes for OAuth apps — GitHub Docs
If your setup is misbehaving in a way this note does not cover, message me on LinkedIn with what you are seeing.