Files
at-container-registry/pkg/auth/token/narrow_test.go
T
Evan JarrettandClaude Opus 5 ae1d7ba626 auth: test NarrowToPullOnly, the function that gates the anonymous path
NarrowToPullOnly had no test. IsPullOnlyScope has a thorough one, but it only
answers yes/no — NarrowToPullOnly rewrites the access list, so what it emits is
what gets signed, and anonymous tokens skip the authgate entirely. Nothing
downstream re-authorizes what this function decides to hand out.

The load-bearing property is the allowlist: "pull" is the only action that can
survive. Beyond the per-case assertions, every case re-checks that no other
action reached the output, so a new case cannot accidentally assert its way
past the property the function exists to hold.

The wildcard cases are the point. A wildcard action means "any action" to
distribution's actionSet.contains, so expanding "*" into "pull" is the single
rewrite that would turn a wildcard request into a grant. Mutation-verified:

  * treat "*" as pull            -> the three wildcard cases fail
  * stop narrowing the actions   -> the four narrowing cases fail
  * trim the action slice in place -> DoesNotMutateInput fails

That last one initially did NOT fail, and the fixture is why. The input had
"pull" first, so an in-place trim writing "pull" into index 0 changed nothing
observable and the test passed against the exact defect it was written for.
"pull" is now deliberately not first, with a comment saying so, because the
ordering is the whole instrument here.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SeaUS5AFPX9gqCahoLRMRh
2026-08-25 16:34:25 -05:00

134 lines
5.6 KiB
Go

package token
import (
"slices"
"testing"
"atcr.io/pkg/auth"
)
func narrowRepo(name string, actions ...string) auth.AccessEntry {
return auth.AccessEntry{Type: "repository", Name: name, Actions: actions}
}
// TestNarrowToPullOnly is the guard on the function standing between an
// anonymous caller and a push token. IsPullOnlyScope only answers yes/no;
// this one rewrites the access list, so what it emits is what gets signed.
//
// The load-bearing property is the allowlist: "pull" is the only action that
// can survive. Every case below that expects a dropped entry or a trimmed
// action list is really asserting that no other action can reach a token.
func TestNarrowToPullOnly(t *testing.T) {
tests := []struct {
name string
access []auth.AccessEntry
want []auth.AccessEntry
wantGrantable bool
}{
{"nil access (the /v2/ ping)", nil, nil, true},
{"empty access", []auth.AccessEntry{}, nil, true},
{"pull only passes through", []auth.AccessEntry{narrowRepo("alice/app", "pull")},
[]auth.AccessEntry{narrowRepo("alice/app", "pull")}, true},
{"pull,push narrows to pull", []auth.AccessEntry{narrowRepo("alice/app", "pull", "push")},
[]auth.AccessEntry{narrowRepo("alice/app", "pull")}, true},
{"pull,push,delete narrows to pull", []auth.AccessEntry{narrowRepo("alice/app", "pull", "push", "delete")},
[]auth.AccessEntry{narrowRepo("alice/app", "pull")}, true},
// No pull requested means the caller is not asking for a read, so there
// is nothing to grant a subset of. Dropped entirely rather than emitted
// with an empty action list.
{"push alone is dropped", []auth.AccessEntry{narrowRepo("alice/app", "push")}, nil, false},
{"delete alone is dropped", []auth.AccessEntry{narrowRepo("alice/app", "delete")}, nil, false},
{"unknown action is dropped", []auth.AccessEntry{narrowRepo("alice/app", "frobnicate")}, nil, false},
// The critical pair. A wildcard ACTION means "any action" to
// distribution's actionSet.contains, so expanding it into "pull" would
// be the one rewrite that turns a wildcard request into a grant.
// Anonymous tokens skip the authgate, so a token emitted here is
// authorized by nothing downstream.
{"wildcard action is dropped, NOT expanded to pull",
[]auth.AccessEntry{narrowRepo("alice/app", "*")}, nil, false},
{"wildcard action on wildcard name is dropped",
[]auth.AccessEntry{narrowRepo("*", "*")}, nil, false},
{"catalog wildcard is dropped",
[]auth.AccessEntry{{Type: "registry", Name: "catalog", Actions: []string{"*"}}}, nil, false},
// A wildcard riding alongside an explicit pull must not survive the trim.
{"pull plus wildcard keeps only pull",
[]auth.AccessEntry{narrowRepo("alice/app", "pull", "*")},
[]auth.AccessEntry{narrowRepo("alice/app", "pull")}, true},
// Preserved as-is because callers rely on the entry existing; it grants
// nothing on its own, so it does not make the request grantable. Note
// this diverges from IsPullOnlyScope, which answers true here.
{"entry with no actions survives but is not grantable",
[]auth.AccessEntry{narrowRepo("alice/app")},
[]auth.AccessEntry{narrowRepo("alice/app")}, false},
{"mixed entries keep only the pull-bearing one",
[]auth.AccessEntry{narrowRepo("alice/app", "pull"), narrowRepo("alice/other", "push")},
[]auth.AccessEntry{narrowRepo("alice/app", "pull")}, true},
{"several pull-bearing entries all narrow",
[]auth.AccessEntry{narrowRepo("a/one", "pull", "push"), narrowRepo("a/two", "delete", "pull")},
[]auth.AccessEntry{narrowRepo("a/one", "pull"), narrowRepo("a/two", "pull")}, true},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got, grantable := NarrowToPullOnly(tt.access)
if grantable != tt.wantGrantable {
t.Errorf("grantable = %v, want %v", grantable, tt.wantGrantable)
}
if len(got) != len(tt.want) {
t.Fatalf("got %d entries %+v, want %d %+v", len(got), got, len(tt.want), tt.want)
}
for i := range got {
if got[i].Type != tt.want[i].Type || got[i].Name != tt.want[i].Name {
t.Errorf("entry %d = %+v, want %+v", i, got[i], tt.want[i])
}
if !slices.Equal(got[i].Actions, tt.want[i].Actions) {
t.Errorf("entry %d actions = %v, want %v", i, got[i].Actions, tt.want[i].Actions)
}
}
// Nothing but "pull" may ever reach a signed token, whatever the
// case above happens to assert.
for _, e := range got {
for _, a := range e.Actions {
if a != "pull" {
t.Errorf("action %q survived narrowing on %+v", a, e)
}
}
}
})
}
}
// The caller hands its own slice in and keeps using it — the handler logs the
// requested scope after narrowing. Trimming in place (entry.Actions =
// entry.Actions[:1]) would pass every case above while quietly rewriting the
// caller's data.
func TestNarrowToPullOnly_DoesNotMutateInput(t *testing.T) {
// "pull" is deliberately NOT first. An in-place trim writes "pull" into
// index 0, which is invisible when index 0 already holds "pull" — that
// ordering makes this test pass against the very mutation it exists to catch.
input := []auth.AccessEntry{
narrowRepo("alice/app", "push", "pull", "delete"),
narrowRepo("alice/other", "push"),
}
if _, _ = NarrowToPullOnly(input); true {
if want := []string{"push", "pull", "delete"}; !slices.Equal(input[0].Actions, want) {
t.Errorf("input entry 0 was mutated: %v, want %v", input[0].Actions, want)
}
if want := []string{"push"}; !slices.Equal(input[1].Actions, want) {
t.Errorf("input entry 1 was mutated: %v, want %v", input[1].Actions, want)
}
if len(input) != 2 {
t.Errorf("input slice length changed to %d", len(input))
}
}
}