v3.1.0 d676137

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	tokenCalled := false
24	authSvc := stubAuthService{
25		enabled:  true,
26		authType: domain.AuthTypeToken,
27		validateToken: func(_ context.Context, _ string) (*domain.TokenClaims, error) {
28			tokenCalled = true
29			return nil, errors.New("not a token")
30		},
31	}
32	internalAuth := InternalRegistryAuth{
33		Username: "internal",
34		Password: "secret",
35	}
36
37	handler := RegistryAuthV2(authSvc, internalAuth, nil, 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, tokenCalled, "auth service should not be used for internal loopback auth")
50}
51
52func TestRegistryAuthV2_LocalhostWrongInternalAuthFallsBack(t *testing.T) {
53	log := testLogger()
54	tokenCalled := false
55	authSvc := stubAuthService{
56		enabled:  true,
57		authType: domain.AuthTypeToken,
58		validateToken: func(_ context.Context, _ string) (*domain.TokenClaims, error) {
59			tokenCalled = true
60			return nil, errors.New("invalid token")
61		},
62	}
63	internalAuth := InternalRegistryAuth{
64		Username: "internal",
65		Password: "secret",
66	}
67
68	handler := RegistryAuthV2(authSvc, internalAuth, nil, 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.Contains(t, rec.Header().Get("WWW-Authenticate"), "Bearer")
81	assert.True(t, tokenCalled, "auth service should be used when internal auth fails")
82}
83
84func TestRegistryAuthV2_NonLocalhostIgnoresInternalAuth(t *testing.T) {
85	log := testLogger()
86	tokenCalled := false
87	authSvc := stubAuthService{
88		enabled:  true,
89		authType: domain.AuthTypeToken,
90		validateToken: func(_ context.Context, _ string) (*domain.TokenClaims, error) {
91			tokenCalled = true
92			return nil, errors.New("invalid token")
93		},
94	}
95	internalAuth := InternalRegistryAuth{
96		Username: "internal",
97		Password: "secret",
98	}
99
100	handler := RegistryAuthV2(authSvc, internalAuth, nil, 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.Contains(t, rec.Header().Get("WWW-Authenticate"), "Bearer")
113	assert.True(t, tokenCalled, "auth service should be used for non-localhost requests")
114}
115
116type stubAuthService struct {
117	enabled       bool
118	authType      domain.AuthType
119	validateToken func(ctx context.Context, tokenString string) (*domain.TokenClaims, error)
120}
121
122func (s stubAuthService) GetAuthType() domain.AuthType {
123	return s.authType
124}
125
126func (s stubAuthService) IsEnabled() bool {
127	return s.enabled
128}
129
130func (s stubAuthService) ValidateToken(ctx context.Context, tokenString string) (*domain.TokenClaims, error) {
131	if s.validateToken != nil {
132		return s.validateToken(ctx, tokenString)
133	}
134	return nil, errors.New("not implemented")
135}
136
137func (s stubAuthService) GenerateToken(context.Context, string, []string, time.Duration) (string, error) {
138	return "", errors.New("not implemented")
139}
140
141func (s stubAuthService) GenerateAccessToken(context.Context, string, []string, time.Duration) (string, error) {
142	return "", errors.New("not implemented")
143}
144
145func (s stubAuthService) RevokeToken(context.Context, string) error {
146	return errors.New("not implemented")
147}
148
149func (s stubAuthService) RevokeAllTokens(context.Context) (int, error) {
150	return 0, errors.New("not implemented")
151}
152
153func (s stubAuthService) ListTokens(context.Context) ([]domain.Token, error) {
154	return nil, errors.New("not implemented")
155}
156
157func (s stubAuthService) GetAuthStatus(context.Context) (*domain.AuthStatus, error) {
158	return nil, errors.New("not implemented")
159}
160
161func (s stubAuthService) ExtendToken(context.Context, string) (string, error) {
162	return "", errors.New("not implemented")
163}
164
165func (s stubAuthService) GetAccessTokenTTL() time.Duration {
166	return 15 * time.Minute
167}
168
169// Tests for checkScopeAccess function
170
171func TestCheckScopeAccess_ActionMapping(t *testing.T) {
172	log := testLogger()
173
174	tests := []struct {
175		name           string
176		method         string
177		expectedAction string
178	}{
179		{"GET maps to pull", http.MethodGet, "pull"},
180		{"HEAD maps to pull", http.MethodHead, "pull"},
181		{"PUT maps to push", http.MethodPut, "push"},
182		{"POST maps to push", http.MethodPost, "push"},
183		{"PATCH maps to push", http.MethodPatch, "push"},
184		{"DELETE maps to push", http.MethodDelete, "push"},
185		{"OPTIONS defaults to pull", http.MethodOptions, "pull"},
186	}
187
188	for _, tt := range tests {
189		t.Run(tt.name, func(t *testing.T) {
190			claims := &domain.TokenClaims{
191				Scopes: []string{"repository:myrepo:" + tt.expectedAction},
192			}
193
194			req := httptest.NewRequest(tt.method, "/v2/myrepo/manifests/latest", nil)
195			result := checkScopeAccess(req, claims, log)
196
197			assert.True(t, result, "method %s should be allowed with %s scope", tt.method, tt.expectedAction)
198		})
199	}
200}
201
202func TestCheckScopeAccess_RepoNameExtraction(t *testing.T) {
203	log := testLogger()
204
205	tests := []struct {
206		name     string
207		path     string
208		wantRepo string
209		scopes   []string
210		want     bool
211	}{
212		{
213			name:   "simple repo with manifests",
214			path:   "/v2/myrepo/manifests/latest",
215			scopes: []string{"repository:myrepo:pull"},
216			want:   true,
217		},
218		{
219			name:   "simple repo with blobs",
220			path:   "/v2/myrepo/blobs/sha256:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa",
221			scopes: []string{"repository:myrepo:pull"},
222			want:   true,
223		},
224		{
225			name:   "nested repo with manifests",
226			path:   "/v2/myorg/myapp/manifests/v1.0",
227			scopes: []string{"repository:myorg/myapp:pull"},
228			want:   true,
229		},
230		{
231			name:   "deeply nested repo",
232			path:   "/v2/myorg/team/myapp/manifests/latest",
233			scopes: []string{"repository:myorg/team/myapp:pull"},
234			want:   true,
235		},
236		{
237			name:   "repo with tags list",
238			path:   "/v2/myrepo/tags/list",
239			scopes: []string{"repository:myrepo:pull"},
240			want:   true,
241		},
242		{
243			name:   "wrong repo name",
244			path:   "/v2/myrepo/manifests/latest",
245			scopes: []string{"repository:otherrepo:pull"},
246			want:   false,
247		},
248	}
249
250	for _, tt := range tests {
251		t.Run(tt.name, func(t *testing.T) {
252			claims := &domain.TokenClaims{Scopes: tt.scopes}
253			req := httptest.NewRequest(http.MethodGet, tt.path, nil)
254			result := checkScopeAccess(req, claims, log)
255			assert.Equal(t, tt.want, result)
256		})
257	}
258}
259
260func TestCheckScopeAccess_ReservedComponentsCannotRedirectRepository(t *testing.T) {
261	log := testLogger()
262	tests := []struct {
263		name   string
264		method string
265		path   string
266		scopes []string
267		want   bool
268	}{
269		{
270			name:   "unauthorized repo behind reserved first component",
271			method: http.MethodGet,
272			path:   "/v2/manifests/victim/manifests/latest",
273			scopes: []string{"repository:allowed:pull"},
274			want:   false,
275		},
276		{
277			name:   "authorized prefix cannot reach a nested repository",
278			method: http.MethodGet,
279			path:   "/v2/allowed/tags/victim/manifests/latest",
280			scopes: []string{"repository:allowed:pull"},
281			want:   false,
282		},
283		{
284			name:   "pull token cannot PUT a reserved-looking repository",
285			method: http.MethodPut,
286			path:   "/v2/manifests/victim/manifests/latest",
287			scopes: []string{"repository:manifests/victim:pull"},
288			want:   false,
289		},
290		{
291			name:   "exact nested repository is authorized",
292			method: http.MethodGet,
293			path:   "/v2/manifests/victim/manifests/latest",
294			scopes: []string{"repository:manifests/victim:pull"},
295			want:   true,
296		},
297	}
298	for _, tt := range tests {
299		t.Run(tt.name, func(t *testing.T) {
300			claims := &domain.TokenClaims{Scopes: tt.scopes}
301			req := httptest.NewRequest(tt.method, tt.path, nil)
302			assert.Equal(t, tt.want, checkScopeAccess(req, claims, log))
303		})
304	}
305}
306
307func TestCheckScopeAccess_WildcardMatching(t *testing.T) {
308	log := testLogger()
309
310	tests := []struct {
311		name   string
312		path   string
313		scopes []string
314		want   bool
315	}{
316		{
317			name:   "wildcard repo matches any",
318			path:   "/v2/anyrepo/manifests/latest",
319			scopes: []string{"repository:*:pull"},
320			want:   true,
321		},
322		{
323			name:   "org wildcard matches repo in org",
324			path:   "/v2/myorg/myapp/manifests/latest",
325			scopes: []string{"repository:myorg/*:pull"},
326			want:   true,
327		},
328		{
329			name:   "org wildcard does not match other org",
330			path:   "/v2/otherorg/myapp/manifests/latest",
331			scopes: []string{"repository:myorg/*:pull"},
332			want:   false,
333		},
334		{
335			name:   "wildcard action grants push",
336			path:   "/v2/myrepo/manifests/latest",
337			scopes: []string{"repository:myrepo:*"},
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_SpecialRoutes(t *testing.T) {
353	log := testLogger()
354
355	tests := []struct {
356		name   string
357		path   string
358		scopes []string
359		want   bool
360	}{
361		{
362			name:   "v2 root is allowed",
363			path:   "/v2/",
364			scopes: []string{"repository:myrepo:pull"},
365			want:   true,
366		},
367		{
368			name:   "non-v2 path is denied",
369			path:   "/healthz",
370			scopes: []string{"repository:myrepo:pull"},
371			want:   false,
372		},
373		{
374			name:   "catalog path is handled",
375			path:   "/v2/_catalog",
376			scopes: []string{"repository:myrepo:pull"},
377			want:   true,
378		},
379	}
380
381	for _, tt := range tests {
382		t.Run(tt.name, func(t *testing.T) {
383			claims := &domain.TokenClaims{Scopes: tt.scopes}
384			req := httptest.NewRequest(http.MethodGet, tt.path, nil)
385			result := checkScopeAccess(req, claims, log)
386			assert.Equal(t, tt.want, result)
387		})
388	}
389}
390
391func TestRegistryAuthV2_RejectsLongLivedToken(t *testing.T) {
392	log := testLogger()
393	authSvc := stubAuthService{
394		enabled:  true,
395		authType: domain.AuthTypeToken,
396		validateToken: func(_ context.Context, _ string) (*domain.TokenClaims, error) {
397			return &domain.TokenClaims{
398				Subject:     "admin",
399				Scopes:      []string{"repository:*:pull,push"},
400				IsEphemeral: false, // long-lived stored token
401			}, nil
402		},
403	}
404	internalAuth := InternalRegistryAuth{
405		Username: "internal",
406		Password: "secret",
407	}
408
409	handler := RegistryAuthV2(authSvc, internalAuth, nil, log)(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
410		w.WriteHeader(http.StatusOK)
411	}))
412
413	req := httptest.NewRequest("GET", "/v2/myrepo/manifests/latest", nil)
414	req.RemoteAddr = "10.0.0.1:12345"
415	req.Header.Set("Authorization", "Bearer some-long-lived-jwt")
416	rec := httptest.NewRecorder()
417
418	handler.ServeHTTP(rec, req)
419
420	assert.Equal(t, http.StatusUnauthorized, rec.Code)
421	assert.Contains(t, rec.Body.String(), "long-lived tokens must be exchanged via /auth/token")
422}
423
424func TestRegistryAuthV2_AcceptsEphemeralToken(t *testing.T) {
425	log := testLogger()
426	authSvc := stubAuthService{
427		enabled:  true,
428		authType: domain.AuthTypeToken,
429		validateToken: func(_ context.Context, _ string) (*domain.TokenClaims, error) {
430			return &domain.TokenClaims{
431				Subject:     "admin",
432				Scopes:      []string{"repository:*:pull,push"},
433				IsEphemeral: true, // short-lived ephemeral token
434			}, nil
435		},
436	}
437	internalAuth := InternalRegistryAuth{
438		Username: "internal",
439		Password: "secret",
440	}
441
442	handlerCalled := false
443	handler := RegistryAuthV2(authSvc, internalAuth, nil, log)(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
444		handlerCalled = true
445		w.WriteHeader(http.StatusOK)
446	}))
447
448	req := httptest.NewRequest("GET", "/v2/myrepo/manifests/latest", nil)
449	req.RemoteAddr = "10.0.0.1:12345"
450	req.Header.Set("Authorization", "Bearer some-ephemeral-jwt")
451	rec := httptest.NewRecorder()
452
453	handler.ServeHTTP(rec, req)
454
455	assert.Equal(t, http.StatusOK, rec.Code)
456	assert.True(t, handlerCalled, "handler should be called for ephemeral tokens")
457}
458
459func TestRegistryAuthV2_RejectsLongLivedTokenAsPassword(t *testing.T) {
460	log := testLogger()
461	authSvc := stubAuthService{
462		enabled:  true,
463		authType: domain.AuthTypeToken,
464		validateToken: func(_ context.Context, _ string) (*domain.TokenClaims, error) {
465			return &domain.TokenClaims{
466				Subject:     "ci-user",
467				Scopes:      []string{"repository:*:pull,push"},
468				IsEphemeral: false, // long-lived stored token
469			}, nil
470		},
471	}
472	internalAuth := InternalRegistryAuth{
473		Username: "internal",
474		Password: "secret",
475	}
476
477	handler := RegistryAuthV2(authSvc, internalAuth, nil, log)(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
478		w.WriteHeader(http.StatusOK)
479	}))
480
481	req := httptest.NewRequest("GET", "/v2/myrepo/manifests/latest", nil)
482	req.RemoteAddr = "10.0.0.1:12345"
483	req.SetBasicAuth("ci-user", "some-long-lived-jwt")
484	rec := httptest.NewRecorder()
485
486	handler.ServeHTTP(rec, req)
487
488	assert.Equal(t, http.StatusUnauthorized, rec.Code)
489	assert.Contains(t, rec.Body.String(), "long-lived tokens must be exchanged via /auth/token")
490}
491
492func TestCheckScopeAccess_DenialCases(t *testing.T) {
493	log := testLogger()
494
495	tests := []struct {
496		name   string
497		method string
498		path   string
499		scopes []string
500		want   bool
501	}{
502		{
503			name:   "pull scope denies push",
504			method: http.MethodPut,
505			path:   "/v2/myrepo/manifests/latest",
506			scopes: []string{"repository:myrepo:pull"},
507			want:   false,
508		},
509		{
510			name:   "wrong repo denies access",
511			method: http.MethodGet,
512			path:   "/v2/myrepo/manifests/latest",
513			scopes: []string{"repository:otherrepo:pull"},
514			want:   false,
515		},
516		{
517			name:   "empty scopes denies access",
518			method: http.MethodGet,
519			path:   "/v2/myrepo/manifests/latest",
520			scopes: []string{},
521			want:   false,
522		},
523		{
524			name:   "invalid scope format is skipped",
525			method: http.MethodGet,
526			path:   "/v2/myrepo/manifests/latest",
527			scopes: []string{"invalid-scope-format"},
528			want:   false,
529		},
530	}
531
532	for _, tt := range tests {
533		t.Run(tt.name, func(t *testing.T) {
534			claims := &domain.TokenClaims{Scopes: tt.scopes}
535			req := httptest.NewRequest(tt.method, tt.path, nil)
536			result := checkScopeAccess(req, claims, log)
537			assert.Equal(t, tt.want, result)
538		})
539	}
540}

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