fix(auth): grant org namespace only to org Owners, not all members#1383
Draft
tadasant wants to merge 2 commits into
Draft
fix(auth): grant org namespace only to org Owners, not all members#1383tadasant wants to merge 2 commits into
tadasant wants to merge 2 commits into
Conversation
GitHub access-token exchange granted `io.github.<org>/*` publish
permission for *every* organization a user belonged to, derived from
`GET /users/{username}/orgs` — which returns only public memberships and
carries no role. Any member of an org (or anyone whose public membership
listed it) could publish, and silently overwrite, servers under the
org's namespace.
Switch to `GET /user/memberships/orgs`, which returns the caller's role
per org (and includes private memberships), and grant the org namespace
only when the role is `admin` (org Owner). Personal namespaces are
unaffected.
The memberships endpoint requires the `read:org` scope. A minimal token
without it authenticates fine and still publishes to the personal
namespace; the 403 from the memberships call is treated as "no admin
orgs" so we don't force users to over-scope a token. The token needs no
repository scopes — the registry never touches code.
Refs #982.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The token that authenticates to an org namespace can publish and overwrite any server under io.github.<org>/*, and by default it is reachable by every repository writer (not just org Owners) via branch or tag pushes. Document how to contain that blast radius: - store the token as an Environment secret (update the PAT example to use `environment:`) - restrict the environment to the default branch / release tags and protect that branch with required reviews - optionally require an environment reviewer for per-publish approval - avoid pull_request_target-with-checkout and self-hosted runners on public repos Also clarify that the PAT's read:org scope is what authorizes org publishing and that no repository scopes are needed. Refs #982. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
The bug (scoped to
github-at)When you exchange a GitHub OAuth/PAT token for a registry JWT, the handler granted
io.github.<org>/*publish permission for every organization you belong to. It derived that list fromGET /users/{username}/orgs, which:So membership was silently treated as publishing authority. Anyone who is (or whose public membership lists them as) a member of
acmecould publish — and, because publish is a pure namespace-glob with no per-server ownership check, overwrite — any server underio.github.acme/*.The fix
Switch to
GET /user/memberships/orgs, which returns the authenticated caller's role per org (and includes private memberships), and grant the org namespace only when the role isadmin(org Owner).io.github.<your-username>/*.admin(Owner) ormember. We grant only onadmin.read:orgis the only scope needed. A minimal token without it still authenticates and still publishes to your personal namespace; the403from the memberships call is treated as "no admin orgs" rather than a hard failure, so we don't push anyone to over-scope a token. Critically, the token needs no repository scopes — the registry never reads or writes code, so an org Owner can hand CI a near-powerlessread:orgtoken.Diff is ~90 lines in
github_at.goplus tests and a docs note. No new storage, no new endpoints, no schema changes — the authorization decision stays stateless and delegated to GitHub, consistent with design principle #2 (minimal operational burden).Why this shape, given the alternatives
Pree's writeup (thank you — it's the clearest framing of the problem and the option space) lays out three broad solutions and several approaches:
The two-property test (does the registry control the decision? is it granted through an act requiring real authority?) is the right lens. This PR satisfies it without new state: the registry makes the decision (it filters on role), and the authority is real (live GitHub Owner role, re-checked on every short-lived token exchange).
What this deliberately gives up, so reviewers can weigh it honestly:
github-oidcpath still grants the wholeio.github.<owner>/*namespace to any workflow withid-token: write, i.e. effectively anyone with write access to any repo in the org, and it excludes non-Actions CI (Jenkins/GitLab can't mint GitHub Actions OIDC). The admin-PAT path here is the higher-assurance option for org publishing; OIDC can stay as a lower-assurance Actions convenience, or be tightened separately.Revocation note
The registry JWT is 5-minute, no-refresh (per #264/#273), not 24h — so "Owner gets removed" propagates within minutes on the next token exchange, with no revocation list to maintain. (Noting this because at least one analysis assumed a longer TTL, which would change the revocation-lag tradeoff.)
Tests
go test ./internal/api/handlers/v0/auth/...passes, including new cases:403(missingread:org) still yields the personal namespace and no errorgofmt/go vetclean. (golangci-lint v2.4.0's published image is built against go1.25 and refuses the repo's go1.26 target locally; relying on CI for the lint gate.)Update: CI token-hardening guidance (2nd commit)
A reviewer question prompted a companion docs change: the registry-side fix makes the org Owner the only one who can mint org-namespace authority, but if that Owner drops the resulting PAT into a plain repo secret, every repository writer can reach it (via branch/tag pushes that run repo-controlled build/test code in the privileged job) — quietly re-widening publish access beyond Owners. External fork contributors still cannot reach it by default (no secrets on fork PRs, no branch push), barring misconfigurations like
pull_request_target-with-checkout or self-hosted runners on public repos.So
docs/modelcontextprotocol-io/github-actions.mdxnow documents how to contain the blast radius:environment:),pull_request_targetcheckout, self-hosted runners on public repos).This is complementary, not a substitute: it shrinks who holds the org credential, but an authorized publish can still overwrite a different server in the same namespace until per-server publisher ownership exists (the separate follow-up noted above).