mirror of
https://tangled.org/evan.jarrett.net/at-container-registry
synced 2026-09-02 08:16:57 +00:00
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
134 lines
5.6 KiB
Go
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))
|
|
}
|
|
}
|
|
}
|