20. Fine-grained permission categories do not nest¶
Status¶
Accepted. Extends 015.
Context¶
Requirement::ADMINISTRATION was shared by every resource that writes through
an admin-only endpoint, on the assumption that one resource maps to one
permission. Checking each endpoint we call against GitHub's reference showed
that assumption is false, and that two declarations were wrong because of it:
GET /repos/{owner}/{repo}/environments— the read that every environment plan starts with — is Actions: read.Administration: writedoes not include it. A token granted exactly what the old table demanded could create an environment but not list one./repos/{owner}/{repo}/environments/{name}/variablesis Environments, not Variables. The scope decides the permission, not the payload; we had declared both scopes asVariables: write.
The mistake in both cases was the same: assuming a permission that grants the expensive operation also grants the cheap one, or that a resource named after a thing is governed by the permission named after that thing.
Decision¶
A Requirement lists every permission the resource's endpoints need,
including read permissions in categories it does not write, and including the
same category at two levels where that is what the reference says. Four entries
for one resource is normal, not a smell.
Requirements are derived per endpoint, from GitHub's published table, not per resource by analogy with a resource that looks similar.
Where the reference does not settle a mapping — several endpoints are listed
under two permissions with a marker that means either "both" or "either" — the
minimal sufficient claim is declared and marked Confidence::Unverified, which
carries a footnote into the docs and an admission into doctor. actions is
currently the only one — see ADR-021, which is where
that entry came from; pages was the original example and has since been
settled by its X-Accepted-GitHub-Permissions header.
Consequences¶
- The published permission table is longer and less tidy. It is also correct, and the tidiness was costing users a 403 on a read.
environmentsneeds its ownRequirementrather than sharingADMINISTRATION, and any future resource touching environments will too.- Two resources reading the same endpoint must each declare the permission it
needs.
environmentsandvariablesboth list environments, so both carryActions: read; neither can rely on the other having asked. - The
github_token_notestrings are load-bearing: the docs generator groups resources by exact string equality to build one sentence instead of five.ENVIRONMENTSrepeatsADMINISTRATION's note verbatim for that reason. - Settling an ambiguous mapping needs a fine-grained token and the
X-Accepted-GitHub-Permissionsresponse header. A classic token getsX-Accepted-OAuth-Scopesinstead and cannot answer the question. - That header's syntax is the opposite of the obvious reading, and we got it
wrong once: a comma joins permissions that are all required, and a
semicolon separates alternative sets.
pages=write,administration=writemeans both;issues=read; pull_requests=readmeans either. Assuming the intuitive reading turns every finding inside out. - The header also uses wire names, not the names in the token UI: repository
variables are
actions_variablesthere and "Variables" everywhere a human looks. - That check is a live test rather than a chore someone remembers:
live_declared_permissions_match_what_github_acceptsasks GitHub what each endpoint accepts and fails if a declaration does not cover it. It skips on a classic token, so it cannot be the only guard — the unit tests insrc/resources/requirement.rsstill pin the mappings offline. - Its first run paid for itself: it found that the Pages writes need
Administration: writeas well asPages: write, which no amount of reading the published table would have settled. - A declared requirement and
github_token_capablecan legitimately disagree. Pages needsAdministration: writeas a fine-grained token yet remains manageable with the Actions token, because that is a different permission system with apageskey of its own. When in doubt the flag staystrue: a false refusal cannot be overruled, while a false permission is merely a 403 from GitHub with an explanation attached.