v2.30.1 1983e1b

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, 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, 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, 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:abc123",
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_WildcardMatching(t *testing.T) {
261	log := testLogger()
262
263	tests := []struct {
264		name   string
265		path   string
266		scopes []string
267		want   bool
268	}{
269		{
270			name:   "wildcard repo matches any",
271			path:   "/v2/anyrepo/manifests/latest",
272			scopes: []string{"repository:*:pull"},
273			want:   true,
274		},
275		{
276			name:   "org wildcard matches repo in org",
277			path:   "/v2/myorg/myapp/manifests/latest",
278			scopes: []string{"repository:myorg/*:pull"},
279			want:   true,
280		},
281		{
282			name:   "org wildcard does not match other org",
283			path:   "/v2/otherorg/myapp/manifests/latest",
284			scopes: []string{"repository:myorg/*:pull"},
285			want:   false,
286		},
287		{
288			name:   "wildcard action grants push",
289			path:   "/v2/myrepo/manifests/latest",
290			scopes: []string{"repository:myrepo:*"},
291			want:   true,
292		},
293	}
294
295	for _, tt := range tests {
296		t.Run(tt.name, func(t *testing.T) {
297			claims := &domain.TokenClaims{Scopes: tt.scopes}
298			req := httptest.NewRequest(http.MethodGet, tt.path, nil)
299			result := checkScopeAccess(req, claims, log)
300			assert.Equal(t, tt.want, result)
301		})
302	}
303}
304
305func TestCheckScopeAccess_SpecialRoutes(t *testing.T) {
306	log := testLogger()
307
308	tests := []struct {
309		name   string
310		path   string
311		scopes []string
312		want   bool
313	}{
314		{
315			name:   "v2 root is allowed",
316			path:   "/v2/",
317			scopes: []string{"repository:myrepo:pull"},
318			want:   true,
319		},
320		{
321			name:   "non-v2 path is allowed",
322			path:   "/healthz",
323			scopes: []string{"repository:myrepo:pull"},
324			want:   true,
325		},
326		{
327			name:   "catalog path is handled",
328			path:   "/v2/_catalog",
329			scopes: []string{"repository:myrepo:pull"},
330			want:   true,
331		},
332	}
333
334	for _, tt := range tests {
335		t.Run(tt.name, func(t *testing.T) {
336			claims := &domain.TokenClaims{Scopes: tt.scopes}
337			req := httptest.NewRequest(http.MethodGet, tt.path, nil)
338			result := checkScopeAccess(req, claims, log)
339			assert.Equal(t, tt.want, result)
340		})
341	}
342}
343
344func TestRegistryAuthV2_RejectsLongLivedToken(t *testing.T) {
345	log := testLogger()
346	authSvc := stubAuthService{
347		enabled:  true,
348		authType: domain.AuthTypeToken,
349		validateToken: func(_ context.Context, _ string) (*domain.TokenClaims, error) {
350			return &domain.TokenClaims{
351				Subject:     "admin",
352				Scopes:      []string{"repository:*:pull,push"},
353				IsEphemeral: false, // long-lived stored token
354			}, nil
355		},
356	}
357	internalAuth := InternalRegistryAuth{
358		Username: "internal",
359		Password: "secret",
360	}
361
362	handler := RegistryAuthV2(authSvc, internalAuth, log)(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
363		w.WriteHeader(http.StatusOK)
364	}))
365
366	req := httptest.NewRequest("GET", "/v2/myrepo/manifests/latest", nil)
367	req.RemoteAddr = "10.0.0.1:12345"
368	req.Header.Set("Authorization", "Bearer some-long-lived-jwt")
369	rec := httptest.NewRecorder()
370
371	handler.ServeHTTP(rec, req)
372
373	assert.Equal(t, http.StatusUnauthorized, rec.Code)
374	assert.Contains(t, rec.Body.String(), "long-lived tokens must be exchanged via /auth/token")
375}
376
377func TestRegistryAuthV2_AcceptsEphemeralToken(t *testing.T) {
378	log := testLogger()
379	authSvc := stubAuthService{
380		enabled:  true,
381		authType: domain.AuthTypeToken,
382		validateToken: func(_ context.Context, _ string) (*domain.TokenClaims, error) {
383			return &domain.TokenClaims{
384				Subject:     "admin",
385				Scopes:      []string{"repository:*:pull,push"},
386				IsEphemeral: true, // short-lived ephemeral token
387			}, nil
388		},
389	}
390	internalAuth := InternalRegistryAuth{
391		Username: "internal",
392		Password: "secret",
393	}
394
395	handlerCalled := false
396	handler := RegistryAuthV2(authSvc, internalAuth, log)(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
397		handlerCalled = true
398		w.WriteHeader(http.StatusOK)
399	}))
400
401	req := httptest.NewRequest("GET", "/v2/myrepo/manifests/latest", nil)
402	req.RemoteAddr = "10.0.0.1:12345"
403	req.Header.Set("Authorization", "Bearer some-ephemeral-jwt")
404	rec := httptest.NewRecorder()
405
406	handler.ServeHTTP(rec, req)
407
408	assert.Equal(t, http.StatusOK, rec.Code)
409	assert.True(t, handlerCalled, "handler should be called for ephemeral tokens")
410}
411
412func TestRegistryAuthV2_RejectsLongLivedTokenAsPassword(t *testing.T) {
413	log := testLogger()
414	authSvc := stubAuthService{
415		enabled:  true,
416		authType: domain.AuthTypeToken,
417		validateToken: func(_ context.Context, _ string) (*domain.TokenClaims, error) {
418			return &domain.TokenClaims{
419				Subject:     "ci-user",
420				Scopes:      []string{"repository:*:pull,push"},
421				IsEphemeral: false, // long-lived stored token
422			}, nil
423		},
424	}
425	internalAuth := InternalRegistryAuth{
426		Username: "internal",
427		Password: "secret",
428	}
429
430	handler := RegistryAuthV2(authSvc, internalAuth, log)(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
431		w.WriteHeader(http.StatusOK)
432	}))
433
434	req := httptest.NewRequest("GET", "/v2/myrepo/manifests/latest", nil)
435	req.RemoteAddr = "10.0.0.1:12345"
436	req.SetBasicAuth("ci-user", "some-long-lived-jwt")
437	rec := httptest.NewRecorder()
438
439	handler.ServeHTTP(rec, req)
440
441	assert.Equal(t, http.StatusUnauthorized, rec.Code)
442	assert.Contains(t, rec.Body.String(), "long-lived tokens must be exchanged via /auth/token")
443}
444
445func TestCheckScopeAccess_DenialCases(t *testing.T) {
446	log := testLogger()
447
448	tests := []struct {
449		name   string
450		method string
451		path   string
452		scopes []string
453		want   bool
454	}{
455		{
456			name:   "pull scope denies push",
457			method: http.MethodPut,
458			path:   "/v2/myrepo/manifests/latest",
459			scopes: []string{"repository:myrepo:pull"},
460			want:   false,
461		},
462		{
463			name:   "wrong repo denies access",
464			method: http.MethodGet,
465			path:   "/v2/myrepo/manifests/latest",
466			scopes: []string{"repository:otherrepo:pull"},
467			want:   false,
468		},
469		{
470			name:   "empty scopes denies access",
471			method: http.MethodGet,
472			path:   "/v2/myrepo/manifests/latest",
473			scopes: []string{},
474			want:   false,
475		},
476		{
477			name:   "invalid scope format is skipped",
478			method: http.MethodGet,
479			path:   "/v2/myrepo/manifests/latest",
480			scopes: []string{"invalid-scope-format"},
481			want:   false,
482		},
483	}
484
485	for _, tt := range tests {
486		t.Run(tt.name, func(t *testing.T) {
487			claims := &domain.TokenClaims{Scopes: tt.scopes}
488			req := httptest.NewRequest(tt.method, tt.path, nil)
489			result := checkScopeAccess(req, claims, log)
490			assert.Equal(t, tt.want, result)
491		})
492	}
493}

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