detached 8d262c1

fix(admin): use per-IP rate limiting with RateLimiter interface

commit
8d262c1b70cfa6f1883605a21563b0ed7bcd44b7
author
bnema · · 2026-01-18 03:16 UTC
parent
ee95a80
signature
unsigned
2 files · +388 -5
M internal/adapters/in/http/admin/middleware.go +26 -5
@@ -3,14 +3,16 @@ package admin
33 import (
44 "context"
55 "encoding/json"
6+ "net"
67 "net/http"
78 "strings"
89
910 "github.com/bnema/zerowrap"
10- "golang.org/x/time/rate"
1111
1212 "gordon/internal/adapters/dto"
13+ "gordon/internal/adapters/in/http/middleware"
1314 "gordon/internal/boundaries/in"
15+ "gordon/internal/boundaries/out"
1416 "gordon/internal/domain"
1517 )
1618
@@ -25,14 +27,21 @@ const (
2527 )
2628
2729 // AuthMiddleware creates middleware that validates admin API authentication.
28-// The limiter parameter is optional and can be nil to disable rate limiting.
29-func AuthMiddleware(authSvc in.AuthService, limiter *rate.Limiter, log zerowrap.Logger) func(http.Handler) http.Handler {
30+// Both globalLimiter and ipLimiter can be nil to disable rate limiting.
31+// The trustedNets parameter is used for proper IP extraction behind reverse proxies.
32+func AuthMiddleware(
33+ authSvc in.AuthService,
34+ globalLimiter out.RateLimiter,
35+ ipLimiter out.RateLimiter,
36+ trustedNets []*net.IPNet,
37+ log zerowrap.Logger,
38+) func(http.Handler) http.Handler {
3039 return func(next http.Handler) http.Handler {
3140 return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
3241 ctx := r.Context()
3342
34- // Rate limit check (before any auth processing)
35- if limiter != nil && !limiter.Allow() {
43+ // Check global rate limit
44+ if globalLimiter != nil && !globalLimiter.Allow(ctx, "global") {
3645 w.Header().Set("Content-Type", "application/json")
3746 w.Header().Set("Retry-After", "1")
3847 w.WriteHeader(http.StatusTooManyRequests)
@@ -40,6 +49,18 @@ func AuthMiddleware(authSvc in.AuthService, limiter *rate.Limiter, log zerowrap.
4049 return
4150 }
4251
52+ // Check per-IP rate limit
53+ if ipLimiter != nil {
54+ ip := middleware.GetClientIP(r, trustedNets)
55+ if !ipLimiter.Allow(ctx, "ip:"+ip) {
56+ w.Header().Set("Content-Type", "application/json")
57+ w.Header().Set("Retry-After", "1")
58+ w.WriteHeader(http.StatusTooManyRequests)
59+ _ = json.NewEncoder(w).Encode(dto.ErrorResponse{Error: "rate limit exceeded"})
60+ return
61+ }
62+ }
63+
4364 // Check if auth is enabled
4465 if !authSvc.IsEnabled() {
4566 // Auth disabled, log warning and allow all requests
@@ -3,14 +3,16 @@ package admin
3 import (3 import (
4 "context"4 "context"
5 "encoding/json"5 "encoding/json"
6+ "net"
6 "net/http"7 "net/http"
7 "strings"8 "strings"
8 9
9 "github.com/bnema/zerowrap"10 "github.com/bnema/zerowrap"
10- "golang.org/x/time/rate"
11 11
12 "gordon/internal/adapters/dto"12 "gordon/internal/adapters/dto"
13+ "gordon/internal/adapters/in/http/middleware"
13 "gordon/internal/boundaries/in"14 "gordon/internal/boundaries/in"
15+ "gordon/internal/boundaries/out"
14 "gordon/internal/domain"16 "gordon/internal/domain"
15 )17 )
16 18
@@ -25,14 +27,21 @@ const (
25 )27 )
26 28
27 // AuthMiddleware creates middleware that validates admin API authentication.29 // AuthMiddleware creates middleware that validates admin API authentication.
28-// The limiter parameter is optional and can be nil to disable rate limiting.30+// Both globalLimiter and ipLimiter can be nil to disable rate limiting.
29-func AuthMiddleware(authSvc in.AuthService, limiter *rate.Limiter, log zerowrap.Logger) func(http.Handler) http.Handler {31+// The trustedNets parameter is used for proper IP extraction behind reverse proxies.
32+func AuthMiddleware(
33+ authSvc in.AuthService,
34+ globalLimiter out.RateLimiter,
35+ ipLimiter out.RateLimiter,
36+ trustedNets []*net.IPNet,
37+ log zerowrap.Logger,
38+) func(http.Handler) http.Handler {
30 return func(next http.Handler) http.Handler {39 return func(next http.Handler) http.Handler {
31 return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {40 return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
32 ctx := r.Context()41 ctx := r.Context()
33 42
34- // Rate limit check (before any auth processing)43+ // Check global rate limit
35- if limiter != nil && !limiter.Allow() {44+ if globalLimiter != nil && !globalLimiter.Allow(ctx, "global") {
36 w.Header().Set("Content-Type", "application/json")45 w.Header().Set("Content-Type", "application/json")
37 w.Header().Set("Retry-After", "1")46 w.Header().Set("Retry-After", "1")
38 w.WriteHeader(http.StatusTooManyRequests)47 w.WriteHeader(http.StatusTooManyRequests)
@@ -40,6 +49,18 @@ func AuthMiddleware(authSvc in.AuthService, limiter *rate.Limiter, log zerowrap.
40 return49 return
41 }50 }
42 51
52+ // Check per-IP rate limit
53+ if ipLimiter != nil {
54+ ip := middleware.GetClientIP(r, trustedNets)
55+ if !ipLimiter.Allow(ctx, "ip:"+ip) {
56+ w.Header().Set("Content-Type", "application/json")
57+ w.Header().Set("Retry-After", "1")
58+ w.WriteHeader(http.StatusTooManyRequests)
59+ _ = json.NewEncoder(w).Encode(dto.ErrorResponse{Error: "rate limit exceeded"})
60+ return
61+ }
62+ }
63+
43 // Check if auth is enabled64 // Check if auth is enabled
44 if !authSvc.IsEnabled() {65 if !authSvc.IsEnabled() {
45 // Auth disabled, log warning and allow all requests66 // Auth disabled, log warning and allow all requests
A internal/adapters/in/http/admin/middleware_test.go +362 -0
@@ -0,0 +1,362 @@
1+package admin
2+
3+import (
4+ "context"
5+ "net/http"
6+ "net/http/httptest"
7+ "testing"
8+
9+ "github.com/bnema/zerowrap"
10+ "github.com/stretchr/testify/assert"
11+ "github.com/stretchr/testify/mock"
12+
13+ "gordon/internal/adapters/in/http/middleware"
14+ inmocks "gordon/internal/boundaries/in/mocks"
15+ outmocks "gordon/internal/boundaries/out/mocks"
16+ "gordon/internal/domain"
17+)
18+
19+func adminTestLogger() zerowrap.Logger {
20+ return zerowrap.Default()
21+}
22+
23+func TestAuthMiddleware_GlobalRateLimitHit(t *testing.T) {
24+ authSvc := inmocks.NewMockAuthService(t)
25+ globalLimiter := outmocks.NewMockRateLimiter(t)
26+ ipLimiter := outmocks.NewMockRateLimiter(t)
27+
28+ // Global limiter returns false (rate limited)
29+ globalLimiter.EXPECT().Allow(context.Background(), "global").Return(false)
30+
31+ handler := http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
32+ w.WriteHeader(http.StatusOK)
33+ })
34+
35+ mw := AuthMiddleware(authSvc, globalLimiter, ipLimiter, nil, adminTestLogger())
36+ wrappedHandler := mw(handler)
37+
38+ req := httptest.NewRequest(http.MethodGet, "/admin/status", nil)
39+ rec := httptest.NewRecorder()
40+ wrappedHandler.ServeHTTP(rec, req)
41+
42+ assert.Equal(t, http.StatusTooManyRequests, rec.Code)
43+ assert.Contains(t, rec.Body.String(), "rate limit exceeded")
44+}
45+
46+func TestAuthMiddleware_PerIPRateLimitHit(t *testing.T) {
47+ authSvc := inmocks.NewMockAuthService(t)
48+ globalLimiter := outmocks.NewMockRateLimiter(t)
49+ ipLimiter := outmocks.NewMockRateLimiter(t)
50+
51+ // Global limiter allows, per-IP limiter denies
52+ globalLimiter.EXPECT().Allow(context.Background(), "global").Return(true)
53+ ipLimiter.EXPECT().Allow(context.Background(), "ip:192.168.1.100").Return(false)
54+
55+ handler := http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
56+ w.WriteHeader(http.StatusOK)
57+ })
58+
59+ mw := AuthMiddleware(authSvc, globalLimiter, ipLimiter, nil, adminTestLogger())
60+ wrappedHandler := mw(handler)
61+
62+ req := httptest.NewRequest(http.MethodGet, "/admin/status", nil)
63+ req.RemoteAddr = "192.168.1.100:12345"
64+ rec := httptest.NewRecorder()
65+ wrappedHandler.ServeHTTP(rec, req)
66+
67+ assert.Equal(t, http.StatusTooManyRequests, rec.Code)
68+ assert.Contains(t, rec.Body.String(), "rate limit exceeded")
69+}
70+
71+func TestAuthMiddleware_TrustedProxy(t *testing.T) {
72+ authSvc := inmocks.NewMockAuthService(t)
73+ globalLimiter := outmocks.NewMockRateLimiter(t)
74+ ipLimiter := outmocks.NewMockRateLimiter(t)
75+
76+ // Request from trusted proxy, should use X-Forwarded-For IP
77+ trustedNets := middleware.ParseTrustedProxies([]string{"127.0.0.1"})
78+
79+ globalLimiter.EXPECT().Allow(context.Background(), "global").Return(true)
80+ ipLimiter.EXPECT().Allow(context.Background(), "ip:203.0.113.50").Return(true)
81+ authSvc.EXPECT().IsEnabled().Return(false)
82+
83+ handler := http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
84+ w.WriteHeader(http.StatusOK)
85+ })
86+
87+ mw := AuthMiddleware(authSvc, globalLimiter, ipLimiter, trustedNets, adminTestLogger())
88+ wrappedHandler := mw(handler)
89+
90+ req := httptest.NewRequest(http.MethodGet, "/admin/status", nil)
91+ req.RemoteAddr = "127.0.0.1:12345"
92+ req.Header.Set("X-Forwarded-For", "203.0.113.50")
93+ rec := httptest.NewRecorder()
94+ wrappedHandler.ServeHTTP(rec, req)
95+
96+ assert.Equal(t, http.StatusOK, rec.Code)
97+}
98+
99+func TestAuthMiddleware_NoRateLimiting(t *testing.T) {
100+ authSvc := inmocks.NewMockAuthService(t)
101+
102+ // Auth disabled, no rate limiters
103+ authSvc.EXPECT().IsEnabled().Return(false)
104+
105+ handler := http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
106+ w.WriteHeader(http.StatusOK)
107+ })
108+
109+ mw := AuthMiddleware(authSvc, nil, nil, nil, adminTestLogger())
110+ wrappedHandler := mw(handler)
111+
112+ req := httptest.NewRequest(http.MethodGet, "/admin/status", nil)
113+ rec := httptest.NewRecorder()
114+ wrappedHandler.ServeHTTP(rec, req)
115+
116+ assert.Equal(t, http.StatusOK, rec.Code)
117+}
118+
119+func TestAuthMiddleware_AuthDisabled(t *testing.T) {
120+ authSvc := inmocks.NewMockAuthService(t)
121+ globalLimiter := outmocks.NewMockRateLimiter(t)
122+ ipLimiter := outmocks.NewMockRateLimiter(t)
123+
124+ globalLimiter.EXPECT().Allow(context.Background(), "global").Return(true)
125+ ipLimiter.EXPECT().Allow(context.Background(), "ip:192.168.1.100").Return(true)
126+ authSvc.EXPECT().IsEnabled().Return(false)
127+
128+ handlerCalled := false
129+ handler := http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
130+ handlerCalled = true
131+ w.WriteHeader(http.StatusOK)
132+ })
133+
134+ mw := AuthMiddleware(authSvc, globalLimiter, ipLimiter, nil, adminTestLogger())
135+ wrappedHandler := mw(handler)
136+
137+ req := httptest.NewRequest(http.MethodGet, "/admin/status", nil)
138+ req.RemoteAddr = "192.168.1.100:12345"
139+ rec := httptest.NewRecorder()
140+ wrappedHandler.ServeHTTP(rec, req)
141+
142+ assert.Equal(t, http.StatusOK, rec.Code)
143+ assert.True(t, handlerCalled)
144+}
145+
146+func TestAuthMiddleware_MissingAuthHeader(t *testing.T) {
147+ authSvc := inmocks.NewMockAuthService(t)
148+ globalLimiter := outmocks.NewMockRateLimiter(t)
149+ ipLimiter := outmocks.NewMockRateLimiter(t)
150+
151+ globalLimiter.EXPECT().Allow(context.Background(), "global").Return(true)
152+ ipLimiter.EXPECT().Allow(context.Background(), "ip:192.168.1.100").Return(true)
153+ authSvc.EXPECT().IsEnabled().Return(true)
154+
155+ handler := http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
156+ w.WriteHeader(http.StatusOK)
157+ })
158+
159+ mw := AuthMiddleware(authSvc, globalLimiter, ipLimiter, nil, adminTestLogger())
160+ wrappedHandler := mw(handler)
161+
162+ req := httptest.NewRequest(http.MethodGet, "/admin/status", nil)
163+ req.RemoteAddr = "192.168.1.100:12345"
164+ rec := httptest.NewRecorder()
165+ wrappedHandler.ServeHTTP(rec, req)
166+
167+ assert.Equal(t, http.StatusUnauthorized, rec.Code)
168+ assert.Contains(t, rec.Body.String(), "missing authorization header")
169+}
170+
171+func TestAuthMiddleware_InvalidToken(t *testing.T) {
172+ authSvc := inmocks.NewMockAuthService(t)
173+ globalLimiter := outmocks.NewMockRateLimiter(t)
174+ ipLimiter := outmocks.NewMockRateLimiter(t)
175+
176+ globalLimiter.EXPECT().Allow(context.Background(), "global").Return(true)
177+ ipLimiter.EXPECT().Allow(context.Background(), "ip:192.168.1.100").Return(true)
178+ authSvc.EXPECT().IsEnabled().Return(true)
179+ authSvc.EXPECT().ValidateToken(mock.Anything, "invalid-token").Return(nil, assert.AnError)
180+
181+ handler := http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
182+ w.WriteHeader(http.StatusOK)
183+ })
184+
185+ mw := AuthMiddleware(authSvc, globalLimiter, ipLimiter, nil, adminTestLogger())
186+ wrappedHandler := mw(handler)
187+
188+ req := httptest.NewRequest(http.MethodGet, "/admin/status", nil)
189+ req.RemoteAddr = "192.168.1.100:12345"
190+ req.Header.Set("Authorization", "Bearer invalid-token")
191+ rec := httptest.NewRecorder()
192+ wrappedHandler.ServeHTTP(rec, req)
193+
194+ assert.Equal(t, http.StatusUnauthorized, rec.Code)
195+ assert.Contains(t, rec.Body.String(), "invalid token")
196+}
197+
198+func TestAuthMiddleware_MissingAdminScope(t *testing.T) {
199+ authSvc := inmocks.NewMockAuthService(t)
200+ globalLimiter := outmocks.NewMockRateLimiter(t)
201+ ipLimiter := outmocks.NewMockRateLimiter(t)
202+
203+ globalLimiter.EXPECT().Allow(context.Background(), "global").Return(true)
204+ ipLimiter.EXPECT().Allow(context.Background(), "ip:192.168.1.100").Return(true)
205+ authSvc.EXPECT().IsEnabled().Return(true)
206+ authSvc.EXPECT().ValidateToken(mock.Anything, "valid-token").Return(&domain.TokenClaims{
207+ Subject: "user1",
208+ Scopes: []string{"push", "pull"}, // No admin scopes
209+ }, nil)
210+
211+ handler := http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
212+ w.WriteHeader(http.StatusOK)
213+ })
214+
215+ mw := AuthMiddleware(authSvc, globalLimiter, ipLimiter, nil, adminTestLogger())
216+ wrappedHandler := mw(handler)
217+
218+ req := httptest.NewRequest(http.MethodGet, "/admin/status", nil)
219+ req.RemoteAddr = "192.168.1.100:12345"
220+ req.Header.Set("Authorization", "Bearer valid-token")
221+ rec := httptest.NewRecorder()
222+ wrappedHandler.ServeHTTP(rec, req)
223+
224+ assert.Equal(t, http.StatusForbidden, rec.Code)
225+ assert.Contains(t, rec.Body.String(), "admin scope required")
226+}
227+
228+func TestAuthMiddleware_Success(t *testing.T) {
229+ authSvc := inmocks.NewMockAuthService(t)
230+ globalLimiter := outmocks.NewMockRateLimiter(t)
231+ ipLimiter := outmocks.NewMockRateLimiter(t)
232+
233+ globalLimiter.EXPECT().Allow(context.Background(), "global").Return(true)
234+ ipLimiter.EXPECT().Allow(context.Background(), "ip:192.168.1.100").Return(true)
235+ authSvc.EXPECT().IsEnabled().Return(true)
236+ authSvc.EXPECT().ValidateToken(mock.Anything, "valid-admin-token").Return(&domain.TokenClaims{
237+ Subject: "admin",
238+ Scopes: []string{"admin:*:*"},
239+ }, nil)
240+
241+ var capturedCtx context.Context
242+ handler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
243+ capturedCtx = r.Context()
244+ w.WriteHeader(http.StatusOK)
245+ })
246+
247+ mw := AuthMiddleware(authSvc, globalLimiter, ipLimiter, nil, adminTestLogger())
248+ wrappedHandler := mw(handler)
249+
250+ req := httptest.NewRequest(http.MethodGet, "/admin/status", nil)
251+ req.RemoteAddr = "192.168.1.100:12345"
252+ req.Header.Set("Authorization", "Bearer valid-admin-token")
253+ rec := httptest.NewRecorder()
254+ wrappedHandler.ServeHTTP(rec, req)
255+
256+ assert.Equal(t, http.StatusOK, rec.Code)
257+
258+ // Verify context has scopes and subject
259+ scopes := GetScopes(capturedCtx)
260+ assert.Equal(t, []string{"admin:*:*"}, scopes)
261+ assert.Equal(t, "admin", GetSubject(capturedCtx))
262+}
263+
264+func TestAuthMiddleware_DirectToken(t *testing.T) {
265+ authSvc := inmocks.NewMockAuthService(t)
266+ globalLimiter := outmocks.NewMockRateLimiter(t)
267+ ipLimiter := outmocks.NewMockRateLimiter(t)
268+
269+ globalLimiter.EXPECT().Allow(context.Background(), "global").Return(true)
270+ ipLimiter.EXPECT().Allow(context.Background(), "ip:192.168.1.100").Return(true)
271+ authSvc.EXPECT().IsEnabled().Return(true)
272+ // Token without "Bearer " prefix
273+ authSvc.EXPECT().ValidateToken(mock.Anything, "direct-token").Return(&domain.TokenClaims{
274+ Subject: "admin",
275+ Scopes: []string{"admin:routes:read"},
276+ }, nil)
277+
278+ handler := http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
279+ w.WriteHeader(http.StatusOK)
280+ })
281+
282+ mw := AuthMiddleware(authSvc, globalLimiter, ipLimiter, nil, adminTestLogger())
283+ wrappedHandler := mw(handler)
284+
285+ req := httptest.NewRequest(http.MethodGet, "/admin/status", nil)
286+ req.RemoteAddr = "192.168.1.100:12345"
287+ req.Header.Set("Authorization", "direct-token") // No "Bearer " prefix
288+ rec := httptest.NewRecorder()
289+ wrappedHandler.ServeHTTP(rec, req)
290+
291+ assert.Equal(t, http.StatusOK, rec.Code)
292+}
293+
294+func TestRequireScope(t *testing.T) {
295+ tests := []struct {
296+ name string
297+ scopes []string
298+ resource string
299+ action string
300+ wantStatus int
301+ }{
302+ {
303+ name: "wildcard scope grants access",
304+ scopes: []string{"admin:*:*"},
305+ resource: "routes",
306+ action: "write",
307+ wantStatus: http.StatusOK,
308+ },
309+ {
310+ name: "exact scope grants access",
311+ scopes: []string{"admin:routes:read"},
312+ resource: "routes",
313+ action: "read",
314+ wantStatus: http.StatusOK,
315+ },
316+ {
317+ name: "wrong resource denies access",
318+ scopes: []string{"admin:secrets:read"},
319+ resource: "routes",
320+ action: "read",
321+ wantStatus: http.StatusForbidden,
322+ },
323+ {
324+ name: "wrong action denies access",
325+ scopes: []string{"admin:routes:read"},
326+ resource: "routes",
327+ action: "write",
328+ wantStatus: http.StatusForbidden,
329+ },
330+ }
331+
332+ for _, tt := range tests {
333+ t.Run(tt.name, func(t *testing.T) {
334+ handler := http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
335+ w.WriteHeader(http.StatusOK)
336+ })
337+
338+ mw := RequireScope(tt.resource, tt.action)
339+ wrappedHandler := mw(handler)
340+
341+ req := httptest.NewRequest(http.MethodGet, "/admin/routes", nil)
342+ // Add scopes to context
343+ ctx := context.WithValue(req.Context(), ContextKeyScopes, tt.scopes)
344+ req = req.WithContext(ctx)
345+
346+ rec := httptest.NewRecorder()
347+ wrappedHandler.ServeHTTP(rec, req)
348+
349+ assert.Equal(t, tt.wantStatus, rec.Code)
350+ })
351+ }
352+}
353+
354+func TestHasAccess(t *testing.T) {
355+ ctx := context.WithValue(context.Background(), ContextKeyScopes, []string{"admin:routes:read", "admin:secrets:*"})
356+
357+ assert.True(t, HasAccess(ctx, "routes", "read"))
358+ assert.False(t, HasAccess(ctx, "routes", "write"))
359+ assert.True(t, HasAccess(ctx, "secrets", "read"))
360+ assert.True(t, HasAccess(ctx, "secrets", "write"))
361+ assert.False(t, HasAccess(ctx, "config", "read"))
362+}
@@ -0,0 +1,362 @@
1+package admin
2+
3+import (
4+ "context"
5+ "net/http"
6+ "net/http/httptest"
7+ "testing"
8+
9+ "github.com/bnema/zerowrap"
10+ "github.com/stretchr/testify/assert"
11+ "github.com/stretchr/testify/mock"
12+
13+ "gordon/internal/adapters/in/http/middleware"
14+ inmocks "gordon/internal/boundaries/in/mocks"
15+ outmocks "gordon/internal/boundaries/out/mocks"
16+ "gordon/internal/domain"
17+)
18+
19+func adminTestLogger() zerowrap.Logger {
20+ return zerowrap.Default()
21+}
22+
23+func TestAuthMiddleware_GlobalRateLimitHit(t *testing.T) {
24+ authSvc := inmocks.NewMockAuthService(t)
25+ globalLimiter := outmocks.NewMockRateLimiter(t)
26+ ipLimiter := outmocks.NewMockRateLimiter(t)
27+
28+ // Global limiter returns false (rate limited)
29+ globalLimiter.EXPECT().Allow(context.Background(), "global").Return(false)
30+
31+ handler := http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
32+ w.WriteHeader(http.StatusOK)
33+ })
34+
35+ mw := AuthMiddleware(authSvc, globalLimiter, ipLimiter, nil, adminTestLogger())
36+ wrappedHandler := mw(handler)
37+
38+ req := httptest.NewRequest(http.MethodGet, "/admin/status", nil)
39+ rec := httptest.NewRecorder()
40+ wrappedHandler.ServeHTTP(rec, req)
41+
42+ assert.Equal(t, http.StatusTooManyRequests, rec.Code)
43+ assert.Contains(t, rec.Body.String(), "rate limit exceeded")
44+}
45+
46+func TestAuthMiddleware_PerIPRateLimitHit(t *testing.T) {
47+ authSvc := inmocks.NewMockAuthService(t)
48+ globalLimiter := outmocks.NewMockRateLimiter(t)
49+ ipLimiter := outmocks.NewMockRateLimiter(t)
50+
51+ // Global limiter allows, per-IP limiter denies
52+ globalLimiter.EXPECT().Allow(context.Background(), "global").Return(true)
53+ ipLimiter.EXPECT().Allow(context.Background(), "ip:192.168.1.100").Return(false)
54+
55+ handler := http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
56+ w.WriteHeader(http.StatusOK)
57+ })
58+
59+ mw := AuthMiddleware(authSvc, globalLimiter, ipLimiter, nil, adminTestLogger())
60+ wrappedHandler := mw(handler)
61+
62+ req := httptest.NewRequest(http.MethodGet, "/admin/status", nil)
63+ req.RemoteAddr = "192.168.1.100:12345"
64+ rec := httptest.NewRecorder()
65+ wrappedHandler.ServeHTTP(rec, req)
66+
67+ assert.Equal(t, http.StatusTooManyRequests, rec.Code)
68+ assert.Contains(t, rec.Body.String(), "rate limit exceeded")
69+}
70+
71+func TestAuthMiddleware_TrustedProxy(t *testing.T) {
72+ authSvc := inmocks.NewMockAuthService(t)
73+ globalLimiter := outmocks.NewMockRateLimiter(t)
74+ ipLimiter := outmocks.NewMockRateLimiter(t)
75+
76+ // Request from trusted proxy, should use X-Forwarded-For IP
77+ trustedNets := middleware.ParseTrustedProxies([]string{"127.0.0.1"})
78+
79+ globalLimiter.EXPECT().Allow(context.Background(), "global").Return(true)
80+ ipLimiter.EXPECT().Allow(context.Background(), "ip:203.0.113.50").Return(true)
81+ authSvc.EXPECT().IsEnabled().Return(false)
82+
83+ handler := http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
84+ w.WriteHeader(http.StatusOK)
85+ })
86+
87+ mw := AuthMiddleware(authSvc, globalLimiter, ipLimiter, trustedNets, adminTestLogger())
88+ wrappedHandler := mw(handler)
89+
90+ req := httptest.NewRequest(http.MethodGet, "/admin/status", nil)
91+ req.RemoteAddr = "127.0.0.1:12345"
92+ req.Header.Set("X-Forwarded-For", "203.0.113.50")
93+ rec := httptest.NewRecorder()
94+ wrappedHandler.ServeHTTP(rec, req)
95+
96+ assert.Equal(t, http.StatusOK, rec.Code)
97+}
98+
99+func TestAuthMiddleware_NoRateLimiting(t *testing.T) {
100+ authSvc := inmocks.NewMockAuthService(t)
101+
102+ // Auth disabled, no rate limiters
103+ authSvc.EXPECT().IsEnabled().Return(false)
104+
105+ handler := http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
106+ w.WriteHeader(http.StatusOK)
107+ })
108+
109+ mw := AuthMiddleware(authSvc, nil, nil, nil, adminTestLogger())
110+ wrappedHandler := mw(handler)
111+
112+ req := httptest.NewRequest(http.MethodGet, "/admin/status", nil)
113+ rec := httptest.NewRecorder()
114+ wrappedHandler.ServeHTTP(rec, req)
115+
116+ assert.Equal(t, http.StatusOK, rec.Code)
117+}
118+
119+func TestAuthMiddleware_AuthDisabled(t *testing.T) {
120+ authSvc := inmocks.NewMockAuthService(t)
121+ globalLimiter := outmocks.NewMockRateLimiter(t)
122+ ipLimiter := outmocks.NewMockRateLimiter(t)
123+
124+ globalLimiter.EXPECT().Allow(context.Background(), "global").Return(true)
125+ ipLimiter.EXPECT().Allow(context.Background(), "ip:192.168.1.100").Return(true)
126+ authSvc.EXPECT().IsEnabled().Return(false)
127+
128+ handlerCalled := false
129+ handler := http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
130+ handlerCalled = true
131+ w.WriteHeader(http.StatusOK)
132+ })
133+
134+ mw := AuthMiddleware(authSvc, globalLimiter, ipLimiter, nil, adminTestLogger())
135+ wrappedHandler := mw(handler)
136+
137+ req := httptest.NewRequest(http.MethodGet, "/admin/status", nil)
138+ req.RemoteAddr = "192.168.1.100:12345"
139+ rec := httptest.NewRecorder()
140+ wrappedHandler.ServeHTTP(rec, req)
141+
142+ assert.Equal(t, http.StatusOK, rec.Code)
143+ assert.True(t, handlerCalled)
144+}
145+
146+func TestAuthMiddleware_MissingAuthHeader(t *testing.T) {
147+ authSvc := inmocks.NewMockAuthService(t)
148+ globalLimiter := outmocks.NewMockRateLimiter(t)
149+ ipLimiter := outmocks.NewMockRateLimiter(t)
150+
151+ globalLimiter.EXPECT().Allow(context.Background(), "global").Return(true)
152+ ipLimiter.EXPECT().Allow(context.Background(), "ip:192.168.1.100").Return(true)
153+ authSvc.EXPECT().IsEnabled().Return(true)
154+
155+ handler := http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
156+ w.WriteHeader(http.StatusOK)
157+ })
158+
159+ mw := AuthMiddleware(authSvc, globalLimiter, ipLimiter, nil, adminTestLogger())
160+ wrappedHandler := mw(handler)
161+
162+ req := httptest.NewRequest(http.MethodGet, "/admin/status", nil)
163+ req.RemoteAddr = "192.168.1.100:12345"
164+ rec := httptest.NewRecorder()
165+ wrappedHandler.ServeHTTP(rec, req)
166+
167+ assert.Equal(t, http.StatusUnauthorized, rec.Code)
168+ assert.Contains(t, rec.Body.String(), "missing authorization header")
169+}
170+
171+func TestAuthMiddleware_InvalidToken(t *testing.T) {
172+ authSvc := inmocks.NewMockAuthService(t)
173+ globalLimiter := outmocks.NewMockRateLimiter(t)
174+ ipLimiter := outmocks.NewMockRateLimiter(t)
175+
176+ globalLimiter.EXPECT().Allow(context.Background(), "global").Return(true)
177+ ipLimiter.EXPECT().Allow(context.Background(), "ip:192.168.1.100").Return(true)
178+ authSvc.EXPECT().IsEnabled().Return(true)
179+ authSvc.EXPECT().ValidateToken(mock.Anything, "invalid-token").Return(nil, assert.AnError)
180+
181+ handler := http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
182+ w.WriteHeader(http.StatusOK)
183+ })
184+
185+ mw := AuthMiddleware(authSvc, globalLimiter, ipLimiter, nil, adminTestLogger())
186+ wrappedHandler := mw(handler)
187+
188+ req := httptest.NewRequest(http.MethodGet, "/admin/status", nil)
189+ req.RemoteAddr = "192.168.1.100:12345"
190+ req.Header.Set("Authorization", "Bearer invalid-token")
191+ rec := httptest.NewRecorder()
192+ wrappedHandler.ServeHTTP(rec, req)
193+
194+ assert.Equal(t, http.StatusUnauthorized, rec.Code)
195+ assert.Contains(t, rec.Body.String(), "invalid token")
196+}
197+
198+func TestAuthMiddleware_MissingAdminScope(t *testing.T) {
199+ authSvc := inmocks.NewMockAuthService(t)
200+ globalLimiter := outmocks.NewMockRateLimiter(t)
201+ ipLimiter := outmocks.NewMockRateLimiter(t)
202+
203+ globalLimiter.EXPECT().Allow(context.Background(), "global").Return(true)
204+ ipLimiter.EXPECT().Allow(context.Background(), "ip:192.168.1.100").Return(true)
205+ authSvc.EXPECT().IsEnabled().Return(true)
206+ authSvc.EXPECT().ValidateToken(mock.Anything, "valid-token").Return(&domain.TokenClaims{
207+ Subject: "user1",
208+ Scopes: []string{"push", "pull"}, // No admin scopes
209+ }, nil)
210+
211+ handler := http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
212+ w.WriteHeader(http.StatusOK)
213+ })
214+
215+ mw := AuthMiddleware(authSvc, globalLimiter, ipLimiter, nil, adminTestLogger())
216+ wrappedHandler := mw(handler)
217+
218+ req := httptest.NewRequest(http.MethodGet, "/admin/status", nil)
219+ req.RemoteAddr = "192.168.1.100:12345"
220+ req.Header.Set("Authorization", "Bearer valid-token")
221+ rec := httptest.NewRecorder()
222+ wrappedHandler.ServeHTTP(rec, req)
223+
224+ assert.Equal(t, http.StatusForbidden, rec.Code)
225+ assert.Contains(t, rec.Body.String(), "admin scope required")
226+}
227+
228+func TestAuthMiddleware_Success(t *testing.T) {
229+ authSvc := inmocks.NewMockAuthService(t)
230+ globalLimiter := outmocks.NewMockRateLimiter(t)
231+ ipLimiter := outmocks.NewMockRateLimiter(t)
232+
233+ globalLimiter.EXPECT().Allow(context.Background(), "global").Return(true)
234+ ipLimiter.EXPECT().Allow(context.Background(), "ip:192.168.1.100").Return(true)
235+ authSvc.EXPECT().IsEnabled().Return(true)
236+ authSvc.EXPECT().ValidateToken(mock.Anything, "valid-admin-token").Return(&domain.TokenClaims{
237+ Subject: "admin",
238+ Scopes: []string{"admin:*:*"},
239+ }, nil)
240+
241+ var capturedCtx context.Context
242+ handler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
243+ capturedCtx = r.Context()
244+ w.WriteHeader(http.StatusOK)
245+ })
246+
247+ mw := AuthMiddleware(authSvc, globalLimiter, ipLimiter, nil, adminTestLogger())
248+ wrappedHandler := mw(handler)
249+
250+ req := httptest.NewRequest(http.MethodGet, "/admin/status", nil)
251+ req.RemoteAddr = "192.168.1.100:12345"
252+ req.Header.Set("Authorization", "Bearer valid-admin-token")
253+ rec := httptest.NewRecorder()
254+ wrappedHandler.ServeHTTP(rec, req)
255+
256+ assert.Equal(t, http.StatusOK, rec.Code)
257+
258+ // Verify context has scopes and subject
259+ scopes := GetScopes(capturedCtx)
260+ assert.Equal(t, []string{"admin:*:*"}, scopes)
261+ assert.Equal(t, "admin", GetSubject(capturedCtx))
262+}
263+
264+func TestAuthMiddleware_DirectToken(t *testing.T) {
265+ authSvc := inmocks.NewMockAuthService(t)
266+ globalLimiter := outmocks.NewMockRateLimiter(t)
267+ ipLimiter := outmocks.NewMockRateLimiter(t)
268+
269+ globalLimiter.EXPECT().Allow(context.Background(), "global").Return(true)
270+ ipLimiter.EXPECT().Allow(context.Background(), "ip:192.168.1.100").Return(true)
271+ authSvc.EXPECT().IsEnabled().Return(true)
272+ // Token without "Bearer " prefix
273+ authSvc.EXPECT().ValidateToken(mock.Anything, "direct-token").Return(&domain.TokenClaims{
274+ Subject: "admin",
275+ Scopes: []string{"admin:routes:read"},
276+ }, nil)
277+
278+ handler := http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
279+ w.WriteHeader(http.StatusOK)
280+ })
281+
282+ mw := AuthMiddleware(authSvc, globalLimiter, ipLimiter, nil, adminTestLogger())
283+ wrappedHandler := mw(handler)
284+
285+ req := httptest.NewRequest(http.MethodGet, "/admin/status", nil)
286+ req.RemoteAddr = "192.168.1.100:12345"
287+ req.Header.Set("Authorization", "direct-token") // No "Bearer " prefix
288+ rec := httptest.NewRecorder()
289+ wrappedHandler.ServeHTTP(rec, req)
290+
291+ assert.Equal(t, http.StatusOK, rec.Code)
292+}
293+
294+func TestRequireScope(t *testing.T) {
295+ tests := []struct {
296+ name string
297+ scopes []string
298+ resource string
299+ action string
300+ wantStatus int
301+ }{
302+ {
303+ name: "wildcard scope grants access",
304+ scopes: []string{"admin:*:*"},
305+ resource: "routes",
306+ action: "write",
307+ wantStatus: http.StatusOK,
308+ },
309+ {
310+ name: "exact scope grants access",
311+ scopes: []string{"admin:routes:read"},
312+ resource: "routes",
313+ action: "read",
314+ wantStatus: http.StatusOK,
315+ },
316+ {
317+ name: "wrong resource denies access",
318+ scopes: []string{"admin:secrets:read"},
319+ resource: "routes",
320+ action: "read",
321+ wantStatus: http.StatusForbidden,
322+ },
323+ {
324+ name: "wrong action denies access",
325+ scopes: []string{"admin:routes:read"},
326+ resource: "routes",
327+ action: "write",
328+ wantStatus: http.StatusForbidden,
329+ },
330+ }
331+
332+ for _, tt := range tests {
333+ t.Run(tt.name, func(t *testing.T) {
334+ handler := http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
335+ w.WriteHeader(http.StatusOK)
336+ })
337+
338+ mw := RequireScope(tt.resource, tt.action)
339+ wrappedHandler := mw(handler)
340+
341+ req := httptest.NewRequest(http.MethodGet, "/admin/routes", nil)
342+ // Add scopes to context
343+ ctx := context.WithValue(req.Context(), ContextKeyScopes, tt.scopes)
344+ req = req.WithContext(ctx)
345+
346+ rec := httptest.NewRecorder()
347+ wrappedHandler.ServeHTTP(rec, req)
348+
349+ assert.Equal(t, tt.wantStatus, rec.Code)
350+ })
351+ }
352+}
353+
354+func TestHasAccess(t *testing.T) {
355+ ctx := context.WithValue(context.Background(), ContextKeyScopes, []string{"admin:routes:read", "admin:secrets:*"})
356+
357+ assert.True(t, HasAccess(ctx, "routes", "read"))
358+ assert.False(t, HasAccess(ctx, "routes", "write"))
359+ assert.True(t, HasAccess(ctx, "secrets", "read"))
360+ assert.True(t, HasAccess(ctx, "secrets", "write"))
361+ assert.False(t, HasAccess(ctx, "config", "read"))
362+}

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