# github toolpass authenticates as a GitHub App installed in one organization, or with a plain token, maps the user's email to a GitHub login (through the organization's SAML identities, a login template, a mapping file, an HTTP identity service or a callable given in code), 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. ## Credential 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). ### A token instead of an App `token` takes any token GitHub's API accepts as `Authorization: Bearer`: a fine-grained personal access token, a classic one, or an installation token another system mints. Set `token` or `credential`, not both; `app_id` and `installation_id` belong to the App. The checks and the decisions are the same; nothing is minted, the token is read at every request (a rotated file takes effect), and a 401 is not retried. A token answers what its user can see. The reads it needs, read-only: repository metadata (the collaborator permission endpoint), organization members (organization and team memberships), and for `@branch` questions repository administration (rules and classic protection). What an App's installation token answers and a typical token cannot: | Read | With a token | Answer when it cannot | |---|---|---| | SAML external identities (`identity_mode: saml`, the default when no identity setting is given) | only an organization owner's token | unknown (`not_configured`, "configure identity_url, ..."), never a deny | | branch ruleset rules and classic protection (`@branch`) | needs admin, or Administration: read, on the repository | unknown (`unsupported`) | | `members_can_create_repositories` (`org.repo.create` for a member) | an owner's token | unknown (`unsupported`) | | the collaborator permission of a private repository the token's user cannot see | no | unknown (`resource_not_visible`) | Use `identity_mode: http`, `callable`, `template` or `map_file` with a token unless it is an owner's. ## Connection ```yaml - 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, map_file, http or callable # 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 ``` With a token and an identity service: ```yaml - id: github-acme integration: github organization: acme token: env:GITHUB_READ_TOKEN identity_url: https://groot.internal/v1/github-login # identity_mode http, implied identity_token: file:/secrets/groot-token # optional bearer ``` Or in code, with the lookup as a callable (`identity_mode: callable` is implied): ```python async def github_login(email: str) -> str | None: return await groot.login_for(email) # None: no identity tp = toolpass.Toolpass(connections=[{"id": "github-acme", "integration": "github", "organization": "acme", "token": toolpass.env("GITHUB_READ_TOKEN"), "identity": github_login}]) ``` | 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 (or the token reads). Every resource must belong to it: the installation is per organization | | `app_id` | App auth, required with `credential`: the App's client id (preferred) or numeric App id; the JWT issuer | | `installation_id` | App auth: numeric installation id; discovered when omitted | | `credential` | App auth: the App private key PEM (PKCS#1 or PKCS#8), `env:` or `file:` | | `token` | token auth, instead of `credential`: a GitHub token, `env:` or `file:` ([above](#a-token-instead-of-an-app)) | | `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`. `http`: ask `identity_url`. `callable`: call `identity`. Default: `callable` when `identity` is given, else `http` when `identity_url` is set, else `saml` | | `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) | | `identity_url` | http mode: the identity service, an `https://` URL without a query (plain `http://` for loopback, or anywhere with `identity_allow_http`) | | `identity_allow_http` | http mode only, `false` by default: `true` lets `identity_url` be plain `http://` on any host, for a network that carries TLS itself (a service mesh such as `http://groot.security.svc.cluster.local`). `identity_token` then travels in that request | | `collaborator_404` | `unknown` (default) or `deny`: what a 404 from the collaborator permission read means. GitHub answers 200 with permission `none` for an organization member without access, so a 404 means the repository is not visible to toolpass or the login is not a collaborator on a private repository. With `deny` toolpass reads the repository: visible, the 404 is a deny; not visible, the answer stays unknown (`resource_not_visible`), since nothing about the login was checked | | `identity_token` | http mode, optional: sent to the identity service as `Authorization: Bearer`, `env:` or `file:` | | `identity` | callable mode, in code only: `identity(email)` returns a login or `None`, sync or async; a config file cannot carry it | ### Identity - **saml**: one GraphQL query filters `externalIdentities` by `userName`. GitHub's filter is not trusted: only returned identities whose `samlIdentity.nameId`, `samlIdentity.username` or `scimIdentity.username` equals the email (case-insensitively) count. One such identity with a linked GitHub user is the login; several linked to different accounts is `user_ambiguous`. If no returned identity carries the email (some identity providers only send the address as the SAML `nameId`), 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 answers `user_ambiguous`. When the listing stopped at a cap, the map is partial: a miss is then unknown (`unsupported`, "identity list truncated"), not `user_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 (`not_configured`) with a message pointing at the other modes. `/search/users` is 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. - **http**: `GET ?email=` (the email query-encoded, no redirect followed). The answer decides: | Answer | Decision | |---|---| | 200 with a JSON string that is a GitHub login (`"octocat"`) | the login | | 404 (no identity) or 422 (the address is refused) | deny (`user_not_found`) | | any other status (401, 403, 5xx, a redirect), a transport error or timeout, an `identity_token` that cannot be read, a body that is not JSON, JSON that is not a string, a string that is not a GitHub login | unknown (`unsupported`): the question could not be asked; the text says which | Neither the body nor the token is ever put into a decision. - **callable**: `identity(email)` is called; an `async def` runs to completion on an event loop of its own (in a worker thread when the calling thread runs one), bounded by the connection's timeout; a client bound to your application's event loop cannot be used from it. A login string is the login; `None` is a deny (`user_not_found`); an exception, a value that is not a string, or a string that is not a GitHub login is unknown (`unsupported`), never a deny. A sync callable is not interrupted at the timeout. The engine's identity cache keeps a resolved login for `identity_cache_seconds` (15 minutes by default) and a `user_not_found` for 60 seconds, for every mode; `unsupported` is never cached. ## Resources | Resource | Meaning | |---|---| | `repo:/[@branch]` | a repository; `@branch` adds the branch's rules and protection to `repo.push` and `pr.merge` | | `org:` | the organization | | `team:/` | 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. ## Actions | 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. ### Branch rules and protection For `repo.push` and `pr.merge` on `repo:/@`, after the permission allows, toolpass reads two records and never reads either for a deny: 1. `GET /repos/{owner}/{repo}/rules/branches/{branch}`, the ruleset rules that apply. The endpoint answers `200 []` 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"). 2. `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 in `restrictions.users[].login` or be an `active` member of a team in `restrictions.teams[].slug` (`GET /orgs/{org}/teams/{slug}/memberships/{login}`; child-team members are included by GitHub). Apps are skipped. Not listed is a **deny** for `repo.push` ("branch restricts pushes to listed users, teams and apps") and unknown for `pr.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 unless `enforce_admins.enabled` is true; for an admin on a protected branch without an `enforce_admins` object the answer is unknown. - **Rules that route changes through pull requests**: for `repo.push`, a `pull_request`, `update` or `merge_queue` rule 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. `creation` and `deletion` rules do not affect a push to an existing branch. `pr.merge` is not affected. - **Required reviews** (classic `required_pull_request_reviews`): for `repo.push`, unknown (`unsupported`, "requires pull request reviews"), unless the login is an exempt admin. `pr.merge` is 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. ## Decisions Every decision for a resolved user carries the GitHub login (`Decision.identity` in Python and Node, `"identity"` in the HTTP response), so a caller can print it or bind it to its logs without parsing the reason. | 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. With `collaborator_404: deny`, deny when the repository itself is visible | | `members_can_create_repositories` absent | unknown (`unsupported`) | | `has_issues` absent | unknown (`unsupported`) | | SAML identity unlinked | 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`) | | http mode: 404 or 422; callable mode: `None` | deny (`user_not_found`) | | http mode: other status, transport error, a body that is not a login string; callable mode: an exception or a value that is not a login | unknown (`unsupported`) | | token auth: GraphQL `FORBIDDEN` / `INSUFFICIENT_SCOPES` on the SAML read, or the organization not visible to it; any auth: no SAML identity provider | unknown (`not_configured`): nothing about the user was checked; configure `identity_url`, `identity`, `login_template` or `user_map_file` | | 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 token has its user's limit). 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). ## Probe With a token: `GET /orgs/{org}`. The summary is "token reads organization ", with the scopes a classic token reports (`X-OAuth-Scopes`); a scope other than `read:org`, `read:user`, `user:email` and the other `read:` scopes is reported as over-privileged. The classic `repo` scope, which a classic token needs to read private repositories, writes too: the warning says so and points at a fine-grained token with read permissions only. In `saml` mode a warning says the SAML read needs an owner's token. With an App: `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. ## What it cannot see - 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_admins` are 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_forking` is true: `allow_forking` is taken as the effective answer. - Outside collaborators are collaborators: `repo.*` answers for them, `org.member` denies. - Whether a login may create a repository of a particular visibility: the allow lists the visibilities the organization permits when GitHub reports them. ## Unverified Each item is marked `# UNVERIFIED:` in the code. - Whether an installation token sees `members_can_create_repositories` and the per-visibility fields on `GET /orgs/{org}`; when they are absent `org.repo.create` for a non-owner is unknown. - Whether a non-collaborator on a public repository gets `pull: true` from the collaborator permission endpoint; if GitHub answers 404 instead, `repo.read` on public repositories is unknown (`resource_not_visible`) for outsiders. - Ruleset bypass actors: a `pull_request`, `update` or `merge_queue` rule downgrades `repo.push` to 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) and `enforce_admins.enabled` are 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.enabled` is true ("Do not allow bypassing the above settings"); an admin on a protected branch whose record lacks `enforce_admins` is unknown. - Whether a classic push restriction also blocks merging a pull request into the branch: `pr.merge` for a login outside the restriction is unknown, not a deny. - `allow_forking` on 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.samlIdentityProvider` is expected to be null there, so `identity_mode: saml` answers `not_configured` ("no SAML identity provider"); use `http`, `callable`, `template` or `map_file`. - Token auth: which reads a fine-grained token's "Metadata", "Members" and "Administration" read permissions answer was taken from GitHub's documentation, not captured from a live organization. - Branch names containing `/` are sent path-escaped (`feature%2Fx`) to the rules and protection endpoints. ## Test `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. `test_token_and_identity.py` runs the same fake on token auth, with a fake identity service for `http` mode and callables for `callable` mode. 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:/@` to confirm the protection record shapes.