docs(auth): document fine-grained PAT permission for org publishing (+ pagination-cap test) (#1435)
Follow-up to #1383, surfaced while validating that change end-to-end against staging. ## Why #1383 hardened org-namespace publishing to require Owner (`admin`) role, read via `GET /user/memberships/orgs`. Validating on staging revealed a gap: the docs only mention the classic `read:org` scope, but a **fine-grained PAT** (GitHub's recommended token type) needs a *different* permission to expose org membership. Without it, GitHub returns no org memberships for the token, so the exchange silently grants only the personal namespace — with no hint why org publishing "doesn't work." This is **not** a code bug (the behavior is fail-closed and correct given GitHub's response); it's a documentation/UX gap. ## What - **Docs** (`authentication.mdx`, `github-actions.mdx`): spell out both token types — - Classic PAT → `read:org` scope - Fine-grained PAT → **Organization permissions → Members → Read-only** (the equivalent), noting a fine-grained PAT is bound to a single resource owner. - Also clarified `read:user` is not required. - **Test** (`github_at_test.go`): cover the `maxOrgMembershipPages` pagination cap — when every page is full, the exchange **fails closed** (no token) rather than returning a possibly-truncated org grant. This path was previously untested. - **Comment** (`github_at.go`): qualify the `X-GitHub-SSO` 403 check as best-effort — we have not confirmed the memberships *list* endpoint emits that header on SSO enforcement (it may just omit the org). The check is safe either way, but the comment no longer overstates it as a guarantee. ## Verification (live, against staging) Confirmed by exchanging real tokens for `rdimitrov` on `staging.registry.modelcontextprotocol.io`: - **Classic `read:org` PAT** across 16 real org memberships → the JWT granted exactly the 8 **admin** orgs + personal namespace, and **zero** of the 8 **member** orgs (incl. `stacklok`, `modelcontextprotocol`) — the core #1383 behavior, working. - **Fine-grained PAT**: without Members:read → personal namespace only (fail-closed); after adding Members:read → the org (Owner) namespace is granted. This is what the new docs describe. No runtime behavior change here beyond the added test; the docs and comment are the substance. --------- Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
parent
5da9b4c8bf
commit
9e762b03c7
4 changed files with 48 additions and 6 deletions
|
|
@ -53,7 +53,12 @@ GitHub authentication always grants your personal namespace, `io.github.<your-us
|
|||
|
||||
To publish under an **organization** namespace (`io.github.<orgname>/*`), you must be an **Owner** of that organization. Ordinary org membership is no longer sufficient: the registry checks your membership role and only grants the org namespace to admins. This prevents anyone who merely belongs to an org from publishing — or overwriting — servers under the org's name.
|
||||
|
||||
If you authenticate with a Personal Access Token (for example in CI), the token must include the `read:org` scope for the registry to see your organization role. A token without `read:org` can still publish to your personal namespace, but org publishing will be silently unavailable. The token needs **no** repository scopes — the registry never reads or writes your code.
|
||||
If you authenticate with a Personal Access Token (for example in CI), the token must let the registry read your organization role. A token that can't will still publish to your personal namespace, but org publishing will be silently unavailable. The exact requirement depends on the token type:
|
||||
|
||||
- **Classic PAT**: grant the `read:org` scope.
|
||||
- **Fine-grained PAT**: grant the **Organization permissions → Members → Read-only** permission (the fine-grained equivalent of `read:org`). Without it, GitHub returns no organization membership for the token and you'll get your personal namespace only. Note that a fine-grained PAT is bound to a single resource owner, so it can only see the organization it was created for.
|
||||
|
||||
Either way the token needs **no** repository scopes — the registry never reads or writes your code.
|
||||
|
||||
## DNS Authentication
|
||||
|
||||
|
|
|
|||
|
|
@ -199,7 +199,11 @@ jobs:
|
|||
You may need to add a secret depending on which authentication method you choose:
|
||||
|
||||
- **GitHub OIDC Authentication**: No dedicated secret necessary.
|
||||
- **GitHub PAT Authentication**: Add a `MCP_GITHUB_TOKEN` secret with a GitHub Personal Access Token (PAT) that has the `read:org` scope. `read:org` is what lets the registry confirm you are an **Owner** of the organization before granting its namespace; the token needs **no** repository scopes (the registry never reads or writes your code), and no other scopes are required — `read:user` is not needed, and a bare token still authenticates and publishes to your personal namespace. For an **organization** token, store it as an **Environment secret** on the `mcp-registry-publish` environment rather than a plain repository secret — see "Securing your registry token in CI" below for why this matters and how to configure the environment.
|
||||
- **GitHub PAT Authentication**: Add a `MCP_GITHUB_TOKEN` secret with a GitHub Personal Access Token. The registry only needs to read your organization role to confirm you are an **Owner** before granting its namespace, so grant the minimum for that and **no** repository scopes (the registry never reads or writes your code):
|
||||
- **Classic PAT**: the `read:org` scope (`read:user` is *not* needed; a bare token still authenticates and publishes to your personal namespace).
|
||||
- **Fine-grained PAT**: the **Organization permissions → Members → Read-only** permission (the equivalent of `read:org`). Without it you'll get your personal namespace only. A fine-grained PAT is scoped to one resource owner, so create it for the organization you're publishing to.
|
||||
|
||||
For an **organization** token, store it as an **Environment secret** on the `mcp-registry-publish` environment rather than a plain repository secret — see "Securing your registry token in CI" below for why this matters and how to configure the environment.
|
||||
- **DNS Authentication**: Add a `MCP_PRIVATE_KEY` secret with your Ed25519 private key.
|
||||
|
||||
You may also need to add secrets for your package registry. For example, the workflow above needs an `NPM_TOKEN` secret with your npm token.
|
||||
|
|
|
|||
|
|
@ -255,10 +255,15 @@ func (h *GitHubHandler) fetchOrgMembershipsPage(ctx context.Context, token strin
|
|||
if resp.Header.Get("X-RateLimit-Remaining") == "0" || resp.Header.Get("Retry-After") != "" {
|
||||
return nil, fmt.Errorf("GitHub API rate limit exceeded while listing org memberships (status 403): %s", readErrorBody(resp.Body))
|
||||
}
|
||||
// A SAML/SSO-enforced org returns 403 with an X-GitHub-SSO header when the
|
||||
// token has not been SSO-authorized. That is an Owner being blocked, not a
|
||||
// missing scope, so fail closed rather than silently dropping the org grant
|
||||
// (which would degrade a legitimate Owner to personal-only with no signal).
|
||||
// Best-effort defense: GitHub uses the X-GitHub-SSO header to signal that a
|
||||
// resource is behind SAML/SSO the token hasn't been authorized for. If we see
|
||||
// it, treat that as an Owner being blocked (not a missing scope) and fail
|
||||
// closed rather than silently degrading them to personal-only. NOTE: we have
|
||||
// not confirmed that the memberships *list* endpoint emits this header on SSO
|
||||
// enforcement (it may instead just omit the SSO-protected org); this check is
|
||||
// safe either way — if the header is absent we fall through to the same
|
||||
// graceful degrade as before — but it is not a proven guarantee that every
|
||||
// SSO-blocked Owner is caught here.
|
||||
if resp.Header.Get("X-GitHub-SSO") != "" {
|
||||
return nil, fmt.Errorf("GitHub org memberships require SSO authorization for this token (status 403): %s", readErrorBody(resp.Body))
|
||||
}
|
||||
|
|
|
|||
|
|
@ -665,6 +665,34 @@ func TestGitHubHandler_ExchangeToken_403FailClosed(t *testing.T) {
|
|||
})
|
||||
}
|
||||
|
||||
func TestGitHubHandler_ExchangeToken_PaginationCap(t *testing.T) {
|
||||
testSeed := make([]byte, ed25519.SeedSize)
|
||||
_, err := rand.Read(testSeed)
|
||||
require.NoError(t, err)
|
||||
|
||||
cfg := &config.Config{JWTPrivateKey: hex.EncodeToString(testSeed)}
|
||||
|
||||
t.Run("all-full pages past the cap fails closed", func(t *testing.T) {
|
||||
// Every page comes back full (100), so the loop never sees a short final page
|
||||
// and runs to maxOrgMembershipPages. Rather than return a possibly-truncated
|
||||
// org set, the exchange must fail closed (error, no token).
|
||||
server := newMockGitHubServer(func(w http.ResponseWriter, _ *http.Request) {
|
||||
page := make([]orgMembership, 0, 100)
|
||||
for i := 0; i < 100; i++ {
|
||||
page = append(page, orgMembership{
|
||||
State: "active", Role: "member",
|
||||
Organization: v0auth.GitHubUserOrOrg{Login: fmt.Sprintf("org-%d", i), ID: i},
|
||||
})
|
||||
}
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
json.NewEncoder(w).Encode(page) //nolint:errcheck
|
||||
})
|
||||
defer server.Close()
|
||||
|
||||
assertExchangeFailsClosed(t, cfg, server)
|
||||
})
|
||||
}
|
||||
|
||||
func TestJWTTokenValidation(t *testing.T) {
|
||||
testSeed := make([]byte, ed25519.SeedSize)
|
||||
_, err := rand.Read(testSeed)
|
||||
|
|
|
|||
Loading…
Reference in a new issue