v2.7.1 b3a38c8

middleware.go RawHistoryFolder

1package admin
2
3import (
4	"context"
5	"encoding/json"
6	"net"
7	"net/http"
8	"strings"
9
10	"github.com/bnema/zerowrap"
11
12	"gordon/internal/adapters/dto"
13	"gordon/internal/adapters/in/http/middleware"
14	"gordon/internal/boundaries/in"
15	"gordon/internal/boundaries/out"
16	"gordon/internal/domain"
17)
18
19// contextKey is a custom type for context keys to avoid collisions.
20type contextKey string
21
22const (
23	// ContextKeyScopes is the context key for storing token scopes.
24	ContextKeyScopes contextKey = "admin_scopes"
25	// ContextKeySubject is the context key for storing token subject.
26	ContextKeySubject contextKey = "admin_subject"
27)
28
29// AuthMiddleware creates middleware that validates admin API authentication.
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.
32func 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 {
39	return func(next http.Handler) http.Handler {
40		return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
41			ctx := r.Context()
42
43			// Check global rate limit
44			if globalLimiter != nil && !globalLimiter.Allow(ctx, "global") {
45				w.Header().Set("Content-Type", "application/json")
46				w.Header().Set("Retry-After", "1")
47				w.WriteHeader(http.StatusTooManyRequests)
48				_ = json.NewEncoder(w).Encode(dto.ErrorResponse{Error: "rate limit exceeded"})
49				return
50			}
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
64			// Check if auth is enabled
65			if !authSvc.IsEnabled() {
66				// Auth disabled, log warning and allow all requests
67				log.Warn().
68					Str("path", r.URL.Path).
69					Str("method", r.Method).
70					Str("remote_addr", r.RemoteAddr).
71					Msg("auth disabled - allowing unauthenticated admin API access")
72				next.ServeHTTP(w, r)
73				return
74			}
75
76			// Extract token from Authorization header
77			auth := r.Header.Get("Authorization")
78			if auth == "" {
79				sendUnauthorized(w, "missing authorization header")
80				return
81			}
82
83			// Support both "Bearer <token>" and direct token
84			token := auth
85			if strings.HasPrefix(auth, "Bearer ") {
86				token = strings.TrimPrefix(auth, "Bearer ")
87			}
88
89			// Validate token
90			claims, err := authSvc.ValidateToken(ctx, token)
91			if err != nil {
92				log.Warn().Err(err).Msg("invalid admin token")
93				sendUnauthorized(w, "invalid token")
94				return
95			}
96
97			// Check if token has admin scopes
98			hasAdminScope := false
99			for _, scope := range claims.Scopes {
100				if strings.HasPrefix(scope, domain.ScopeTypeAdmin+":") {
101					hasAdminScope = true
102					break
103				}
104			}
105
106			if !hasAdminScope {
107				log.Warn().Str("subject", claims.Subject).Msg("token missing admin scopes")
108				sendForbidden(w, "admin scope required")
109				return
110			}
111
112			// Add claims to context for downstream handlers
113			ctx = context.WithValue(ctx, ContextKeyScopes, claims.Scopes)
114			ctx = context.WithValue(ctx, ContextKeySubject, claims.Subject)
115
116			next.ServeHTTP(w, r.WithContext(ctx))
117		})
118	}
119}
120
121// RequireScope creates middleware that checks for a specific admin scope.
122func RequireScope(resource, action string) func(http.Handler) http.Handler {
123	return func(next http.Handler) http.Handler {
124		return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
125			ctx := r.Context()
126
127			// Get scopes from context
128			scopes, ok := ctx.Value(ContextKeyScopes).([]string)
129			if !ok {
130				sendForbidden(w, "no scopes in context")
131				return
132			}
133
134			// Check if user has required scope
135			if !domain.HasAdminAccess(scopes, resource, action) {
136				sendForbidden(w, "insufficient permissions")
137				return
138			}
139
140			next.ServeHTTP(w, r)
141		})
142	}
143}
144
145// GetSubject retrieves the authenticated subject from the context.
146func GetSubject(ctx context.Context) string {
147	subject, _ := ctx.Value(ContextKeySubject).(string)
148	return subject
149}
150
151// GetScopes retrieves the token scopes from the context.
152func GetScopes(ctx context.Context) []string {
153	scopes, _ := ctx.Value(ContextKeyScopes).([]string)
154	return scopes
155}
156
157// HasAccess checks if the context has access to the given resource and action.
158func HasAccess(ctx context.Context, resource, action string) bool {
159	scopes := GetScopes(ctx)
160	return domain.HasAdminAccess(scopes, resource, action)
161}
162
163func sendUnauthorized(w http.ResponseWriter, message string) {
164	w.Header().Set("Content-Type", "application/json")
165	w.Header().Set("WWW-Authenticate", `Bearer realm="gordon-admin"`)
166	w.WriteHeader(http.StatusUnauthorized)
167	_ = json.NewEncoder(w).Encode(dto.ErrorResponse{Error: message})
168}
169
170func sendForbidden(w http.ResponseWriter, message string) {
171	w.Header().Set("Content-Type", "application/json")
172	w.WriteHeader(http.StatusForbidden)
173	_ = json.NewEncoder(w).Encode(dto.ErrorResponse{Error: message})
174}

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