diff --git a/pkg/auth/token/narrow_test.go b/pkg/auth/token/narrow_test.go new file mode 100644 index 0000000..04f4513 --- /dev/null +++ b/pkg/auth/token/narrow_test.go @@ -0,0 +1,133 @@ +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)) + } + } +}