Files
at-container-registry/pkg/appview/authgate/anonymous_authorizer_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

300 lines
11 KiB
Go

package authgate
import (
"context"
"database/sql"
"fmt"
"testing"
"atcr.io/pkg/appview/db"
"atcr.io/pkg/auth"
)
// seedCaptainRecord inserts a hold_captain_records row with full control over
// public and successor, which seedCaptain (private, no successor) does not give.
func seedCaptainRecord(t *testing.T, d *sql.DB, rec db.HoldCaptainRecord) {
t.Helper()
if err := db.BatchUpsertCaptainRecords(d, []db.HoldCaptainRecord{rec}); err != nil {
t.Fatalf("BatchUpsertCaptainRecords(%s): %v", rec.HoldDID, err)
}
}
// newAnonGate builds an AnonymousAuthorizer whose identity resolution is a
// static map, so the tests stay hermetic (the production resolver would issue
// live DNS/HTTPS lookups).
func newAnonGate(t *testing.T, d *sql.DB, defaultHoldDID string, identities map[string]string) *AnonymousAuthorizer {
t.Helper()
a := NewAnonymousAuthorizer(d, defaultHoldDID)
a.resolveOwnerDID = func(_ context.Context, identity string) (string, error) {
if did, ok := identities[identity]; ok {
return did, nil
}
return "", fmt.Errorf("no such identity: %s", identity)
}
return a
}
func pullEntry(name string) auth.AccessEntry {
return auth.AccessEntry{Type: "repository", Name: name, Actions: []string{"pull"}}
}
func names(access []auth.AccessEntry) []string {
out := make([]string, 0, len(access))
for _, e := range access {
out = append(out, e.Name)
}
return out
}
// A public hold admits an identity-less reader, which is exactly what
// CheckReadAccessWithCaptain concludes at /v2/ for an empty user DID.
func TestAnonymousAuthorizer_PublicHoldKeepsScope(t *testing.T) {
d := newTestDB(t)
seedUser(t, d, "did:plc:alice", "alice.test", "did:web:public.hold")
seedCaptainRecord(t, d, db.HoldCaptainRecord{
HoldDID: "did:web:public.hold", OwnerDID: "did:plc:alice", Public: true,
})
gate := newAnonGate(t, d, "", map[string]string{"alice.test": "did:plc:alice"})
got := gate.AuthorizeAnonymous(context.Background(), []auth.AccessEntry{pullEntry("alice.test/app")})
if len(got) != 1 {
t.Fatalf("public hold must keep the scope, got %v", names(got))
}
}
// The defect: a private hold refuses the token at /v2/, so /auth/token must not
// sign it in the first place.
func TestAnonymousAuthorizer_PrivateHoldDropsScope(t *testing.T) {
d := newTestDB(t)
seedUser(t, d, "did:plc:alice", "alice.test", "did:web:private.hold")
seedCaptainRecord(t, d, db.HoldCaptainRecord{
HoldDID: "did:web:private.hold", OwnerDID: "did:plc:alice", Public: false,
})
gate := newAnonGate(t, d, "", map[string]string{"alice.test": "did:plc:alice"})
got := gate.AuthorizeAnonymous(context.Background(), []auth.AccessEntry{pullEntry("alice.test/app")})
if len(got) != 0 {
t.Fatalf("private hold must drop the scope, got %v", names(got))
}
}
// Different owners mean different holds, so the verdict is per entry.
func TestAnonymousAuthorizer_MixedOwnersNarrowsToThePublicOne(t *testing.T) {
d := newTestDB(t)
seedUser(t, d, "did:plc:alice", "alice.test", "did:web:public.hold")
seedUser(t, d, "did:plc:bob", "bob.test", "did:web:private.hold")
seedCaptainRecord(t, d, db.HoldCaptainRecord{
HoldDID: "did:web:public.hold", OwnerDID: "did:plc:alice", Public: true,
})
seedCaptainRecord(t, d, db.HoldCaptainRecord{
HoldDID: "did:web:private.hold", OwnerDID: "did:plc:bob", Public: false,
})
gate := newAnonGate(t, d, "", map[string]string{
"alice.test": "did:plc:alice",
"bob.test": "did:plc:bob",
})
got := gate.AuthorizeAnonymous(context.Background(), []auth.AccessEntry{
pullEntry("alice.test/app"),
pullEntry("bob.test/app"),
})
if len(got) != 1 || got[0].Name != "alice.test/app" {
t.Fatalf("expected only alice.test/app to survive, got %v", names(got))
}
}
// A DID owner arrives hyphen-encoded, because OCI reference grammar forbids
// colons in path components. The registry middleware decodes it; so must this.
func TestAnonymousAuthorizer_DecodesHyphenEncodedDIDOwner(t *testing.T) {
d := newTestDB(t)
seedUser(t, d, "did:plc:alice", "alice.test", "did:web:private.hold")
seedCaptainRecord(t, d, db.HoldCaptainRecord{
HoldDID: "did:web:private.hold", OwnerDID: "did:plc:alice", Public: false,
})
gate := newAnonGate(t, d, "", map[string]string{"did:plc:alice": "did:plc:alice"})
got := gate.AuthorizeAnonymous(context.Background(), []auth.AccessEntry{pullEntry("did-plc-alice/app")})
if len(got) != 0 {
t.Fatalf("hyphen-encoded DID owner must resolve to the same private hold, got %v", names(got))
}
}
// /v2/ applies a single-hop successor redirect before checking read access, so
// a migrated hold is judged by its successor's captain record. Checking the old
// identity here would reintroduce the disagreement this gate closes.
func TestAnonymousAuthorizer_FollowsSuccessorToPrivate(t *testing.T) {
d := newTestDB(t)
seedUser(t, d, "did:plc:alice", "alice.test", "did:web:old.hold")
seedCaptainRecord(t, d, db.HoldCaptainRecord{
HoldDID: "did:web:old.hold", OwnerDID: "did:plc:alice",
Public: true, Successor: "did:web:new.hold",
})
seedCaptainRecord(t, d, db.HoldCaptainRecord{
HoldDID: "did:web:new.hold", OwnerDID: "did:plc:alice", Public: false,
})
gate := newAnonGate(t, d, "", map[string]string{"alice.test": "did:plc:alice"})
got := gate.AuthorizeAnonymous(context.Background(), []auth.AccessEntry{pullEntry("alice.test/app")})
if len(got) != 0 {
t.Fatalf("the successor is private, so the scope must be dropped, got %v", names(got))
}
}
// The mirror image: the old hold is private, the successor public. Blobs go to
// the successor, so the successor's answer is the one that counts.
func TestAnonymousAuthorizer_FollowsSuccessorToPublic(t *testing.T) {
d := newTestDB(t)
seedUser(t, d, "did:plc:alice", "alice.test", "did:web:old.hold")
seedCaptainRecord(t, d, db.HoldCaptainRecord{
HoldDID: "did:web:old.hold", OwnerDID: "did:plc:alice",
Public: false, Successor: "did:web:new.hold",
})
seedCaptainRecord(t, d, db.HoldCaptainRecord{
HoldDID: "did:web:new.hold", OwnerDID: "did:plc:alice", Public: true,
})
gate := newAnonGate(t, d, "", map[string]string{"alice.test": "did:plc:alice"})
got := gate.AuthorizeAnonymous(context.Background(), []auth.AccessEntry{pullEntry("alice.test/app")})
if len(got) != 1 {
t.Fatalf("the successor is public, so the scope must survive, got %v", names(got))
}
}
// Single hop only, matching resolveSuccessor: the second hop is not followed,
// so the first successor's own record decides.
func TestAnonymousAuthorizer_SuccessorChainIsNotFollowed(t *testing.T) {
d := newTestDB(t)
seedUser(t, d, "did:plc:alice", "alice.test", "did:web:hop0.hold")
seedCaptainRecord(t, d, db.HoldCaptainRecord{
HoldDID: "did:web:hop0.hold", OwnerDID: "did:plc:alice",
Public: false, Successor: "did:web:hop1.hold",
})
seedCaptainRecord(t, d, db.HoldCaptainRecord{
HoldDID: "did:web:hop1.hold", OwnerDID: "did:plc:alice",
Public: true, Successor: "did:web:hop2.hold",
})
seedCaptainRecord(t, d, db.HoldCaptainRecord{
HoldDID: "did:web:hop2.hold", OwnerDID: "did:plc:alice", Public: false,
})
gate := newAnonGate(t, d, "", map[string]string{"alice.test": "did:plc:alice"})
got := gate.AuthorizeAnonymous(context.Background(), []auth.AccessEntry{pullEntry("alice.test/app")})
if len(got) != 1 {
t.Fatalf("only one hop is followed, so hop1's public flag decides, got %v", names(got))
}
}
// Fail open on an unresolvable identity: /v2/ still enforces, and a DNS blip
// must not start refusing tokens for public images.
func TestAnonymousAuthorizer_IdentityErrorFailsOpen(t *testing.T) {
d := newTestDB(t)
gate := newAnonGate(t, d, "", nil) // every lookup errors
got := gate.AuthorizeAnonymous(context.Background(), []auth.AccessEntry{pullEntry("nobody.test/app")})
if len(got) != 1 {
t.Fatalf("an identity resolution failure must fail open, got %v", names(got))
}
}
// A hold with no cached captain record is an unknown, not a private hold.
func TestAnonymousAuthorizer_MissingCaptainRecordFailsOpen(t *testing.T) {
d := newTestDB(t)
seedUser(t, d, "did:plc:alice", "alice.test", "did:web:uningested.hold")
gate := newAnonGate(t, d, "", map[string]string{"alice.test": "did:plc:alice"})
got := gate.AuthorizeAnonymous(context.Background(), []auth.AccessEntry{pullEntry("alice.test/app")})
if len(got) != 1 {
t.Fatalf("a cold captain cache must fail open, got %v", names(got))
}
}
// A successor whose own record has not been ingested is the same unknown.
func TestAnonymousAuthorizer_MissingSuccessorRecordFailsOpen(t *testing.T) {
d := newTestDB(t)
seedUser(t, d, "did:plc:alice", "alice.test", "did:web:old.hold")
seedCaptainRecord(t, d, db.HoldCaptainRecord{
HoldDID: "did:web:old.hold", OwnerDID: "did:plc:alice",
Public: false, Successor: "did:web:unknown.hold",
})
gate := newAnonGate(t, d, "", map[string]string{"alice.test": "did:plc:alice"})
got := gate.AuthorizeAnonymous(context.Background(), []auth.AccessEntry{pullEntry("alice.test/app")})
if len(got) != 1 {
t.Fatalf("an unknown successor must fail open, got %v", names(got))
}
}
// A DB error is a lookup failure like any other.
func TestAnonymousAuthorizer_DBErrorFailsOpen(t *testing.T) {
d := newTestDB(t)
_ = d.Close()
gate := newAnonGate(t, d, "", map[string]string{"alice.test": "did:plc:alice"})
got := gate.AuthorizeAnonymous(context.Background(), []auth.AccessEntry{pullEntry("alice.test/app")})
if len(got) != 1 {
t.Fatalf("a DB error must fail open, got %v", names(got))
}
}
// No hold configured anywhere: /v2/ skips its own check in exactly this case
// (it requires a non-empty hold DID), so this gate must skip it too.
func TestAnonymousAuthorizer_NoHoldConfiguredKeepsScope(t *testing.T) {
d := newTestDB(t)
seedUser(t, d, "did:plc:alice", "alice.test", "")
gate := newAnonGate(t, d, "", map[string]string{"alice.test": "did:plc:alice"})
got := gate.AuthorizeAnonymous(context.Background(), []auth.AccessEntry{pullEntry("alice.test/app")})
if len(got) != 1 {
t.Fatalf("no hold means no verdict to pre-empt, got %v", names(got))
}
}
// The AppView default hold covers users whose cached profile has no defaultHold.
func TestAnonymousAuthorizer_FallsBackToDefaultHold(t *testing.T) {
d := newTestDB(t)
seedUser(t, d, "did:plc:alice", "alice.test", "")
seedCaptainRecord(t, d, db.HoldCaptainRecord{
HoldDID: "did:web:default.hold", OwnerDID: "did:plc:operator", Public: false,
})
gate := newAnonGate(t, d, "did:web:default.hold", map[string]string{"alice.test": "did:plc:alice"})
got := gate.AuthorizeAnonymous(context.Background(), []auth.AccessEntry{pullEntry("alice.test/app")})
if len(got) != 0 {
t.Fatalf("the fallback hold's captain record must decide, got %v", names(got))
}
}
// Entries that grant nothing, and scopes that name no hold, are passed through
// without a lookup — they are not denials.
func TestAnonymousAuthorizer_NonRepositoryAndActionlessEntriesPassThrough(t *testing.T) {
d := newTestDB(t)
gate := newAnonGate(t, d, "", nil)
gate.resolveOwnerDID = func(_ context.Context, identity string) (string, error) {
t.Errorf("no identity resolution should happen, but %q was resolved", identity)
return "", nil
}
in := []auth.AccessEntry{
{Type: "repository", Name: "alice.test/app"}, // no actions
{Type: "registry", Name: "catalog", Actions: []string{"pull"}}, // not hold-gated
{Type: "repository", Name: "noslash", Actions: []string{"pull"}}, // malformed name
}
got := gate.AuthorizeAnonymous(context.Background(), in)
if len(got) != len(in) {
t.Fatalf("expected all %d entries to pass through, got %v", len(in), names(got))
}
}