//go:build integration package integration import ( "context" "fmt" "strings" "testing" "github.com/google/go-containerregistry/pkg/name" "github.com/google/go-containerregistry/pkg/v1/random" "atcr.io/internal/testharness" ) // TestAuthMatrix exercises the authorization matrix that /auth/token plus the // hold authorizer enforce together. Each row picks an actor (captain / crew // with write / crew with read-only / stranger / anonymous) and an operation // (push / pull) and asserts whether the registry round-trip succeeds. We // reuse a single harness across rows: the actors and repos don't overlap so // state mutations (e.g. layer records for a push) don't bleed between cases. // // The whole matrix runs once per OCI client in `Clients` so we catch // dialect differences between ggcr (crane) and the OCI working group client // (oras-go). func TestAuthMatrix(t *testing.T) { h := testharness.New(t) // Identities. Each row references one of these — strangers and read-only // crew get separate handles so their state stays isolated. captain := h.Captain crewWriter := h.AddSailor("writer.test") crewReader := h.AddSailorWithPermissions("reader.test", []string{"blob:read"}) stranger := h.AddStranger("stranger.test") // Seed a pull target by pushing once as the captain via crane. Both // clients pull from the same seed: the manifest exists in the hold // regardless of which client reads it. seedRef := mustParseRef(t, fmt.Sprintf("%s/%s/seed:tag", h.AppViewHostPort(), captain.Handle())) seedImage, err := random.Image(1<<18, 2) // 256KB × 2 layers — keep it small if err != nil { t.Fatalf("build seed image: %v", err) } if err := (craneClient{}).Push(t.Context(), t, seedRef.String(), seedImage, h.RegistryCreds(captain)); err != nil { t.Fatalf("seed push: %v", err) } cases := []struct { name string creds testharness.Auth op string // "push" or "pull" repoFn func(client string) string wantErr bool // errContains lists substrings, any of which is acceptable in the // error message. Different OCI clients wrap registry responses // with different fidelity: // - crane surfaces the registry response body verbatim // ("authentication required", "blob:write", etc.) // - oras-go drops the /auth/token body and surfaces only the // HTTP status text ("Unauthorized") // - regclient strips response bodies entirely and surfaces // "unauthorized" for any 401/403 // We accept the broader signals so the matrix can include clients // with coarser error wrapping. The strict assertions still apply // to crane and oras; regclient gets the "request was denied" // signal but loses the reason-string detail. errContains []string }{ { name: "captain_push", creds: h.RegistryCreds(captain), op: "push", repoFn: func(c string) string { return fmt.Sprintf("%s/%s/own-%s:tag", h.AppViewHostPort(), captain.Handle(), c) }, }, { name: "captain_pull", creds: h.RegistryCreds(captain), op: "pull", repoFn: func(_ string) string { return seedRef.String() }, }, { name: "crew_write_push", creds: h.RegistryCreds(crewWriter), op: "push", repoFn: func(c string) string { return fmt.Sprintf("%s/%s/own-%s:tag", h.AppViewHostPort(), crewWriter.Handle(), c) }, }, { name: "crew_write_pull", creds: h.RegistryCreds(crewWriter), op: "pull", repoFn: func(_ string) string { return seedRef.String() }, }, { name: "crew_read_only_push_denied", creds: h.RegistryCreds(crewReader), op: "push", repoFn: func(c string) string { return fmt.Sprintf("%s/%s/own-%s:tag", h.AppViewHostPort(), crewReader.Handle(), c) }, // authgate's checkCrewBlobWrite surfaces "lacks blob:write" through // errcode.ErrorCodeDenied. The OCI client wraps it with "DENIED". wantErr: true, errContains: []string{"blob:write", "unauthorized"}, }, { name: "crew_read_only_pull", creds: h.RegistryCreds(crewReader), op: "pull", repoFn: func(_ string) string { return seedRef.String() }, }, { name: "stranger_push_denied", creds: h.RegistryCreds(stranger), op: "push", repoFn: func(c string) string { return fmt.Sprintf("%s/%s/own-%s:tag", h.AppViewHostPort(), stranger.Handle(), c) }, // hold_crew_members has no row for stranger → checkCrewBlobWrite // returns "crew membership required". wantErr: true, errContains: []string{"crew membership required", "unauthorized"}, }, { name: "stranger_pull", // Pull bypasses the membership requirement (it's push-only), so a // PDS-known but non-crew identity can still pull from a public // hold. This is the credential-helper first-pull case. creds: h.RegistryCreds(stranger), op: "pull", repoFn: func(_ string) string { return seedRef.String() }, }, { name: "anonymous_push_denied", creds: h.AnonCreds(), op: "push", // Target a real identity's namespace. A push scope carries a write // action, so it is never pull-only and /auth/token challenges it even // with anonymous pull enabled. crane surfaces the response body // ("authentication required"); oras-go drops the body and surfaces // "Unauthorized". repoFn: func(c string) string { return fmt.Sprintf("%s/%s/anon-push-%s:tag", h.AppViewHostPort(), captain.Handle(), c) }, wantErr: true, errContains: []string{"authentication required", "Unauthorized", "unauthorized"}, }, { name: "anonymous_pull", // The harness hold is public (captain.Public), so a credential-less // pull is allowed: /auth/token issues a pull-only token with an empty // subject and the hold serves the blobs. Privacy is still the hold's // call — a private hold rejects the same request. creds: h.AnonCreds(), op: "pull", repoFn: func(_ string) string { return seedRef.String() }, }, { name: "anonymous_pull_unknown_identity_denied", // An unresolvable identity must be a clean NAME_UNKNOWN, not a 500. // Anonymous pull makes this path reachable without credentials, and a // 5xx would send clients that retry server errors into a retry loop. creds: h.AnonCreds(), op: "pull", repoFn: func(c string) string { return fmt.Sprintf("%s/not-a-real-handle/%s:tag", h.AppViewHostPort(), c) }, wantErr: true, errContains: []string{"not known to registry", "NAME_UNKNOWN", "not found", "404"}, }, } for _, c := range Clients { t.Run(c.Name(), func(t *testing.T) { for _, tc := range cases { t.Run(tc.name, func(t *testing.T) { err := runOp(t.Context(), t, c, tc.op, tc.repoFn(c.Name()), tc.creds) if tc.wantErr { if err == nil { t.Fatalf("%s: expected error, got nil", tc.name) } if len(tc.errContains) > 0 && !containsAny(err.Error(), tc.errContains) { t.Errorf("%s: expected error containing any of %q, got: %v", tc.name, tc.errContains, err) } return } if err != nil { t.Fatalf("%s: unexpected error: %v", tc.name, err) } }) } }) } } // TestAuthMatrixPrivateHold covers `public: false` + `allow_all_crew: true`, // the production configuration: anyone may pull and push, but they must have // an account. Anonymous gets nothing. // // The two captain settings are orthogonal. public decides whether a reader // with no identity is admitted; allowAllCrew decides whether any authenticated // user may self-register as crew. With allowAllCrew on, a signed-in stranger // is auto-enrolled with blob:read+blob:write on first contact (the appview // reconciles crew membership for pull-only token requests too), so "crew only" // and "anyone with an account" are the same set. That is the intent, and // stranger_pull below pins it. // // TestAuthMatrix only ever ran against a public hold, so the anonymous-denied // half of this had no end-to-end coverage at all. func TestAuthMatrixPrivateHold(t *testing.T) { h := testharness.New(t, testharness.WithPrivateHold()) captain := h.Captain crewWriter := h.AddSailor("writer.test") crewReader := h.AddSailorWithPermissions("reader.test", []string{"blob:read"}) stranger := h.AddStranger("stranger.test") // Seed as the captain: the owner can always write to their own hold. seedRef := mustParseRef(t, fmt.Sprintf("%s/%s/seed:tag", h.AppViewHostPort(), captain.Handle())) seedImage, err := random.Image(1<<18, 2) if err != nil { t.Fatalf("build seed image: %v", err) } if err := (craneClient{}).Push(t.Context(), t, seedRef.String(), seedImage, h.RegistryCreds(captain)); err != nil { t.Fatalf("seed push: %v", err) } // Denials on a private hold are authorization failures, so accept the // signals the various clients surface for a 401/403. denied := []string{"unauthorized", "Unauthorized", "authentication required", "read access denied", "not a crew member", "denied", "403", "401"} cases := []struct { name string creds testharness.Auth op string repoFn func(client string) string wantErr bool errContains []string }{ { name: "captain_pull", creds: h.RegistryCreds(captain), op: "pull", repoFn: func(_ string) string { return seedRef.String() }, }, { name: "crew_write_pull", // blob:write implies blob:read. creds: h.RegistryCreds(crewWriter), op: "pull", repoFn: func(_ string) string { return seedRef.String() }, }, { name: "crew_read_only_pull", creds: h.RegistryCreds(crewReader), op: "pull", repoFn: func(_ string) string { return seedRef.String() }, }, { name: "stranger_pull", // Allowed, and deliberately so: allow_all_crew means a signed-in // stranger self-registers as crew on first contact and is granted // blob:read+blob:write. "You need an account" is the rule here, // not "the captain must have added you". creds: h.RegistryCreds(stranger), op: "pull", repoFn: func(_ string) string { return seedRef.String() }, }, { name: "anonymous_pull_denied", // captain.Public is the only thing that admits an anonymous // reader, and it is false here. creds: h.AnonCreds(), op: "pull", repoFn: func(_ string) string { return seedRef.String() }, wantErr: true, errContains: denied, }, { name: "captain_push", creds: h.RegistryCreds(captain), op: "push", repoFn: func(c string) string { return fmt.Sprintf("%s/%s/priv-%s:tag", h.AppViewHostPort(), captain.Handle(), c) }, }, { name: "anonymous_push_denied", creds: h.AnonCreds(), op: "push", repoFn: func(c string) string { return fmt.Sprintf("%s/%s/anon-push-%s:tag", h.AppViewHostPort(), captain.Handle(), c) }, wantErr: true, errContains: denied, }, } for _, c := range Clients { t.Run(c.Name(), func(t *testing.T) { for _, tc := range cases { t.Run(tc.name, func(t *testing.T) { err := runOp(t.Context(), t, c, tc.op, tc.repoFn(c.Name()), tc.creds) if tc.wantErr { if err == nil { t.Fatalf("%s: expected error, got nil", tc.name) } if len(tc.errContains) > 0 && !containsAny(err.Error(), tc.errContains) { t.Errorf("%s: expected error containing any of %q, got: %v", tc.name, tc.errContains, err) } return } if err != nil { t.Fatalf("%s: unexpected error: %v", tc.name, err) } }) } }) } } // runOp performs the chosen op against the given ref using the supplied // client and credentials. Push builds a fresh random image so concurrent or // subsequent runs don't collide on shared blob digests at the registry; pull // just resolves the reference, which is enough to exercise the auth path // even without comparing digests. func runOp(ctx context.Context, t *testing.T, c Client, op, ref string, creds testharness.Auth) error { t.Helper() switch op { case "push": img, err := random.Image(1<<17, 2) // 128KB × 2 layers if err != nil { return fmt.Errorf("build random image: %w", err) } return c.Push(ctx, t, ref, img, creds) case "pull": _, err := c.Pull(ctx, ref, creds) return err default: return fmt.Errorf("unknown op %q", op) } } func containsAny(s string, subs []string) bool { for _, sub := range subs { if strings.Contains(s, sub) { return true } } return false } func mustParseRef(t *testing.T, s string) name.Reference { t.Helper() r, err := name.ParseReference(s, name.Insecure) if err != nil { t.Fatalf("parse ref %q: %v", s, err) } return r }