mirror of
https://tangled.org/evan.jarrett.net/at-container-registry
synced 2026-09-02 08:16:57 +00:00
a7569a7added credential-less pulls of public images. Three things about it were wrong, all of them in how the appview handled the decision that belongs to the hold. **Scope handling was all-or-nothing.** IsPullOnlyScope required every requested action to already be "pull", but clients routinely ask for more than the operation needs — pull,push is common for a plain read, and some ask for pull,push,delete up front. Those were rejected and challenged, leaving a credential-less client no way to pull even a public image, which is the entire feature. NarrowToPullOnly drops the write actions and issues a token carrying "pull" and nothing else. Granting a subset is what the distribution token spec expects. The allowlist property is preserved: "pull" is the only action that survives, and "*" is deliberately not expanded into it, since a wildcard request is not evidence the caller wants a read. **The appview-side read gate was inert.** checkReadAccess passed p.ctx.DID, the DID of the repository *owner*, not the requester. Any non-empty DID satisfies a private hold's check, and the owner's is never empty, so it asked "may the owner read their own hold", answered yes, and admitted everyone. Worse, CheckReadAccessWithCaptain admitted any authenticated DID to a private hold at all, on an explicitly-MVP assumption that holding a DID was close enough to being a sailor. Every doc says otherwise (docs/hold.md:109 "Crew with blob:read", CLAUDE.md:140, docs/BYOS.md:280) and so does the hold (ValidateBlobReadAccess: owner, or crew carrying blob:read/blob:write). It now takes isCrew and requires owner-or-crew, and callers only pay for the crew lookup when it can change the answer — a public hold or an anonymous caller is decided by the captain record alone. Nothing here loosens access; it brings the local gate into agreement with the authority. **Denials could not reach the client.** distribution's blobHandler.GetBlob maps everything except ErrBlobUnknown to ErrorCodeUnknown, so a 401 raised in the blob store left as a 500 — misreporting an auth failure as a server fault, and giving BearerChallenge no 401 to attach WWW-Authenticate to, so Docker was told "server error" instead of being prompted for credentials. Clients that retry 5xx looped: 4.1s per case in the matrix, now 0.01s. The check moves to Repository(), where an errcode.Error is passed through verbatim by the registry app — the same mechanisma7569a7used for NAME_UNKNOWN. It fails open on a lookup error, since the hold is the authority and a transient failure should not break public pulls. Removes auth.allow_anonymous_pull. It could only ever withhold — captain.Public is what grants — so it was a second flag for a decision the hold already owns, and gating it appview-side was never the intent. Layer bytes 307 straight to S3, so the appview is not even in the path whose cost might have justified an operator-side lever. Tests: TestAuthMatrix only ever ran against a public hold, and its pull cases never fetched a layer — crane.Pull is lazy and img.Digest() needs only the manifest, which ATCR serves from the user's PDS where it is world-readable, so no pull row in the matrix touched blob authorization at all. Pulls now materialize layer bytes, and testharness.WithPrivateHold plus TestAuthMatrixPrivateHold cover public:false + allow_all_crew:true — the production shape, where anyone with an account pulls and pushes and anonymous gets nothing. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
366 lines
12 KiB
Go
366 lines
12 KiB
Go
//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
|
||
}
|