toolpass authenticates as a GitHub App installed in one organization, maps the user's email to a
GitHub login (through the organization's SAML identities, a login template or a mapping file), and
asks the REST API for that login's effective permission: the collaborator permission endpoint for
repositories (the highest of direct, team, organization and enterprise grants, as GitHub computes
it), the membership endpoints for organizations and teams. For @branch questions it also reads the
branch's ruleset rules and its classic branch protection. Nothing is written and no user token is
ever used.
Create a GitHub App (organization settings, Developer settings, GitHub Apps), install it in the organization, and give it these permissions, all read:
| Permission | Why |
|---|---|
| Repository: Metadata (read) | collaborator permission, repository settings, rules |
| Organization: Members (read) | organization and team memberships, SAML external identities |
| Repository: Administration (read) | @branch questions: branch rules and classic branch protection; without it they are unknown |
Generate a private key for the App and store the PEM in a file or environment variable. The App's
client id (Iv1... / Iv23...) is the preferred app_id; the numeric App id also works. The
installation id is discovered from GET /orgs/{org}/installation unless you set it.
The App's JWT is signed RS256 with iat 60 s in the past and exp 9 minutes ahead; the
installation token it buys lasts one hour and is cached and refreshed five minutes before expiry. A
401 from the API drops the token and retries the call once with a fresh one.
Signing with the App's private key needs cryptography: install toolpass[crypto] (the Docker image has it).
- id: github-acme
integration: github
organization: acme
app_id: Iv1.0123456789abcdef
credential: file:/secrets/github-app.pem
# url: https://github.example.com # GitHub Enterprise Server only
# installation_id: "12345678" # optional, discovered when omitted
# identity_mode: saml # saml (default), template or map_file
# login_template: "{local}" # template mode
# email_domains: acme.com,acme.io # template mode, required
# user_map_file: /etc/toolpass/github-users.txt # map_file mode| Key | Meaning |
|---|---|
url |
GitHub Enterprise Server base URL; REST is then {url}/api/v3 and GraphQL {url}/api/graphql. Omit for github.com |
organization |
the organization the App is installed in. Every resource must belong to it: the installation is per organization |
app_id |
the App's client id (preferred) or numeric App id; the JWT issuer |
installation_id |
numeric installation id; discovered when omitted |
credential |
the App private key PEM (PKCS#1 or PKCS#8), env: or file: |
identity_mode |
saml: look the email up in the organization's SAML external identities. template: render login_template and verify the login exists. map_file: look the email up in user_map_file |
login_template |
placeholders {email}, {local}, {domain}; must contain {email} or {local} |
email_domains |
template mode, required: comma-separated domains the template applies to (acme.com, acme.io; entries are trimmed and lowercased, each must be a DNS name). An email from any other domain is unknown (unsupported), so root@attacker.example never renders to the login root |
user_map_file |
lines of email login or email=login, # comments; must exist at startup; re-read at most every 60 s (a broken rewrite keeps the previous map) |
- saml: one GraphQL query filters
externalIdentitiesbyuserName. GitHub's filter is not trusted: only returned identities whosesamlIdentity.nameId,samlIdentity.usernameorscimIdentity.usernameequals the email (case-insensitively) count. One such identity with a linked GitHub user is the login; several linked to different accounts isuser_ambiguous. If no returned identity carries the email (some identity providers only send the address as the SAMLnameId), toolpass lists every external identity (100 per page, at most 50 pages or 5,000 identities), builds an address-to-login map from the three fields above, caches it for 10 minutes and looks the email up case-insensitively. An address that appears on identities linked to different accounts is recorded as a conflict and answersuser_ambiguous. When the listing stopped at a cap, the map is partial: a miss is then unknown (unsupported, "identity list truncated"), notuser_not_found. An identity whose user is null (the person has not linked their SAML identity to a GitHub account) is unknown (unsupported), not a deny. An organization without a SAML identity provider is unknown (unsupported) with a message pointing at the other modes./search/usersis never used. - template: the email's domain must be listed in
email_domains, otherwise the answer is unknown (unsupported) and nothing is looked up.GET /users/{login}then confirms the rendered login exists and is a user, not an organization. - map_file: no upstream call for the identity.
| Resource | Meaning |
|---|---|
repo:<owner>/<name>[@branch] |
a repository; @branch adds the branch's rules and protection to repo.push and pr.merge |
org:<login> |
the organization |
team:<org>/<slug> |
a team, by slug |
The owner or organization must equal organization (case-insensitive); anything else is
invalid_request. Owner and login names must match GitHub's rules (alphanumerics and single
hyphens, at most 39 characters), repository names may also contain . and _, team slugs are
lowercase. Branch names may not contain .., spaces, control characters, \ ~ ^ : ? * [ @, or start
with -. Everything is path-escaped before it goes into a URL.
| Action | What GitHub is asked |
|---|---|
repo.read |
permissions include pull |
repo.triage |
triage |
repo.push |
push; with @branch, the branch's rules and protection are read (see below) |
repo.maintain |
maintain |
repo.admin |
admin |
issue.create |
pull and the repository's has_issues is true |
pr.create |
pull. With push the branch can live in the repository. Without push the pull request must come from a fork, so the repository's allow_forking is read: false is a deny ("forking disabled; pull request needs push access"), absent is unknown, true allows "via fork" and quotes the repository's visibility |
pr.merge |
push; with @branch, the branch's rules and protection are read (see below) |
org.member |
organization membership with state active |
org.admin |
membership role admin (owner) |
org.repo.create |
owner; or member when the organization's members_can_create_repositories is true |
team.member |
team membership with state active |
team.maintainer |
team membership role maintainer |
Permissions come from user.permissions (pull, triage, push, maintain, admin) of
GET /repos/{owner}/{repo}/collaborators/{login}/permission; role_name is quoted in the reason.
When the response carries only the top-level permission string it is expanded (admin >
maintain > write > triage > read). A role_name that is not one of read, triage,
write, maintain, admin (or none) is a custom repository role: when the booleans grant the
level the answer is allow as usual; when they do not, the answer is unknown (unsupported, "custom
repository role; extra abilities not modeled") rather than a deny, since a custom role may carry
abilities the five booleans do not show. A lossy record whose permission string is not a base
role is unknown too.
For repo.push and pr.merge on repo:<owner>/<name>@<branch>, after the permission allows,
toolpass reads two records and never reads either for a deny:
GET /repos/{owner}/{repo}/rules/branches/{branch}, the ruleset rules that apply. The endpoint answers200 []for a branch without rules, so a 404 means the repository or branch is not visible: unknown (resource_not_visible). A 403 means the App lacks Repository Administration: read: unknown (unsupported, "branch rules not readable; grant Repository Administration: read").GET /repos/{owner}/{repo}/branches/{branch}/protection, the classic branch protection. 404 is "not protected"; 403 is unknown (unsupported) as above.
Then, in this order:
- Push restrictions (classic
restrictions, "Restrict who can push to matching branches"): the login must appear inrestrictions.users[].loginor be anactivemember of a team inrestrictions.teams[].slug(GET /orgs/{org}/teams/{slug}/memberships/{login}; child-team members are included by GitHub). Apps are skipped. Not listed is a deny forrepo.push("branch restricts pushes to listed users, teams and apps") and unknown forpr.merge(see Unverified). A team that cannot be checked (403, an unparseable slug, a membership without a state) is unknown, never a deny. Repository admins are exempt unlessenforce_admins.enabledis true; for an admin on a protected branch without anenforce_adminsobject the answer is unknown. - Rules that route changes through pull requests: for
repo.push, apull_request,updateormerge_queuerule turns the allow into unknown (unsupported, "direct push not allowed by rules"), because only bypass actors may push directly and bypass lists are not evaluated.creationanddeletionrules do not affect a push to an existing branch.pr.mergeis not affected. - Required reviews (classic
required_pull_request_reviews): forrepo.push, unknown (unsupported, "requires pull request reviews"), unless the login is an exempt admin.pr.mergeis not affected. - Otherwise the allow stands and the reason lists the rule types ("branch b has N rules (types ...): a direct push may still be rejected") and whether classic protection applies.
| Situation | Answer |
|---|---|
| the login's permissions include the level, membership matches | allow |
permissions do not include the level (including role none) |
deny |
permissions do not include the level and role_name is a custom role |
unknown (unsupported) |
issue.create and issues are disabled |
deny |
pr.create without push and allow_forking is false |
deny |
pr.create without push and allow_forking is absent |
unknown (unsupported) |
@branch push restrictions exclude the login (repo.push) |
deny |
@branch rule pull_request/update/merge_queue, or required reviews (repo.push) |
unknown (unsupported) |
| rules or protection endpoint 403 | unknown (unsupported) |
| rules endpoint 404 | unknown (resource_not_visible) |
organization membership 404, or state pending |
deny |
organization or team membership without a state |
unknown (unsupported) |
| team membership 404 and the team exists | deny |
| team 404 as well | unknown (resource_not_visible) |
| collaborator permission 404 | unknown (resource_not_visible): the repository is not visible to the App, or the login is not a collaborator on a private repository |
members_can_create_repositories absent |
unknown (unsupported) |
has_issues absent |
unknown (unsupported) |
| SAML identity unlinked, or no SAML provider | unknown (unsupported) |
| SAML address on identities linked to different accounts | deny (user_ambiguous) |
| no SAML identity in a truncated listing | unknown (unsupported) |
template mode, email domain not in email_domains |
unknown (unsupported) |
no SAML identity in a complete listing, no /users/{login}, no map entry |
deny (user_not_found) |
401, or 403 without an exhausted rate limit; GraphQL FORBIDDEN / INSUFFICIENT_SCOPES; App not installed |
unknown (credential_rejected) |
403 with X-RateLimit-Remaining: 0 or Retry-After, 429, GraphQL RATE_LIMITED |
unknown (upstream_rate_limited) |
| other GraphQL errors, 5xx, transport | unknown (upstream_error); timeouts upstream_timeout |
Rate limit: 5,000 requests per hour per installation. A check costs one to four calls after the
token is cached (identity, permission, repository metadata where needed, and for @branch the rules
and protection records plus one team membership call per restricted team).
GET /app and GET /orgs/{org}/installation with the App JWT. The summary is
"app installed in ". Warnings: metadata or members missing from the installation's
permissions; any permission with value write or admin (over-privileged); installation_id not
matching what GitHub reports; in saml mode, an organization without a SAML identity provider.
- Ruleset bypass actors, who may review, CODEOWNERS, status checks, signatures and the other
classic protection settings: an allowed push or merge may still be rejected. Of classic
protection only push restrictions, required reviews and
enforce_adminsare evaluated. - Enterprise-level SAML and Enterprise Managed Users: their external identities are not reachable with an organization installation token (see Unverified).
- The abilities of a custom repository role beyond the five permission booleans: a custom role that lacks the level is unknown rather than denied.
- Whether a private or internal repository may be forked by this particular login when
allow_forkingis true:allow_forkingis taken as the effective answer. - Outside collaborators are collaborators:
repo.*answers for them,org.memberdenies. - Whether a login may create a repository of a particular visibility: the allow lists the visibilities the organization permits when GitHub reports them.
Each item is marked # UNVERIFIED: in the code.
- Whether an installation token sees
members_can_create_repositoriesand the per-visibility fields onGET /orgs/{org}; when they are absentorg.repo.createfor a non-owner is unknown. - Whether a non-collaborator on a public repository gets
pull: truefrom the collaborator permission endpoint; if GitHub answers 404 instead,repo.readon public repositories is unknown (resource_not_visible) for outsiders. - Ruleset bypass actors: a
pull_request,updateormerge_queuerule downgradesrepo.pushto unknown even for a login the ruleset lets bypass it. - Classic protection field shapes:
restrictions.users[].login,restrictions.teams[].slug,required_pull_request_reviews(present means required) andenforce_admins.enabledare read as GitHub's OpenAPI description declares them; the tests use that description but no live response was captured. - Classic push restrictions are assumed not to apply to repository admins unless
enforce_admins.enabledis true ("Do not allow bypassing the above settings"); an admin on a protected branch whose record lacksenforce_adminsis unknown. - Whether a classic push restriction also blocks merging a pull request into the branch:
pr.mergefor a login outside the restriction is unknown, not a deny. allow_forkingon a private or internal repository is assumed to reflect the organization's "members can fork private repositories" policy.- Enterprise-level SAML / Enterprise Managed Users:
organization.samlIdentityProvideris expected to be null there, soidentity_mode: samlreports "no SAML identity provider"; usetemplateormap_file. - Branch names containing
/are sent path-escaped (feature%2Fx) to the rules and protection endpoints.
python -m pytest tests/integrations/github (in toolpass-py) runs against a fake GitHub (REST and GraphQL) that
verifies the App JWT signature and claims with the test key, hands out installation tokens, and
serves the repository, permission, membership, rules, branch protection and SAML identity endpoints.
With TOOLPASS_SPECS_DIR set (see test/specs/fetch.sh) every REST request is validated against
GitHub's OpenAPI description. Against a real organization: install the App, configure a connection,
run toolpass probe and one check for a user you know has push on some repository, then one for
repo.push on repo:<org>/<name>@<protected branch> to confirm the protection record shapes.