v2.30.0-pre 7341e0e

auth_test.go RawHistoryFolder

1package middleware
2
3import (
4	"context"
5	"errors"
6	"net/http"
7	"net/http/httptest"
8	"testing"
9	"time"
10
11	"github.com/bnema/zerowrap"
12	"github.com/stretchr/testify/assert"
13
14	"github.com/bnema/gordon/internal/domain"
15)
16
17func testLogger() zerowrap.Logger {
18	return zerowrap.Default()
19}
20
21func TestRegistryAuthV2_LocalhostBypassRequiresInternalAuth(t *testing.T) {
22	log := testLogger()
23	called := false
24	authSvc := stubAuthService{
25		enabled:  true,
26		authType: domain.AuthTypePassword,
27		validatePassword: func(_ context.Context, _ string, _ string) bool {
28			called = true
29			return false
30		},
31	}
32	internalAuth := InternalRegistryAuth{
33		Username: "internal",
34		Password: "secret",
35	}
36
37	handler := RegistryAuthV2(authSvc, internalAuth, log)(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
38		w.WriteHeader(http.StatusOK)
39	}))
40
41	req := httptest.NewRequest("GET", "/v2/", nil)
42	req.RemoteAddr = "127.0.0.1:12345"
43	req.SetBasicAuth("internal", "secret")
44	rec := httptest.NewRecorder()
45
46	handler.ServeHTTP(rec, req)
47
48	assert.Equal(t, http.StatusOK, rec.Code)
49	assert.False(t, called, "auth service should not be used for internal loopback auth")
50}
51
52func TestRegistryAuthV2_LocalhostWrongInternalAuthFallsBack(t *testing.T) {
53	log := testLogger()
54	called := false
55	authSvc := stubAuthService{
56		enabled:  true,
57		authType: domain.AuthTypePassword,
58		validatePassword: func(_ context.Context, _ string, _ string) bool {
59			called = true
60			return false
61		},
62	}
63	internalAuth := InternalRegistryAuth{
64		Username: "internal",
65		Password: "secret",
66	}
67
68	handler := RegistryAuthV2(authSvc, internalAuth, log)(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
69		w.WriteHeader(http.StatusOK)
70	}))
71
72	req := httptest.NewRequest("GET", "/v2/", nil)
73	req.RemoteAddr = "127.0.0.1:12345"
74	req.SetBasicAuth("internal", "wrong")
75	rec := httptest.NewRecorder()
76
77	handler.ServeHTTP(rec, req)
78
79	assert.Equal(t, http.StatusUnauthorized, rec.Code)
80	assert.Equal(t, `Basic realm="Gordon Registry"`, rec.Header().Get("WWW-Authenticate"))
81	assert.True(t, called, "auth service should be used when internal auth fails")
82}
83
84func TestRegistryAuthV2_NonLocalhostIgnoresInternalAuth(t *testing.T) {
85	log := testLogger()
86	called := false
87	authSvc := stubAuthService{
88		enabled:  true,
89		authType: domain.AuthTypePassword,
90		validatePassword: func(_ context.Context, _ string, _ string) bool {
91			called = true
92			return false
93		},
94	}
95	internalAuth := InternalRegistryAuth{
96		Username: "internal",
97		Password: "secret",
98	}
99
100	handler := RegistryAuthV2(authSvc, internalAuth, log)(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
101		w.WriteHeader(http.StatusOK)
102	}))
103
104	req := httptest.NewRequest("GET", "/v2/", nil)
105	req.RemoteAddr = "192.168.1.10:12345"
106	req.SetBasicAuth("internal", "secret")
107	rec := httptest.NewRecorder()
108
109	handler.ServeHTTP(rec, req)
110
111	assert.Equal(t, http.StatusUnauthorized, rec.Code)
112	assert.Equal(t, `Basic realm="Gordon Registry"`, rec.Header().Get("WWW-Authenticate"))
113	assert.True(t, called, "auth service should be used for non-localhost requests")
114}
115
116type stubAuthService struct {
117	enabled          bool
118	authType         domain.AuthType
119	validatePassword func(ctx context.Context, username, password string) bool
120	validateToken    func(ctx context.Context, tokenString string) (*domain.TokenClaims, error)
121}
122
123func (s stubAuthService) GetAuthType() domain.AuthType {
124	return s.authType
125}
126
127func (s stubAuthService) IsEnabled() bool {
128	return s.enabled
129}
130
131func (s stubAuthService) ValidatePassword(ctx context.Context, username, password string) bool {
132	if s.validatePassword != nil {
133		return s.validatePassword(ctx, username, password)
134	}
135	return false
136}
137
138func (s stubAuthService) ValidateToken(ctx context.Context, tokenString string) (*domain.TokenClaims, error) {
139	if s.validateToken != nil {
140		return s.validateToken(ctx, tokenString)
141	}
142	return nil, errors.New("not implemented")
143}
144
145func (s stubAuthService) GenerateToken(context.Context, string, []string, time.Duration) (string, error) {
146	return "", errors.New("not implemented")
147}
148
149func (s stubAuthService) GenerateAccessToken(context.Context, string, []string, time.Duration) (string, error) {
150	return "", errors.New("not implemented")
151}
152
153func (s stubAuthService) RevokeToken(context.Context, string) error {
154	return errors.New("not implemented")
155}
156
157func (s stubAuthService) RevokeAllTokens(context.Context) (int, error) {
158	return 0, errors.New("not implemented")
159}
160
161func (s stubAuthService) ListTokens(context.Context) ([]domain.Token, error) {
162	return nil, errors.New("not implemented")
163}
164
165func (s stubAuthService) GeneratePasswordHash(string) (string, error) {
166	return "", errors.New("not implemented")
167}
168
169func (s stubAuthService) GetAuthStatus(context.Context) (*domain.AuthStatus, error) {
170	return nil, errors.New("not implemented")
171}
172
173func (s stubAuthService) ExtendToken(context.Context, string) (string, error) {
174	return "", errors.New("not implemented")
175}
176
177// Tests for checkScopeAccess function
178
179func TestCheckScopeAccess_ActionMapping(t *testing.T) {
180	log := testLogger()
181
182	tests := []struct {
183		name           string
184		method         string
185		expectedAction string
186	}{
187		{"GET maps to pull", http.MethodGet, "pull"},
188		{"HEAD maps to pull", http.MethodHead, "pull"},
189		{"PUT maps to push", http.MethodPut, "push"},
190		{"POST maps to push", http.MethodPost, "push"},
191		{"PATCH maps to push", http.MethodPatch, "push"},
192		{"DELETE maps to push", http.MethodDelete, "push"},
193		{"OPTIONS defaults to pull", http.MethodOptions, "pull"},
194	}
195
196	for _, tt := range tests {
197		t.Run(tt.name, func(t *testing.T) {
198			claims := &domain.TokenClaims{
199				Scopes: []string{"repository:myrepo:" + tt.expectedAction},
200			}
201
202			req := httptest.NewRequest(tt.method, "/v2/myrepo/manifests/latest", nil)
203			result := checkScopeAccess(req, claims, log)
204
205			assert.True(t, result, "method %s should be allowed with %s scope", tt.method, tt.expectedAction)
206		})
207	}
208}
209
210func TestCheckScopeAccess_RepoNameExtraction(t *testing.T) {
211	log := testLogger()
212
213	tests := []struct {
214		name     string
215		path     string
216		wantRepo string
217		scopes   []string
218		want     bool
219	}{
220		{
221			name:   "simple repo with manifests",
222			path:   "/v2/myrepo/manifests/latest",
223			scopes: []string{"repository:myrepo:pull"},
224			want:   true,
225		},
226		{
227			name:   "simple repo with blobs",
228			path:   "/v2/myrepo/blobs/sha256:abc123",
229			scopes: []string{"repository:myrepo:pull"},
230			want:   true,
231		},
232		{
233			name:   "nested repo with manifests",
234			path:   "/v2/myorg/myapp/manifests/v1.0",
235			scopes: []string{"repository:myorg/myapp:pull"},
236			want:   true,
237		},
238		{
239			name:   "deeply nested repo",
240			path:   "/v2/myorg/team/myapp/manifests/latest",
241			scopes: []string{"repository:myorg/team/myapp:pull"},
242			want:   true,
243		},
244		{
245			name:   "repo with tags list",
246			path:   "/v2/myrepo/tags/list",
247			scopes: []string{"repository:myrepo:pull"},
248			want:   true,
249		},
250		{
251			name:   "wrong repo name",
252			path:   "/v2/myrepo/manifests/latest",
253			scopes: []string{"repository:otherrepo:pull"},
254			want:   false,
255		},
256	}
257
258	for _, tt := range tests {
259		t.Run(tt.name, func(t *testing.T) {
260			claims := &domain.TokenClaims{Scopes: tt.scopes}
261			req := httptest.NewRequest(http.MethodGet, tt.path, nil)
262			result := checkScopeAccess(req, claims, log)
263			assert.Equal(t, tt.want, result)
264		})
265	}
266}
267
268func TestCheckScopeAccess_WildcardMatching(t *testing.T) {
269	log := testLogger()
270
271	tests := []struct {
272		name   string
273		path   string
274		scopes []string
275		want   bool
276	}{
277		{
278			name:   "wildcard repo matches any",
279			path:   "/v2/anyrepo/manifests/latest",
280			scopes: []string{"repository:*:pull"},
281			want:   true,
282		},
283		{
284			name:   "org wildcard matches repo in org",
285			path:   "/v2/myorg/myapp/manifests/latest",
286			scopes: []string{"repository:myorg/*:pull"},
287			want:   true,
288		},
289		{
290			name:   "org wildcard does not match other org",
291			path:   "/v2/otherorg/myapp/manifests/latest",
292			scopes: []string{"repository:myorg/*:pull"},
293			want:   false,
294		},
295		{
296			name:   "wildcard action grants push",
297			path:   "/v2/myrepo/manifests/latest",
298			scopes: []string{"repository:myrepo:*"},
299			want:   true,
300		},
301	}
302
303	for _, tt := range tests {
304		t.Run(tt.name, func(t *testing.T) {
305			claims := &domain.TokenClaims{Scopes: tt.scopes}
306			req := httptest.NewRequest(http.MethodGet, tt.path, nil)
307			result := checkScopeAccess(req, claims, log)
308			assert.Equal(t, tt.want, result)
309		})
310	}
311}
312
313func TestCheckScopeAccess_SpecialRoutes(t *testing.T) {
314	log := testLogger()
315
316	tests := []struct {
317		name   string
318		path   string
319		scopes []string
320		want   bool
321	}{
322		{
323			name:   "v2 root is allowed",
324			path:   "/v2/",
325			scopes: []string{"repository:myrepo:pull"},
326			want:   true,
327		},
328		{
329			name:   "non-v2 path is allowed",
330			path:   "/healthz",
331			scopes: []string{"repository:myrepo:pull"},
332			want:   true,
333		},
334		{
335			name:   "catalog path is handled",
336			path:   "/v2/_catalog",
337			scopes: []string{"repository:myrepo:pull"},
338			want:   true,
339		},
340	}
341
342	for _, tt := range tests {
343		t.Run(tt.name, func(t *testing.T) {
344			claims := &domain.TokenClaims{Scopes: tt.scopes}
345			req := httptest.NewRequest(http.MethodGet, tt.path, nil)
346			result := checkScopeAccess(req, claims, log)
347			assert.Equal(t, tt.want, result)
348		})
349	}
350}
351
352func TestCheckScopeAccess_DenialCases(t *testing.T) {
353	log := testLogger()
354
355	tests := []struct {
356		name   string
357		method string
358		path   string
359		scopes []string
360		want   bool
361	}{
362		{
363			name:   "pull scope denies push",
364			method: http.MethodPut,
365			path:   "/v2/myrepo/manifests/latest",
366			scopes: []string{"repository:myrepo:pull"},
367			want:   false,
368		},
369		{
370			name:   "wrong repo denies access",
371			method: http.MethodGet,
372			path:   "/v2/myrepo/manifests/latest",
373			scopes: []string{"repository:otherrepo:pull"},
374			want:   false,
375		},
376		{
377			name:   "empty scopes denies access",
378			method: http.MethodGet,
379			path:   "/v2/myrepo/manifests/latest",
380			scopes: []string{},
381			want:   false,
382		},
383		{
384			name:   "invalid scope format is skipped",
385			method: http.MethodGet,
386			path:   "/v2/myrepo/manifests/latest",
387			scopes: []string{"invalid-scope-format"},
388			want:   false,
389		},
390	}
391
392	for _, tt := range tests {
393		t.Run(tt.name, func(t *testing.T) {
394			claims := &domain.TokenClaims{Scopes: tt.scopes}
395			req := httptest.NewRequest(tt.method, tt.path, nil)
396			result := checkScopeAccess(req, claims, log)
397			assert.Equal(t, tt.want, result)
398		})
399	}
400}

Keyboard shortcuts

Single-key shortcuts are on. ctrl k and esc work whatever this says.

ctrl k :
go to a section, branch, tag, file or repository
?
this list
j k
move the row cursor down / up
enter
open the row under the cursor; on Files, a directory opens or closes in place
↑ ↓ ← → home end
in the file tree (focus on a row): previous / next row, close / open a directory or go to its parent / first entry, first / last row. They never open a file
l h
on Files: open a directory or a file / close a directory or go up to its parent, then to the parent page. Elsewhere: into the side panel (on a merge request, its files) / back to the content; there j k move between its controls
g g G
first / last row
/
filter the list; on Files, find a file anywhere in the repository (esc clears, enter opens the best match)
b
switch branch or tag
g s g f g c g b g m g i g r g a
tabs: summary, files, commits, branches & tags, merge requests, issues, releases, actions (when available)
g h
home
w
wide mode: use the whole width of the screen, or not (remembered)
y
copy a permalink pinned to the commit
d u x
diff: split / unified / collapse or expand all
e E
diff: the whole file in view / every file, with the lines the hunks hide
n
merge request: next note in the diff
esc
close a dialog, leave a field

Branches 7

Tags 106

↑↓ moveenter openesc close