Files
at-container-registry/test/integration/auth_matrix_test.go
2026-05-11 19:53:13 -05:00

212 lines
7.2 KiB
Go
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
//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",
repoFn: func(c string) string { return fmt.Sprintf("%s/anonymous/own-%s:tag", h.AppViewHostPort(), c) },
// /auth/token requires Basic auth — no creds → 401. crane
// surfaces the response body ("authentication required");
// oras-go drops the body and surfaces "Unauthorized".
wantErr: true,
errContains: []string{"authentication required", "Unauthorized", "unauthorized"},
},
{
name: "anonymous_pull_denied",
creds: h.AnonCreds(),
op: "pull",
repoFn: func(_ string) string { return seedRef.String() },
wantErr: true,
errContains: []string{"authentication required", "Unauthorized", "unauthorized"},
},
}
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
}