Files
at-container-registry/pkg/auth/token/handler_anonymous_gate_test.go
T
Evan JarrettandClaude Opus 5 af7522b154 appview: stop /auth/token granting anonymous pull the registry will refuse
An unauthenticated token request for a repo on a private hold came back with a
signed token granting pull, and /v2/ then 401ed that exact token. The
authorization server and the resource server disagreed about the same request.

The old behaviour was deliberate — "minting a pull-only token is not a grant",
with the hold owning the decision via captain.Public — but a token spec expects
the server to issue the subset it will authorize, so granting pull and then
refusing it is the wrong shape.

Adds an optional AnonymousAuthorizer, kept separate from Authorizer because the
anonymous path has no DID and no auth method (three of Authorize's four
arguments are meaningless) and because it must drop whole entries rather than
narrow actions in place, where entries can belong to different owners. Denied
entries are dropped; if nothing granting survives, the caller gets the standard
401 challenge rather than a token with an empty access list, so docker prompts
for credentials instead of proceeding to a second 401.

The scope-less /v2/ ping and the actionless entry NarrowToPullOnly preserves on
purpose both bypass the gate entirely — no identity resolution, no hold lookup —
since anonymous discovery depends on them.

Fails open on any lookup error, matching the /v2/ check, which states the
reason: the hold is the enforcing authority and a transient failure must not
break anonymous pulls of public images. /v2/ still enforces; this is a
correctness and UX fix, not a security fix, and nothing was exposed.

Also closes the successor asymmetry documented under finding 3: /v2/ applies a
single-hop migration redirect before checking read access, so judging the
pre-migration identity here would have reintroduced the disagreement this gate
removes. It was one extra local read of hold_captain_records. Tests pin both
directions and prove the chain is not followed past one hop.

Costs one directory-cached identity resolution plus two local SQL reads per
granting entry, and no call to the hold.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PDqoCE1j3njokkZ9b1C5n9
2026-09-02 21:08:00 -05:00

243 lines
8.8 KiB
Go

