v2.31.0-dev22 8d342c4

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	"github.com/bnema/gordon/internal/adapters/dto"
13	"github.com/bnema/gordon/internal/adapters/in/http/middleware"
14	"github.com/bnema/gordon/internal/boundaries/in"
15	"github.com/bnema/gordon/internal/boundaries/out"
16	"github.com/bnema/gordon/internal/domain"
17)
18
19// AuthMiddleware creates middleware that validates admin API authentication.
20// Both globalLimiter and ipLimiter can be nil to disable rate limiting.
21// The trustedNets parameter is used for proper IP extraction behind reverse proxies.
22func AuthMiddleware(
23	authSvc in.AuthService,
24	globalLimiter out.RateLimiter,
25	ipLimiter out.RateLimiter,
26	trustedNets []*net.IPNet,
27	log zerowrap.Logger,
28) func(http.Handler) http.Handler {
29	return func(next http.Handler) http.Handler {
30		return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
31			ctx := r.Context()
32
33			// Check global rate limit
34			if globalLimiter != nil && !globalLimiter.Allow(ctx, "global") {
35				setAdminJSONHeaders(w)
36				w.Header().Set("Retry-After", "1")
37				w.WriteHeader(http.StatusTooManyRequests)
38				_ = json.NewEncoder(w).Encode(dto.ErrorResponse{Error: "rate limit exceeded"})
39				return
40			}
41
42			// Check per-IP rate limit
43			if ipLimiter != nil {
44				ip := middleware.GetClientIP(r, trustedNets)
45				if !ipLimiter.Allow(ctx, "ip:"+ip) {
46					setAdminJSONHeaders(w)
47					w.Header().Set("Retry-After", "1")
48					w.WriteHeader(http.StatusTooManyRequests)
49					_ = json.NewEncoder(w).Encode(dto.ErrorResponse{Error: "rate limit exceeded"})
50					return
51				}
52			}
53
54			// Authentication is always required for admin APIs.
55			// Fail closed if auth is disabled or misconfigured.
56			if !authSvc.IsEnabled() {
57				log.Error().
58					Str("path", r.URL.Path).
59					Str("method", r.Method).
60					Str("remote_addr", r.RemoteAddr).
61					Msg("authentication disabled/misconfigured - denying admin API request")
62				sendUnauthorized(w, "authentication is required")
63				return
64			}
65
66			// Extract token from Authorization header.
67			// SECURITY: Require the standard "Bearer" prefix per RFC 6750
68			// to prevent accidental token exposure in non-standard formats.
69			auth := r.Header.Get("Authorization")
70			if auth == "" {
71				sendUnauthorized(w, "missing authorization header")
72				return
73			}
74
75			if !strings.HasPrefix(auth, "Bearer ") {
76				sendUnauthorized(w, "authorization header must use Bearer scheme")
77				return
78			}
79			token := strings.TrimPrefix(auth, "Bearer ")
80
81			// Validate token
82			claims, err := authSvc.ValidateToken(ctx, token)
83			if err != nil {
84				log.Warn().Err(err).Msg("invalid admin token")
85				sendUnauthorized(w, "invalid token")
86				return
87			}
88
89			// SECURITY: Reject long-lived (stored) tokens — they must be exchanged
90			// for ephemeral access tokens via /auth/token first.
91			if !claims.IsEphemeral {
92				log.Warn().
93					Str("subject", claims.Subject).
94					Str("path", r.URL.Path).
95					Msg("long-lived token rejected on admin endpoint")
96				sendUnauthorized(w, domain.ErrLongLivedToken.Error())
97				return
98			}
99
100			// Check if token has admin scopes
101			hasAdminScope := false
102			for _, scope := range claims.Scopes {
103				if strings.HasPrefix(scope, domain.ScopeTypeAdmin+":") {
104					hasAdminScope = true
105					break
106				}
107			}
108
109			if !hasAdminScope {
110				log.Warn().Str("subject", claims.Subject).Msg("token missing admin scopes")
111				sendForbidden(w, "admin scope required")
112				return
113			}
114
115			// Add claims to context for downstream handlers
116			ctx = context.WithValue(ctx, domain.ContextKeyScopes, claims.Scopes)
117			ctx = context.WithValue(ctx, domain.ContextKeySubject, claims.Subject)
118			ctx = context.WithValue(ctx, domain.TokenClaimsKey, claims)
119
120			// Prevent intermediaries from caching admin API responses.
121			setNoStoreHeaders(w)
122
123			next.ServeHTTP(w, r.WithContext(ctx))
124		})
125	}
126}
127
128// RequireScope creates middleware that checks for a specific admin scope.
129func RequireScope(resource, action string) func(http.Handler) http.Handler {
130	return func(next http.Handler) http.Handler {
131		return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
132			ctx := r.Context()
133
134			// Get scopes from context
135			scopes, ok := ctx.Value(domain.ContextKeyScopes).([]string)
136			if !ok {
137				sendForbidden(w, "no scopes in context")
138				return
139			}
140
141			// Check if user has required scope
142			if !domain.HasAdminAccess(scopes, resource, action) {
143				sendForbidden(w, "insufficient permissions")
144				return
145			}
146
147			next.ServeHTTP(w, r)
148		})
149	}
150}
151
152// GetSubject retrieves the authenticated subject from the context.
153func GetSubject(ctx context.Context) string {
154	subject, _ := ctx.Value(domain.ContextKeySubject).(string)
155	return subject
156}
157
158// GetScopes retrieves the token scopes from the context.
159func GetScopes(ctx context.Context) []string {
160	scopes, _ := ctx.Value(domain.ContextKeyScopes).([]string)
161	return scopes
162}
163
164// HasAccess checks if the context has access to the given resource and action.
165func HasAccess(ctx context.Context, resource, action string) bool {
166	scopes := GetScopes(ctx)
167	return domain.HasAdminAccess(scopes, resource, action)
168}
169
170func sendUnauthorized(w http.ResponseWriter, message string) {
171	setAdminJSONHeaders(w)
172	w.Header().Set("WWW-Authenticate", `Bearer realm="gordon-admin"`)
173	w.WriteHeader(http.StatusUnauthorized)
174	_ = json.NewEncoder(w).Encode(dto.ErrorResponse{Error: message})
175}
176
177func sendForbidden(w http.ResponseWriter, message string) {
178	setAdminJSONHeaders(w)
179	w.WriteHeader(http.StatusForbidden)
180	_ = json.NewEncoder(w).Encode(dto.ErrorResponse{Error: message})
181}
182
183func setAdminJSONHeaders(w http.ResponseWriter) {
184	w.Header().Set("Content-Type", "application/json")
185	setNoStoreHeaders(w)
186}
187
188func setNoStoreHeaders(w http.ResponseWriter) {
189	w.Header().Set("Cache-Control", "no-store")
190	w.Header().Set("Pragma", "no-cache")
191}

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