mirror of
https://tangled.org/evan.jarrett.net/at-container-registry
synced 2026-09-10 04:06:06 +00:00
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
243 lines
8.8 KiB
Go
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)
|
|
}
|
|
}
|