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)) } }