package token
import (
"context"
"encoding/json"
"net/http"
"net/http/httptest"
"slices"
"testing"
"time"
"atcr.io/pkg/auth"
)
// stubAnonymousAuthorizer keeps the entries whose repository name appears in
// public, drops the rest, and records what it was asked. Entries that grant
// nothing pass through, mirroring the contract the real gate follows.
type stubAnonymousAuthorizer struct {
public []string
calls int
saw []auth.AccessEntry
}
func (s *stubAnonymousAuthorizer) AuthorizeAnonymous(_ context.Context, access []auth.AccessEntry) []auth.AccessEntry {
s.calls++
s.saw = append(s.saw, access...)
kept := make([]auth.AccessEntry, 0, len(access))
for _, entry := range access {
if len(entry.Actions) == 0 || slices.Contains(s.public, entry.Name) {
kept = append(kept, entry)
}
}
return kept
}
// passthroughAnonymousAuthorizer is the fail-open shape: whatever comes in
// comes back out. This is what the real gate returns when a hold lookup errors.
type passthroughAnonymousAuthorizer struct{ calls int }
func (p *passthroughAnonymousAuthorizer) AuthorizeAnonymous(_ context.Context, access []auth.AccessEntry) []auth.AccessEntry {
p.calls++
return access
}
func newGateTestHandler(t *testing.T, gate AnonymousAuthorizer) *Handler {
t.Helper()
issuer, err := NewIssuer(getSharedTestKey(t), "atcr.io", "registry", 15*time.Minute)
if err != nil {
t.Fatalf("NewIssuer() error = %v", err)
}
h := NewHandler(issuer, nil)
h.SetAnonymousAuthorizer(gate)
return h
}
// anonymousGet drives the credential-less GET form and returns the recorder.
func anonymousGet(t *testing.T, h *Handler, query string) *httptest.ResponseRecorder {
t.Helper()
req := httptest.NewRequest(http.MethodGet, "/auth/token?"+query, nil)
w := httptest.NewRecorder()
h.ServeHTTP(w, req)
return w
}
func decodeToken(t *testing.T, w *httptest.ResponseRecorder) string {
t.Helper()
var resp TokenResponse
if err := json.NewDecoder(w.Body).Decode(&resp); err != nil {
t.Fatalf("decode token response: %v", err)
}
if resp.Token == "" {
t.Fatal("expected a non-empty token")
}
return resp.Token
}
// A public hold is the case that must not regress: anonymous pull of a public
// image is the whole point of the credential-less path.
func TestHandler_AnonymousGate_PublicHoldStillGrantsPull(t *testing.T) {
gate := &stubAnonymousAuthorizer{public: []string{"alice.test/public-app"}}
h := newGateTestHandler(t, gate)
w := anonymousGet(t, h, "service=registry&scope=repository:alice.test/public-app:pull")
if w.Code != http.StatusOK {
t.Fatalf("expected 200 for a public hold, got %d. Body: %s", w.Code, w.Body.String())
}
tok := decodeToken(t, w)
if am := ExtractAuthMethod(tok); am != AuthMethodAnonymous {
t.Errorf("expected auth method %q, got %q", AuthMethodAnonymous, am)
}
access := ExtractAccess(tok)
if len(access) != 1 || access[0].Name != "alice.test/public-app" {
t.Fatalf("expected pull access for alice.test/public-app, got %+v", access)
}
if !slices.Equal(access[0].Actions, []string{"pull"}) {
t.Errorf("expected actions [pull], got %v", access[0].Actions)
}
if gate.calls != 1 {
t.Errorf("expected the gate to be consulted once, got %d calls", gate.calls)
}
}
// The defect this gate exists to fix: /auth/token used to sign a pull token for
// a repository whose hold denies anonymous reads, and /v2/ then refused that
// exact token. A 401 challenge here is what makes docker ask for credentials.
func TestHandler_AnonymousGate_PrivateHoldChallengesWithNoToken(t *testing.T) {
gate := &stubAnonymousAuthorizer{} // nothing is public
h := newGateTestHandler(t, gate)
w := anonymousGet(t, h, "service=registry&scope=repository:alice.test/private-app:pull")
if w.Code != http.StatusUnauthorized {
t.Fatalf("expected 401 for a private hold, got %d. Body: %s", w.Code, w.Body.String())
}
if w.Header().Get("WWW-Authenticate") == "" {
t.Error("expected a WWW-Authenticate challenge so docker prompts for credentials")
}
// An empty-access token would be the smaller version of the same bug: the
// client proceeds to /v2/ and collects its 401 there instead.
var resp TokenResponse
if err := json.NewDecoder(w.Body).Decode(&resp); err == nil && resp.Token != "" {
t.Errorf("expected no token in the challenge body, got one: %q", resp.Token)
}
}
// Scopes in one request can name different owners and therefore different
// holds, so the verdict is per entry: keep what is grantable, drop the rest.
func TestHandler_AnonymousGate_MixedScopeKeepsOnlyPublicEntries(t *testing.T) {
gate := &stubAnonymousAuthorizer{public: []string{"alice.test/public-app"}}
h := newGateTestHandler(t, gate)
// Multiple repositories arrive as one space-separated scope value, which is
// the form this handler parses.
w := anonymousGet(t, h,
"service=registry&scope=repository:alice.test/public-app:pull%20repository:bob.test/private-app:pull")
if w.Code != http.StatusOK {
t.Fatalf("expected 200 when one scope survives, got %d. Body: %s", w.Code, w.Body.String())
}
access := ExtractAccess(decodeToken(t, w))
if len(access) != 1 {
t.Fatalf("expected exactly the public entry to survive, got %+v", access)
}
if access[0].Name != "alice.test/public-app" {
t.Errorf("expected alice.test/public-app, got %q", access[0].Name)
}
// Both entries must reach the gate: dropping one is its decision, not the
// handler's.
if len(gate.saw) != 2 {
t.Errorf("expected the gate to see both entries, saw %+v", gate.saw)
}
}
// Anonymous discovery must not pay for the gate, and there is no hold to look
// up: the ping carries no scope at all.
func TestHandler_AnonymousGate_ScopelessPingSkipsTheGate(t *testing.T) {
gate := &stubAnonymousAuthorizer{} // would deny everything if consulted
h := newGateTestHandler(t, gate)
w := anonymousGet(t, h, "service=registry")
if w.Code != http.StatusOK {
t.Fatalf("expected 200 for the /v2/ ping, got %d. Body: %s", w.Code, w.Body.String())
}
if access := ExtractAccess(decodeToken(t, w)); len(access) != 0 {
t.Errorf("expected empty access on the ping token, got %+v", access)
}
if gate.calls != 0 {
t.Errorf("the ping must not trigger a hold lookup, gate was called %d times", gate.calls)
}
}
// NarrowToPullOnly preserves an actionless entry deliberately ("callers rely on
// the entry surviving"). It grants nothing, so the gate must pass it through
// rather than read it as a denial, and it must not cost a hold lookup.
func TestHandler_AnonymousGate_ActionlessScopeSurvivesUnchecked(t *testing.T) {
gate := &stubAnonymousAuthorizer{public: []string{"alice.test/public-app"}}
h := newGateTestHandler(t, gate)
w := anonymousGet(t, h,
"service=registry&scope=repository:alice.test/app:%20repository:alice.test/public-app:pull")
if w.Code != http.StatusOK {
t.Fatalf("expected 200 for an actionless scope alongside a public one, got %d. Body: %s", w.Code, w.Body.String())
}
access := ExtractAccess(decodeToken(t, w))
if len(access) != 2 {
t.Fatalf("expected both entries to survive, got %+v", access)
}
var actionless *auth.AccessEntry
for i := range access {
if access[i].Name == "alice.test/app" {
actionless = &access[i]
}
}
if actionless == nil {
t.Fatalf("the actionless entry was dropped: %+v", access)
}
if len(actionless.Actions) != 0 {
t.Errorf("expected the actionless entry to survive unchanged, got actions %v", actionless.Actions)
}
}
// The gate fails open on any lookup error, because /v2/ is still the enforcing
// layer and a DNS blip or a cold captain cache must not start refusing tokens
// for public images. The handler's job is to honour that verdict.
func TestHandler_AnonymousGate_LookupErrorFailsOpen(t *testing.T) {
gate := &passthroughAnonymousAuthorizer{}
h := newGateTestHandler(t, gate)
w := anonymousGet(t, h, "service=registry&scope=repository:alice.test/app:pull")
if w.Code != http.StatusOK {
t.Fatalf("expected 200 when the gate fails open, got %d. Body: %s", w.Code, w.Body.String())
}
access := ExtractAccess(decodeToken(t, w))
if len(access) != 1 || access[0].Name != "alice.test/app" {
t.Errorf("expected the entry to survive a failed-open lookup, got %+v", access)
}
if gate.calls != 1 {
t.Errorf("expected the gate to be consulted once, got %d calls", gate.calls)
}
}
// With no gate wired (a deployment that has not configured one), the path keeps
// its previous behavior rather than failing closed.
func TestHandler_AnonymousGate_UnsetAuthorizerGrantsAsBefore(t *testing.T) {
issuer, err := NewIssuer(getSharedTestKey(t), "atcr.io", "registry", 15*time.Minute)
if err != nil {
t.Fatalf("NewIssuer() error = %v", err)
}
h := NewHandler(issuer, nil)
w := anonymousGet(t, h, "service=registry&scope=repository:alice.test/app:pull")
if w.Code != http.StatusOK {
t.Fatalf("expected 200 with no gate configured, got %d. Body: %s", w.Code, w.Body.String())
}
if access := ExtractAccess(decodeToken(t, w)); len(access) != 1 {
t.Errorf("expected the requested entry to survive, got %+v", access)
}
}