Skip to content

fix(auth): grant org namespace only to org Owners, not all members#1383

Draft
tadasant wants to merge 2 commits into
mainfrom
fix/org-namespace-admin-only
Draft

fix(auth): grant org namespace only to org Owners, not all members#1383
tadasant wants to merge 2 commits into
mainfrom
fix/org-namespace-admin-only

Conversation

@tadasant

@tadasant tadasant commented Jun 21, 2026

Copy link
Copy Markdown
Member

Draft / proposal. This implements one specific, minimal fix for the GitHub access-token path discussed in #982. It's meant to anchor the conversation in concrete code, not to foreclose the broader authorization redesign. Happy to revise or close in favor of a different direction.

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 from GET /users/{username}/orgs, which:

  1. returns only public org memberships, and
  2. carries no role — an org Owner and a brand-new member look identical.

So membership was silently treated as publishing authority. Anyone who is (or whose public membership lists them as) a member of acme could publish — and, because publish is a pure namespace-glob with no per-server ownership check, overwrite — any server under io.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 is admin (org Owner).

  • Personal namespaces are unchanged — you always get io.github.<your-username>/*.
  • Org namespaces now require Owner. GitHub has no org-level "maintainer" role; org membership is either admin (Owner) or member. We grant only on admin.
  • read:org is the only scope needed. A minimal token without it still authenticates and still publishes to your personal namespace; the 403 from 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-powerless read:org token.

Diff is ~90 lines in github_at.go plus 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:

  • Approach A — GitHub App as the authority layer. Strong model, but its natural unit of authority is a repo, so it needs a repo→server mapping to authorize a namespace. The safe, flexible version of that mapping is registry-managed state — which collapses into Approach B. It's also GitHub-only.
  • Approach B — a registry-managed grants table (OIDC bootstrap, or device-flow admin check). The most capable and provider-agnostic option, and probably where v1 wants to go. But it introduces exactly the "registry manages an authorization database" layer we wanted to avoid taking on right now: revocation, sync, audit, migration.
  • Approach C — scoped bearer tokens (npm-style). Good ergonomics, but again implies issuance/storage the registry would own.

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:

  • Owners are always in the loop. Only org Owners can publish org servers; there's no delegated "this team can publish this server" — that needs Approach B's state. We're accepting reduced flexibility for zero new infrastructure.
  • Blast radius within an org is unchanged. An Owner (or Owner-scoped CI token) can still overwrite any server under the org namespace, because there's still no per-server publisher ownership. That hijack-an-existing-server problem is real but orthogonal to this fix and should be its own change (record publisher identity per server; enforce it on update). Flagging rather than bundling.
  • OIDC is untouched and remains coarser. The github-oidc path still grants the whole io.github.<owner>/* namespace to any workflow with id-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:

  • member-role org is not granted; only admin orgs are
  • a 403 (missing read:org) still yields the personal namespace and no error
  • existing org/name-validation tests updated to the new endpoint + role shape

gofmt/go vet clean. (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.mdx now documents how to contain the blast radius:

  • store the token as a GitHub Actions Environment secret (the PAT example now uses environment:),
  • restrict that environment to the default branch / release tags + require PR reviews on that branch, so only reviewed, maintainer-approved code can reach the token,
  • optionally add a required environment reviewer for explicit per-publish approval,
  • and avoid the two footguns (pull_request_target checkout, 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).

tadasant and others added 2 commits June 21, 2026 23:05
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>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant