v2.4.1 7c3b292

middleware.go RawHistoryFolder

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

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