detached 0d5a499

feat(admin): add admin API handler with auth middleware

commit
0d5a499ee966ae8fa346cf6cddd5dfc0ed7e555c
author
bnema · · 2026-01-16 20:51 UTC
parent
328a5ef
signature
GPG VerifiedCD88F9E564E0102646FB6077A1A07C7C7C3E349D · arch-gpg Verified for the committer email
4 files · +1487 -0
A internal/adapters/in/http/admin/handler.go +429 -0
@@ -0,0 +1,429 @@
1+// Package admin implements the HTTP adapter for the admin API.
2+package admin
3+
4+import (
5+ "encoding/json"
6+ "net/http"
7+ "strings"
8+
9+ "github.com/bnema/zerowrap"
10+
11+ "gordon/internal/boundaries/in"
12+ "gordon/internal/boundaries/out"
13+ "gordon/internal/domain"
14+)
15+
16+// Handler implements the HTTP handler for the admin API.
17+type Handler struct {
18+ configSvc in.ConfigService
19+ authSvc in.AuthService
20+ containerSvc in.ContainerService
21+ eventBus out.EventPublisher
22+ envDir string
23+ log zerowrap.Logger
24+}
25+
26+// NewHandler creates a new admin HTTP handler.
27+func NewHandler(
28+ configSvc in.ConfigService,
29+ authSvc in.AuthService,
30+ containerSvc in.ContainerService,
31+ eventBus out.EventPublisher,
32+ envDir string,
33+ log zerowrap.Logger,
34+) *Handler {
35+ return &Handler{
36+ configSvc: configSvc,
37+ authSvc: authSvc,
38+ containerSvc: containerSvc,
39+ eventBus: eventBus,
40+ envDir: envDir,
41+ log: log,
42+ }
43+}
44+
45+// RegisterRoutes registers the admin routes on the given mux.
46+func (h *Handler) RegisterRoutes(mux *http.ServeMux) {
47+ mux.HandleFunc("/admin/", h.handleAdminRoutes)
48+}
49+
50+// ServeHTTP implements http.Handler.
51+func (h *Handler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
52+ h.handleAdminRoutes(w, r)
53+}
54+
55+func (h *Handler) handleAdminRoutes(w http.ResponseWriter, r *http.Request) {
56+ ctx := zerowrap.CtxWithFields(r.Context(), map[string]any{
57+ zerowrap.FieldLayer: "adapter",
58+ zerowrap.FieldAdapter: "http",
59+ zerowrap.FieldHandler: "admin",
60+ zerowrap.FieldMethod: r.Method,
61+ zerowrap.FieldPath: r.URL.Path,
62+ })
63+ r = r.WithContext(ctx)
64+
65+ path := strings.TrimPrefix(r.URL.Path, "/admin")
66+
67+ // Route to appropriate handler
68+ switch {
69+ case path == "/routes" || strings.HasPrefix(path, "/routes/"):
70+ h.handleRoutes(w, r, path)
71+ case path == "/secrets" || strings.HasPrefix(path, "/secrets/"):
72+ h.handleSecrets(w, r, path)
73+ case path == "/status":
74+ h.handleStatus(w, r)
75+ case path == "/reload":
76+ h.handleReload(w, r)
77+ case path == "/config":
78+ h.handleConfig(w, r)
79+ default:
80+ http.NotFound(w, r)
81+ }
82+}
83+
84+// sendJSON sends a JSON response.
85+func (h *Handler) sendJSON(w http.ResponseWriter, status int, data any) {
86+ w.Header().Set("Content-Type", "application/json")
87+ w.WriteHeader(status)
88+ if data != nil {
89+ _ = json.NewEncoder(w).Encode(data)
90+ }
91+}
92+
93+// sendError sends an error response.
94+func (h *Handler) sendError(w http.ResponseWriter, status int, message string) {
95+ h.sendJSON(w, status, map[string]string{"error": message})
96+}
97+
98+// handleRoutes handles /admin/routes endpoints.
99+func (h *Handler) handleRoutes(w http.ResponseWriter, r *http.Request, path string) {
100+ // Parse domain from path if present
101+ routeDomain := strings.TrimPrefix(path, "/routes/")
102+ if routeDomain == "/routes" {
103+ routeDomain = ""
104+ }
105+
106+ switch r.Method {
107+ case http.MethodGet:
108+ h.handleRoutesGet(w, r, routeDomain)
109+ case http.MethodPost:
110+ h.handleRoutesPost(w, r)
111+ case http.MethodPut:
112+ h.handleRoutesPut(w, r, routeDomain)
113+ case http.MethodDelete:
114+ h.handleRoutesDelete(w, r, routeDomain)
115+ default:
116+ http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
117+ }
118+}
119+
120+func (h *Handler) handleRoutesGet(w http.ResponseWriter, r *http.Request, routeDomain string) {
121+ ctx := r.Context()
122+
123+ // Check read permission
124+ if !HasAccess(ctx, domain.AdminResourceRoutes, domain.AdminActionRead) {
125+ h.sendError(w, http.StatusForbidden, "insufficient permissions for routes:read")
126+ return
127+ }
128+
129+ routes := h.configSvc.GetRoutes(ctx)
130+
131+ if routeDomain == "" {
132+ h.sendJSON(w, http.StatusOK, map[string]any{"routes": routes})
133+ return
134+ }
135+
136+ for _, route := range routes {
137+ if route.Domain == routeDomain {
138+ h.sendJSON(w, http.StatusOK, route)
139+ return
140+ }
141+ }
142+ h.sendError(w, http.StatusNotFound, "route not found")
143+}
144+
145+func (h *Handler) handleRoutesPost(w http.ResponseWriter, r *http.Request) {
146+ ctx := r.Context()
147+ log := zerowrap.FromCtx(ctx)
148+
149+ // Check write permission
150+ if !HasAccess(ctx, domain.AdminResourceRoutes, domain.AdminActionWrite) {
151+ h.sendError(w, http.StatusForbidden, "insufficient permissions for routes:write")
152+ return
153+ }
154+
155+ var route domain.Route
156+ if err := json.NewDecoder(r.Body).Decode(&route); err != nil {
157+ log.Warn().Err(err).Msg("invalid route JSON")
158+ h.sendError(w, http.StatusBadRequest, "invalid JSON")
159+ return
160+ }
161+
162+ if route.Domain == "" || route.Image == "" {
163+ h.sendError(w, http.StatusBadRequest, "domain and image are required")
164+ return
165+ }
166+
167+ if err := h.configSvc.AddRoute(ctx, route); err != nil {
168+ log.Error().Err(err).Str("domain", route.Domain).Msg("failed to add route")
169+ h.sendError(w, http.StatusInternalServerError, err.Error())
170+ return
171+ }
172+
173+ log.Info().Str("domain", route.Domain).Str("image", route.Image).Msg("route added")
174+ h.sendJSON(w, http.StatusCreated, route)
175+}
176+
177+func (h *Handler) handleRoutesPut(w http.ResponseWriter, r *http.Request, routeDomain string) {
178+ ctx := r.Context()
179+ log := zerowrap.FromCtx(ctx)
180+
181+ // Check write permission
182+ if !HasAccess(ctx, domain.AdminResourceRoutes, domain.AdminActionWrite) {
183+ h.sendError(w, http.StatusForbidden, "insufficient permissions for routes:write")
184+ return
185+ }
186+
187+ if routeDomain == "" {
188+ h.sendError(w, http.StatusBadRequest, "domain required in path")
189+ return
190+ }
191+
192+ var route domain.Route
193+ if err := json.NewDecoder(r.Body).Decode(&route); err != nil {
194+ log.Warn().Err(err).Msg("invalid route JSON")
195+ h.sendError(w, http.StatusBadRequest, "invalid JSON")
196+ return
197+ }
198+
199+ route.Domain = routeDomain
200+
201+ if err := h.configSvc.UpdateRoute(ctx, route); err != nil {
202+ log.Error().Err(err).Str("domain", routeDomain).Msg("failed to update route")
203+ h.sendError(w, http.StatusInternalServerError, err.Error())
204+ return
205+ }
206+
207+ log.Info().Str("domain", route.Domain).Str("image", route.Image).Msg("route updated")
208+ h.sendJSON(w, http.StatusOK, route)
209+}
210+
211+func (h *Handler) handleRoutesDelete(w http.ResponseWriter, r *http.Request, routeDomain string) {
212+ ctx := r.Context()
213+ log := zerowrap.FromCtx(ctx)
214+
215+ // Check write permission
216+ if !HasAccess(ctx, domain.AdminResourceRoutes, domain.AdminActionWrite) {
217+ h.sendError(w, http.StatusForbidden, "insufficient permissions for routes:write")
218+ return
219+ }
220+
221+ if routeDomain == "" {
222+ h.sendError(w, http.StatusBadRequest, "domain required in path")
223+ return
224+ }
225+
226+ if err := h.configSvc.RemoveRoute(ctx, routeDomain); err != nil {
227+ log.Error().Err(err).Str("domain", routeDomain).Msg("failed to remove route")
228+ h.sendError(w, http.StatusInternalServerError, err.Error())
229+ return
230+ }
231+
232+ log.Info().Str("domain", routeDomain).Msg("route removed")
233+ h.sendJSON(w, http.StatusOK, map[string]string{"status": "removed"})
234+}
235+
236+// handleSecrets handles /admin/secrets endpoints.
237+func (h *Handler) handleSecrets(w http.ResponseWriter, r *http.Request, path string) {
238+ ctx := r.Context()
239+ log := zerowrap.FromCtx(ctx)
240+
241+ // Parse path: /secrets/{domain} or /secrets/{domain}/{key}
242+ parts := strings.Split(strings.TrimPrefix(path, "/secrets/"), "/")
243+ if len(parts) == 0 || parts[0] == "" {
244+ h.sendError(w, http.StatusBadRequest, "domain required")
245+ return
246+ }
247+
248+ secretDomain := parts[0]
249+ secretKey := ""
250+ if len(parts) > 1 {
251+ secretKey = parts[1]
252+ }
253+
254+ switch r.Method {
255+ case http.MethodGet:
256+ // Check read permission
257+ if !HasAccess(ctx, domain.AdminResourceSecrets, domain.AdminActionRead) {
258+ h.sendError(w, http.StatusForbidden, "insufficient permissions for secrets:read")
259+ return
260+ }
261+ // List secrets for domain (names only, not values)
262+ secrets, err := h.listSecrets(secretDomain)
263+ if err != nil {
264+ log.Error().Err(err).Str("domain", secretDomain).Msg("failed to list secrets")
265+ h.sendError(w, http.StatusInternalServerError, err.Error())
266+ return
267+ }
268+ h.sendJSON(w, http.StatusOK, map[string]any{"domain": secretDomain, "keys": secrets})
269+
270+ case http.MethodPost:
271+ // Check write permission
272+ if !HasAccess(ctx, domain.AdminResourceSecrets, domain.AdminActionWrite) {
273+ h.sendError(w, http.StatusForbidden, "insufficient permissions for secrets:write")
274+ return
275+ }
276+ // Set secret(s)
277+ var data map[string]string
278+ if err := json.NewDecoder(r.Body).Decode(&data); err != nil {
279+ log.Warn().Err(err).Msg("invalid secrets JSON")
280+ h.sendError(w, http.StatusBadRequest, "invalid JSON")
281+ return
282+ }
283+
284+ if err := h.setSecrets(secretDomain, data); err != nil {
285+ log.Error().Err(err).Str("domain", secretDomain).Msg("failed to set secrets")
286+ h.sendError(w, http.StatusInternalServerError, err.Error())
287+ return
288+ }
289+
290+ log.Info().Str("domain", secretDomain).Int("count", len(data)).Msg("secrets set")
291+ h.sendJSON(w, http.StatusOK, map[string]string{"status": "updated"})
292+
293+ case http.MethodDelete:
294+ // Check write permission
295+ if !HasAccess(ctx, domain.AdminResourceSecrets, domain.AdminActionWrite) {
296+ h.sendError(w, http.StatusForbidden, "insufficient permissions for secrets:write")
297+ return
298+ }
299+ if secretKey == "" {
300+ h.sendError(w, http.StatusBadRequest, "key required in path")
301+ return
302+ }
303+
304+ if err := h.deleteSecret(secretDomain, secretKey); err != nil {
305+ log.Error().Err(err).Str("domain", secretDomain).Str("key", secretKey).Msg("failed to delete secret")
306+ h.sendError(w, http.StatusInternalServerError, err.Error())
307+ return
308+ }
309+
310+ log.Info().Str("domain", secretDomain).Str("key", secretKey).Msg("secret deleted")
311+ h.sendJSON(w, http.StatusOK, map[string]string{"status": "deleted"})
312+
313+ default:
314+ http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
315+ }
316+}
317+
318+// handleStatus handles /admin/status endpoint.
319+func (h *Handler) handleStatus(w http.ResponseWriter, r *http.Request) {
320+ if r.Method != http.MethodGet {
321+ http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
322+ return
323+ }
324+
325+ ctx := r.Context()
326+
327+ // Check read permission
328+ if !HasAccess(ctx, domain.AdminResourceStatus, domain.AdminActionRead) {
329+ h.sendError(w, http.StatusForbidden, "insufficient permissions for status:read")
330+ return
331+ }
332+
333+ routes := h.configSvc.GetRoutes(ctx)
334+
335+ // Get container statuses
336+ statuses := make(map[string]string)
337+ for _, route := range routes {
338+ status := "unknown"
339+ container, ok := h.containerSvc.Get(ctx, route.Domain)
340+ if ok && container != nil {
341+ status = container.Status
342+ }
343+ statuses[route.Domain] = status
344+ }
345+
346+ status := map[string]any{
347+ "routes": len(routes),
348+ "registry_domain": h.configSvc.GetRegistryDomain(),
349+ "registry_port": h.configSvc.GetRegistryPort(),
350+ "server_port": h.configSvc.GetServerPort(),
351+ "auto_route": h.configSvc.IsAutoRouteEnabled(),
352+ "network_isolation": h.configSvc.IsNetworkIsolationEnabled(),
353+ "container_status": statuses,
354+ }
355+
356+ h.sendJSON(w, http.StatusOK, status)
357+}
358+
359+// handleReload handles /admin/reload endpoint.
360+// This reloads configuration from file into memory and triggers container sync.
361+func (h *Handler) handleReload(w http.ResponseWriter, r *http.Request) {
362+ if r.Method != http.MethodPost {
363+ http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
364+ return
365+ }
366+
367+ ctx := r.Context()
368+ log := zerowrap.FromCtx(ctx)
369+
370+ // Check write permission (reload modifies state)
371+ if !HasAccess(ctx, domain.AdminResourceConfig, domain.AdminActionWrite) {
372+ h.sendError(w, http.StatusForbidden, "insufficient permissions for config:write")
373+ return
374+ }
375+
376+ // Reload configuration from file into memory
377+ if err := h.configSvc.Load(ctx); err != nil {
378+ log.Error().Err(err).Msg("failed to reload config")
379+ h.sendError(w, http.StatusInternalServerError, err.Error())
380+ return
381+ }
382+
383+ // Publish manual reload event to sync containers
384+ // ManualReloadHandler starts missing containers without restarting running ones
385+ if h.eventBus != nil {
386+ if err := h.eventBus.Publish(domain.EventManualReload, nil); err != nil {
387+ log.Warn().Err(err).Msg("failed to publish manual reload event")
388+ }
389+ }
390+
391+ log.Info().Msg("config reloaded via admin API")
392+ h.sendJSON(w, http.StatusOK, map[string]string{"status": "reloaded"})
393+}
394+
395+// handleConfig handles /admin/config endpoint.
396+func (h *Handler) handleConfig(w http.ResponseWriter, r *http.Request) {
397+ if r.Method != http.MethodGet {
398+ http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
399+ return
400+ }
401+
402+ ctx := r.Context()
403+
404+ // Check read permission
405+ if !HasAccess(ctx, domain.AdminResourceConfig, domain.AdminActionRead) {
406+ h.sendError(w, http.StatusForbidden, "insufficient permissions for config:read")
407+ return
408+ }
409+
410+ config := map[string]any{
411+ "server": map[string]any{
412+ "port": h.configSvc.GetServerPort(),
413+ "registry_port": h.configSvc.GetRegistryPort(),
414+ "registry_domain": h.configSvc.GetRegistryDomain(),
415+ "data_dir": h.configSvc.GetDataDir(),
416+ },
417+ "auto_route": map[string]any{
418+ "enabled": h.configSvc.IsAutoRouteEnabled(),
419+ },
420+ "network_isolation": map[string]any{
421+ "enabled": h.configSvc.IsNetworkIsolationEnabled(),
422+ "prefix": h.configSvc.GetNetworkPrefix(),
423+ },
424+ "routes": h.configSvc.GetRoutes(ctx),
425+ "external_routes": h.configSvc.GetExternalRoutes(),
426+ }
427+
428+ h.sendJSON(w, http.StatusOK, config)
429+}
@@ -0,0 +1,429 @@
1+// Package admin implements the HTTP adapter for the admin API.
2+package admin
3+
4+import (
5+ "encoding/json"
6+ "net/http"
7+ "strings"
8+
9+ "github.com/bnema/zerowrap"
10+
11+ "gordon/internal/boundaries/in"
12+ "gordon/internal/boundaries/out"
13+ "gordon/internal/domain"
14+)
15+
16+// Handler implements the HTTP handler for the admin API.
17+type Handler struct {
18+ configSvc in.ConfigService
19+ authSvc in.AuthService
20+ containerSvc in.ContainerService
21+ eventBus out.EventPublisher
22+ envDir string
23+ log zerowrap.Logger
24+}
25+
26+// NewHandler creates a new admin HTTP handler.
27+func NewHandler(
28+ configSvc in.ConfigService,
29+ authSvc in.AuthService,
30+ containerSvc in.ContainerService,
31+ eventBus out.EventPublisher,
32+ envDir string,
33+ log zerowrap.Logger,
34+) *Handler {
35+ return &Handler{
36+ configSvc: configSvc,
37+ authSvc: authSvc,
38+ containerSvc: containerSvc,
39+ eventBus: eventBus,
40+ envDir: envDir,
41+ log: log,
42+ }
43+}
44+
45+// RegisterRoutes registers the admin routes on the given mux.
46+func (h *Handler) RegisterRoutes(mux *http.ServeMux) {
47+ mux.HandleFunc("/admin/", h.handleAdminRoutes)
48+}
49+
50+// ServeHTTP implements http.Handler.
51+func (h *Handler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
52+ h.handleAdminRoutes(w, r)
53+}
54+
55+func (h *Handler) handleAdminRoutes(w http.ResponseWriter, r *http.Request) {
56+ ctx := zerowrap.CtxWithFields(r.Context(), map[string]any{
57+ zerowrap.FieldLayer: "adapter",
58+ zerowrap.FieldAdapter: "http",
59+ zerowrap.FieldHandler: "admin",
60+ zerowrap.FieldMethod: r.Method,
61+ zerowrap.FieldPath: r.URL.Path,
62+ })
63+ r = r.WithContext(ctx)
64+
65+ path := strings.TrimPrefix(r.URL.Path, "/admin")
66+
67+ // Route to appropriate handler
68+ switch {
69+ case path == "/routes" || strings.HasPrefix(path, "/routes/"):
70+ h.handleRoutes(w, r, path)
71+ case path == "/secrets" || strings.HasPrefix(path, "/secrets/"):
72+ h.handleSecrets(w, r, path)
73+ case path == "/status":
74+ h.handleStatus(w, r)
75+ case path == "/reload":
76+ h.handleReload(w, r)
77+ case path == "/config":
78+ h.handleConfig(w, r)
79+ default:
80+ http.NotFound(w, r)
81+ }
82+}
83+
84+// sendJSON sends a JSON response.
85+func (h *Handler) sendJSON(w http.ResponseWriter, status int, data any) {
86+ w.Header().Set("Content-Type", "application/json")
87+ w.WriteHeader(status)
88+ if data != nil {
89+ _ = json.NewEncoder(w).Encode(data)
90+ }
91+}
92+
93+// sendError sends an error response.
94+func (h *Handler) sendError(w http.ResponseWriter, status int, message string) {
95+ h.sendJSON(w, status, map[string]string{"error": message})
96+}
97+
98+// handleRoutes handles /admin/routes endpoints.
99+func (h *Handler) handleRoutes(w http.ResponseWriter, r *http.Request, path string) {
100+ // Parse domain from path if present
101+ routeDomain := strings.TrimPrefix(path, "/routes/")
102+ if routeDomain == "/routes" {
103+ routeDomain = ""
104+ }
105+
106+ switch r.Method {
107+ case http.MethodGet:
108+ h.handleRoutesGet(w, r, routeDomain)
109+ case http.MethodPost:
110+ h.handleRoutesPost(w, r)
111+ case http.MethodPut:
112+ h.handleRoutesPut(w, r, routeDomain)
113+ case http.MethodDelete:
114+ h.handleRoutesDelete(w, r, routeDomain)
115+ default:
116+ http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
117+ }
118+}
119+
120+func (h *Handler) handleRoutesGet(w http.ResponseWriter, r *http.Request, routeDomain string) {
121+ ctx := r.Context()
122+
123+ // Check read permission
124+ if !HasAccess(ctx, domain.AdminResourceRoutes, domain.AdminActionRead) {
125+ h.sendError(w, http.StatusForbidden, "insufficient permissions for routes:read")
126+ return
127+ }
128+
129+ routes := h.configSvc.GetRoutes(ctx)
130+
131+ if routeDomain == "" {
132+ h.sendJSON(w, http.StatusOK, map[string]any{"routes": routes})
133+ return
134+ }
135+
136+ for _, route := range routes {
137+ if route.Domain == routeDomain {
138+ h.sendJSON(w, http.StatusOK, route)
139+ return
140+ }
141+ }
142+ h.sendError(w, http.StatusNotFound, "route not found")
143+}
144+
145+func (h *Handler) handleRoutesPost(w http.ResponseWriter, r *http.Request) {
146+ ctx := r.Context()
147+ log := zerowrap.FromCtx(ctx)
148+
149+ // Check write permission
150+ if !HasAccess(ctx, domain.AdminResourceRoutes, domain.AdminActionWrite) {
151+ h.sendError(w, http.StatusForbidden, "insufficient permissions for routes:write")
152+ return
153+ }
154+
155+ var route domain.Route
156+ if err := json.NewDecoder(r.Body).Decode(&route); err != nil {
157+ log.Warn().Err(err).Msg("invalid route JSON")
158+ h.sendError(w, http.StatusBadRequest, "invalid JSON")
159+ return
160+ }
161+
162+ if route.Domain == "" || route.Image == "" {
163+ h.sendError(w, http.StatusBadRequest, "domain and image are required")
164+ return
165+ }
166+
167+ if err := h.configSvc.AddRoute(ctx, route); err != nil {
168+ log.Error().Err(err).Str("domain", route.Domain).Msg("failed to add route")
169+ h.sendError(w, http.StatusInternalServerError, err.Error())
170+ return
171+ }
172+
173+ log.Info().Str("domain", route.Domain).Str("image", route.Image).Msg("route added")
174+ h.sendJSON(w, http.StatusCreated, route)
175+}
176+
177+func (h *Handler) handleRoutesPut(w http.ResponseWriter, r *http.Request, routeDomain string) {
178+ ctx := r.Context()
179+ log := zerowrap.FromCtx(ctx)
180+
181+ // Check write permission
182+ if !HasAccess(ctx, domain.AdminResourceRoutes, domain.AdminActionWrite) {
183+ h.sendError(w, http.StatusForbidden, "insufficient permissions for routes:write")
184+ return
185+ }
186+
187+ if routeDomain == "" {
188+ h.sendError(w, http.StatusBadRequest, "domain required in path")
189+ return
190+ }
191+
192+ var route domain.Route
193+ if err := json.NewDecoder(r.Body).Decode(&route); err != nil {
194+ log.Warn().Err(err).Msg("invalid route JSON")
195+ h.sendError(w, http.StatusBadRequest, "invalid JSON")
196+ return
197+ }
198+
199+ route.Domain = routeDomain
200+
201+ if err := h.configSvc.UpdateRoute(ctx, route); err != nil {
202+ log.Error().Err(err).Str("domain", routeDomain).Msg("failed to update route")
203+ h.sendError(w, http.StatusInternalServerError, err.Error())
204+ return
205+ }
206+
207+ log.Info().Str("domain", route.Domain).Str("image", route.Image).Msg("route updated")
208+ h.sendJSON(w, http.StatusOK, route)
209+}
210+
211+func (h *Handler) handleRoutesDelete(w http.ResponseWriter, r *http.Request, routeDomain string) {
212+ ctx := r.Context()
213+ log := zerowrap.FromCtx(ctx)
214+
215+ // Check write permission
216+ if !HasAccess(ctx, domain.AdminResourceRoutes, domain.AdminActionWrite) {
217+ h.sendError(w, http.StatusForbidden, "insufficient permissions for routes:write")
218+ return
219+ }
220+
221+ if routeDomain == "" {
222+ h.sendError(w, http.StatusBadRequest, "domain required in path")
223+ return
224+ }
225+
226+ if err := h.configSvc.RemoveRoute(ctx, routeDomain); err != nil {
227+ log.Error().Err(err).Str("domain", routeDomain).Msg("failed to remove route")
228+ h.sendError(w, http.StatusInternalServerError, err.Error())
229+ return
230+ }
231+
232+ log.Info().Str("domain", routeDomain).Msg("route removed")
233+ h.sendJSON(w, http.StatusOK, map[string]string{"status": "removed"})
234+}
235+
236+// handleSecrets handles /admin/secrets endpoints.
237+func (h *Handler) handleSecrets(w http.ResponseWriter, r *http.Request, path string) {
238+ ctx := r.Context()
239+ log := zerowrap.FromCtx(ctx)
240+
241+ // Parse path: /secrets/{domain} or /secrets/{domain}/{key}
242+ parts := strings.Split(strings.TrimPrefix(path, "/secrets/"), "/")
243+ if len(parts) == 0 || parts[0] == "" {
244+ h.sendError(w, http.StatusBadRequest, "domain required")
245+ return
246+ }
247+
248+ secretDomain := parts[0]
249+ secretKey := ""
250+ if len(parts) > 1 {
251+ secretKey = parts[1]
252+ }
253+
254+ switch r.Method {
255+ case http.MethodGet:
256+ // Check read permission
257+ if !HasAccess(ctx, domain.AdminResourceSecrets, domain.AdminActionRead) {
258+ h.sendError(w, http.StatusForbidden, "insufficient permissions for secrets:read")
259+ return
260+ }
261+ // List secrets for domain (names only, not values)
262+ secrets, err := h.listSecrets(secretDomain)
263+ if err != nil {
264+ log.Error().Err(err).Str("domain", secretDomain).Msg("failed to list secrets")
265+ h.sendError(w, http.StatusInternalServerError, err.Error())
266+ return
267+ }
268+ h.sendJSON(w, http.StatusOK, map[string]any{"domain": secretDomain, "keys": secrets})
269+
270+ case http.MethodPost:
271+ // Check write permission
272+ if !HasAccess(ctx, domain.AdminResourceSecrets, domain.AdminActionWrite) {
273+ h.sendError(w, http.StatusForbidden, "insufficient permissions for secrets:write")
274+ return
275+ }
276+ // Set secret(s)
277+ var data map[string]string
278+ if err := json.NewDecoder(r.Body).Decode(&data); err != nil {
279+ log.Warn().Err(err).Msg("invalid secrets JSON")
280+ h.sendError(w, http.StatusBadRequest, "invalid JSON")
281+ return
282+ }
283+
284+ if err := h.setSecrets(secretDomain, data); err != nil {
285+ log.Error().Err(err).Str("domain", secretDomain).Msg("failed to set secrets")
286+ h.sendError(w, http.StatusInternalServerError, err.Error())
287+ return
288+ }
289+
290+ log.Info().Str("domain", secretDomain).Int("count", len(data)).Msg("secrets set")
291+ h.sendJSON(w, http.StatusOK, map[string]string{"status": "updated"})
292+
293+ case http.MethodDelete:
294+ // Check write permission
295+ if !HasAccess(ctx, domain.AdminResourceSecrets, domain.AdminActionWrite) {
296+ h.sendError(w, http.StatusForbidden, "insufficient permissions for secrets:write")
297+ return
298+ }
299+ if secretKey == "" {
300+ h.sendError(w, http.StatusBadRequest, "key required in path")
301+ return
302+ }
303+
304+ if err := h.deleteSecret(secretDomain, secretKey); err != nil {
305+ log.Error().Err(err).Str("domain", secretDomain).Str("key", secretKey).Msg("failed to delete secret")
306+ h.sendError(w, http.StatusInternalServerError, err.Error())
307+ return
308+ }
309+
310+ log.Info().Str("domain", secretDomain).Str("key", secretKey).Msg("secret deleted")
311+ h.sendJSON(w, http.StatusOK, map[string]string{"status": "deleted"})
312+
313+ default:
314+ http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
315+ }
316+}
317+
318+// handleStatus handles /admin/status endpoint.
319+func (h *Handler) handleStatus(w http.ResponseWriter, r *http.Request) {
320+ if r.Method != http.MethodGet {
321+ http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
322+ return
323+ }
324+
325+ ctx := r.Context()
326+
327+ // Check read permission
328+ if !HasAccess(ctx, domain.AdminResourceStatus, domain.AdminActionRead) {
329+ h.sendError(w, http.StatusForbidden, "insufficient permissions for status:read")
330+ return
331+ }
332+
333+ routes := h.configSvc.GetRoutes(ctx)
334+
335+ // Get container statuses
336+ statuses := make(map[string]string)
337+ for _, route := range routes {
338+ status := "unknown"
339+ container, ok := h.containerSvc.Get(ctx, route.Domain)
340+ if ok && container != nil {
341+ status = container.Status
342+ }
343+ statuses[route.Domain] = status
344+ }
345+
346+ status := map[string]any{
347+ "routes": len(routes),
348+ "registry_domain": h.configSvc.GetRegistryDomain(),
349+ "registry_port": h.configSvc.GetRegistryPort(),
350+ "server_port": h.configSvc.GetServerPort(),
351+ "auto_route": h.configSvc.IsAutoRouteEnabled(),
352+ "network_isolation": h.configSvc.IsNetworkIsolationEnabled(),
353+ "container_status": statuses,
354+ }
355+
356+ h.sendJSON(w, http.StatusOK, status)
357+}
358+
359+// handleReload handles /admin/reload endpoint.
360+// This reloads configuration from file into memory and triggers container sync.
361+func (h *Handler) handleReload(w http.ResponseWriter, r *http.Request) {
362+ if r.Method != http.MethodPost {
363+ http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
364+ return
365+ }
366+
367+ ctx := r.Context()
368+ log := zerowrap.FromCtx(ctx)
369+
370+ // Check write permission (reload modifies state)
371+ if !HasAccess(ctx, domain.AdminResourceConfig, domain.AdminActionWrite) {
372+ h.sendError(w, http.StatusForbidden, "insufficient permissions for config:write")
373+ return
374+ }
375+
376+ // Reload configuration from file into memory
377+ if err := h.configSvc.Load(ctx); err != nil {
378+ log.Error().Err(err).Msg("failed to reload config")
379+ h.sendError(w, http.StatusInternalServerError, err.Error())
380+ return
381+ }
382+
383+ // Publish manual reload event to sync containers
384+ // ManualReloadHandler starts missing containers without restarting running ones
385+ if h.eventBus != nil {
386+ if err := h.eventBus.Publish(domain.EventManualReload, nil); err != nil {
387+ log.Warn().Err(err).Msg("failed to publish manual reload event")
388+ }
389+ }
390+
391+ log.Info().Msg("config reloaded via admin API")
392+ h.sendJSON(w, http.StatusOK, map[string]string{"status": "reloaded"})
393+}
394+
395+// handleConfig handles /admin/config endpoint.
396+func (h *Handler) handleConfig(w http.ResponseWriter, r *http.Request) {
397+ if r.Method != http.MethodGet {
398+ http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
399+ return
400+ }
401+
402+ ctx := r.Context()
403+
404+ // Check read permission
405+ if !HasAccess(ctx, domain.AdminResourceConfig, domain.AdminActionRead) {
406+ h.sendError(w, http.StatusForbidden, "insufficient permissions for config:read")
407+ return
408+ }
409+
410+ config := map[string]any{
411+ "server": map[string]any{
412+ "port": h.configSvc.GetServerPort(),
413+ "registry_port": h.configSvc.GetRegistryPort(),
414+ "registry_domain": h.configSvc.GetRegistryDomain(),
415+ "data_dir": h.configSvc.GetDataDir(),
416+ },
417+ "auto_route": map[string]any{
418+ "enabled": h.configSvc.IsAutoRouteEnabled(),
419+ },
420+ "network_isolation": map[string]any{
421+ "enabled": h.configSvc.IsNetworkIsolationEnabled(),
422+ "prefix": h.configSvc.GetNetworkPrefix(),
423+ },
424+ "routes": h.configSvc.GetRoutes(ctx),
425+ "external_routes": h.configSvc.GetExternalRoutes(),
426+ }
427+
428+ h.sendJSON(w, http.StatusOK, config)
429+}
A internal/adapters/in/http/admin/handler_test.go +767 -0
@@ -0,0 +1,767 @@
1+package admin
2+
3+import (
4+ "bytes"
5+ "context"
6+ "encoding/json"
7+ "net/http"
8+ "net/http/httptest"
9+ "os"
10+ "path/filepath"
11+ "testing"
12+
13+ "github.com/bnema/zerowrap"
14+ "github.com/stretchr/testify/assert"
15+ "github.com/stretchr/testify/mock"
16+
17+ inmocks "gordon/internal/boundaries/in/mocks"
18+ "gordon/internal/domain"
19+)
20+
21+func testLogger() zerowrap.Logger {
22+ return zerowrap.Default()
23+}
24+
25+func ctxWithScopes(scopes ...string) context.Context {
26+ ctx := context.Background()
27+ return context.WithValue(ctx, ContextKeyScopes, scopes)
28+}
29+
30+// Routes endpoint tests
31+
32+func TestHandler_RoutesGet_RequiresReadScope(t *testing.T) {
33+ configSvc := inmocks.NewMockConfigService(t)
34+ authSvc := inmocks.NewMockAuthService(t)
35+ containerSvc := inmocks.NewMockContainerService(t)
36+
37+ handler := NewHandler(configSvc, authSvc, containerSvc, nil, "/tmp/env", testLogger())
38+
39+ tests := []struct {
40+ name string
41+ scopes []string
42+ wantStatus int
43+ }{
44+ {
45+ name: "routes read access granted",
46+ scopes: []string{"admin:routes:read"},
47+ wantStatus: http.StatusOK,
48+ },
49+ {
50+ name: "routes wildcard access granted",
51+ scopes: []string{"admin:routes:*"},
52+ wantStatus: http.StatusOK,
53+ },
54+ {
55+ name: "all admin access granted",
56+ scopes: []string{"admin:*:*"},
57+ wantStatus: http.StatusOK,
58+ },
59+ {
60+ name: "only write access denied",
61+ scopes: []string{"admin:routes:write"},
62+ wantStatus: http.StatusForbidden,
63+ },
64+ {
65+ name: "wrong resource denied",
66+ scopes: []string{"admin:secrets:read"},
67+ wantStatus: http.StatusForbidden,
68+ },
69+ {
70+ name: "no scopes denied",
71+ scopes: []string{},
72+ wantStatus: http.StatusForbidden,
73+ },
74+ }
75+
76+ for _, tt := range tests {
77+ t.Run(tt.name, func(t *testing.T) {
78+ if tt.wantStatus == http.StatusOK {
79+ configSvc.EXPECT().GetRoutes(mock.Anything).Return([]domain.Route{}).Maybe()
80+ }
81+
82+ req := httptest.NewRequest("GET", "/admin/routes", nil)
83+ req = req.WithContext(ctxWithScopes(tt.scopes...))
84+ rec := httptest.NewRecorder()
85+
86+ handler.ServeHTTP(rec, req)
87+
88+ assert.Equal(t, tt.wantStatus, rec.Code)
89+ })
90+ }
91+}
92+
93+func TestHandler_RoutesPost_RequiresWriteScope(t *testing.T) {
94+ configSvc := inmocks.NewMockConfigService(t)
95+ authSvc := inmocks.NewMockAuthService(t)
96+ containerSvc := inmocks.NewMockContainerService(t)
97+
98+ handler := NewHandler(configSvc, authSvc, containerSvc, nil, "/tmp/env", testLogger())
99+
100+ routeJSON := `{"domain": "app.example.com", "image": "myapp:latest"}`
101+
102+ tests := []struct {
103+ name string
104+ scopes []string
105+ wantStatus int
106+ }{
107+ {
108+ name: "routes write access granted",
109+ scopes: []string{"admin:routes:write"},
110+ wantStatus: http.StatusCreated,
111+ },
112+ {
113+ name: "routes wildcard access granted",
114+ scopes: []string{"admin:routes:*"},
115+ wantStatus: http.StatusCreated,
116+ },
117+ {
118+ name: "all admin access granted",
119+ scopes: []string{"admin:*:*"},
120+ wantStatus: http.StatusCreated,
121+ },
122+ {
123+ name: "only read access denied",
124+ scopes: []string{"admin:routes:read"},
125+ wantStatus: http.StatusForbidden,
126+ },
127+ {
128+ name: "wrong resource denied",
129+ scopes: []string{"admin:secrets:write"},
130+ wantStatus: http.StatusForbidden,
131+ },
132+ }
133+
134+ for _, tt := range tests {
135+ t.Run(tt.name, func(t *testing.T) {
136+ if tt.wantStatus == http.StatusCreated {
137+ configSvc.EXPECT().AddRoute(mock.Anything, mock.AnythingOfType("domain.Route")).Return(nil).Maybe()
138+ }
139+
140+ req := httptest.NewRequest("POST", "/admin/routes", bytes.NewBufferString(routeJSON))
141+ req = req.WithContext(ctxWithScopes(tt.scopes...))
142+ rec := httptest.NewRecorder()
143+
144+ handler.ServeHTTP(rec, req)
145+
146+ assert.Equal(t, tt.wantStatus, rec.Code)
147+ })
148+ }
149+}
150+
151+func TestHandler_RoutesPut_RequiresWriteScope(t *testing.T) {
152+ configSvc := inmocks.NewMockConfigService(t)
153+ authSvc := inmocks.NewMockAuthService(t)
154+ containerSvc := inmocks.NewMockContainerService(t)
155+
156+ handler := NewHandler(configSvc, authSvc, containerSvc, nil, "/tmp/env", testLogger())
157+
158+ routeJSON := `{"image": "myapp:v2"}`
159+
160+ tests := []struct {
161+ name string
162+ scopes []string
163+ wantStatus int
164+ }{
165+ {
166+ name: "routes write access granted",
167+ scopes: []string{"admin:routes:write"},
168+ wantStatus: http.StatusOK,
169+ },
170+ {
171+ name: "only read access denied",
172+ scopes: []string{"admin:routes:read"},
173+ wantStatus: http.StatusForbidden,
174+ },
175+ }
176+
177+ for _, tt := range tests {
178+ t.Run(tt.name, func(t *testing.T) {
179+ if tt.wantStatus == http.StatusOK {
180+ configSvc.EXPECT().UpdateRoute(mock.Anything, mock.AnythingOfType("domain.Route")).Return(nil).Maybe()
181+ }
182+
183+ req := httptest.NewRequest("PUT", "/admin/routes/app.example.com", bytes.NewBufferString(routeJSON))
184+ req = req.WithContext(ctxWithScopes(tt.scopes...))
185+ rec := httptest.NewRecorder()
186+
187+ handler.ServeHTTP(rec, req)
188+
189+ assert.Equal(t, tt.wantStatus, rec.Code)
190+ })
191+ }
192+}
193+
194+func TestHandler_RoutesDelete_RequiresWriteScope(t *testing.T) {
195+ configSvc := inmocks.NewMockConfigService(t)
196+ authSvc := inmocks.NewMockAuthService(t)
197+ containerSvc := inmocks.NewMockContainerService(t)
198+
199+ handler := NewHandler(configSvc, authSvc, containerSvc, nil, "/tmp/env", testLogger())
200+
201+ tests := []struct {
202+ name string
203+ scopes []string
204+ wantStatus int
205+ }{
206+ {
207+ name: "routes write access granted",
208+ scopes: []string{"admin:routes:write"},
209+ wantStatus: http.StatusOK,
210+ },
211+ {
212+ name: "only read access denied",
213+ scopes: []string{"admin:routes:read"},
214+ wantStatus: http.StatusForbidden,
215+ },
216+ }
217+
218+ for _, tt := range tests {
219+ t.Run(tt.name, func(t *testing.T) {
220+ if tt.wantStatus == http.StatusOK {
221+ configSvc.EXPECT().RemoveRoute(mock.Anything, "app.example.com").Return(nil).Maybe()
222+ }
223+
224+ req := httptest.NewRequest("DELETE", "/admin/routes/app.example.com", nil)
225+ req = req.WithContext(ctxWithScopes(tt.scopes...))
226+ rec := httptest.NewRecorder()
227+
228+ handler.ServeHTTP(rec, req)
229+
230+ assert.Equal(t, tt.wantStatus, rec.Code)
231+ })
232+ }
233+}
234+
235+// Secrets endpoint tests
236+
237+func TestHandler_SecretsGet_RequiresReadScope(t *testing.T) {
238+ configSvc := inmocks.NewMockConfigService(t)
239+ authSvc := inmocks.NewMockAuthService(t)
240+ containerSvc := inmocks.NewMockContainerService(t)
241+
242+ // Create temp env directory
243+ tmpDir := t.TempDir()
244+
245+ handler := NewHandler(configSvc, authSvc, containerSvc, nil, tmpDir, testLogger())
246+
247+ tests := []struct {
248+ name string
249+ scopes []string
250+ wantStatus int
251+ }{
252+ {
253+ name: "secrets read access granted",
254+ scopes: []string{"admin:secrets:read"},
255+ wantStatus: http.StatusOK,
256+ },
257+ {
258+ name: "secrets wildcard access granted",
259+ scopes: []string{"admin:secrets:*"},
260+ wantStatus: http.StatusOK,
261+ },
262+ {
263+ name: "all admin access granted",
264+ scopes: []string{"admin:*:*"},
265+ wantStatus: http.StatusOK,
266+ },
267+ {
268+ name: "only write access denied",
269+ scopes: []string{"admin:secrets:write"},
270+ wantStatus: http.StatusForbidden,
271+ },
272+ {
273+ name: "wrong resource denied",
274+ scopes: []string{"admin:routes:read"},
275+ wantStatus: http.StatusForbidden,
276+ },
277+ }
278+
279+ for _, tt := range tests {
280+ t.Run(tt.name, func(t *testing.T) {
281+ req := httptest.NewRequest("GET", "/admin/secrets/app.example.com", nil)
282+ req = req.WithContext(ctxWithScopes(tt.scopes...))
283+ rec := httptest.NewRecorder()
284+
285+ handler.ServeHTTP(rec, req)
286+
287+ assert.Equal(t, tt.wantStatus, rec.Code)
288+ })
289+ }
290+}
291+
292+func TestHandler_SecretsPost_RequiresWriteScope(t *testing.T) {
293+ configSvc := inmocks.NewMockConfigService(t)
294+ authSvc := inmocks.NewMockAuthService(t)
295+ containerSvc := inmocks.NewMockContainerService(t)
296+
297+ // Create temp env directory
298+ tmpDir := t.TempDir()
299+
300+ handler := NewHandler(configSvc, authSvc, containerSvc, nil, tmpDir, testLogger())
301+
302+ secretsJSON := `{"API_KEY": "secret123"}`
303+
304+ tests := []struct {
305+ name string
306+ scopes []string
307+ wantStatus int
308+ }{
309+ {
310+ name: "secrets write access granted",
311+ scopes: []string{"admin:secrets:write"},
312+ wantStatus: http.StatusOK,
313+ },
314+ {
315+ name: "only read access denied",
316+ scopes: []string{"admin:secrets:read"},
317+ wantStatus: http.StatusForbidden,
318+ },
319+ {
320+ name: "wrong resource denied",
321+ scopes: []string{"admin:routes:write"},
322+ wantStatus: http.StatusForbidden,
323+ },
324+ }
325+
326+ for _, tt := range tests {
327+ t.Run(tt.name, func(t *testing.T) {
328+ req := httptest.NewRequest("POST", "/admin/secrets/app.example.com", bytes.NewBufferString(secretsJSON))
329+ req = req.WithContext(ctxWithScopes(tt.scopes...))
330+ rec := httptest.NewRecorder()
331+
332+ handler.ServeHTTP(rec, req)
333+
334+ assert.Equal(t, tt.wantStatus, rec.Code)
335+ })
336+ }
337+}
338+
339+func TestHandler_SecretsDelete_RequiresWriteScope(t *testing.T) {
340+ configSvc := inmocks.NewMockConfigService(t)
341+ authSvc := inmocks.NewMockAuthService(t)
342+ containerSvc := inmocks.NewMockContainerService(t)
343+
344+ // Create temp env directory with a secret
345+ tmpDir := t.TempDir()
346+ envFile := filepath.Join(tmpDir, "app_example_com.env")
347+ os.WriteFile(envFile, []byte("API_KEY=secret123\n"), 0600)
348+
349+ handler := NewHandler(configSvc, authSvc, containerSvc, nil, tmpDir, testLogger())
350+
351+ tests := []struct {
352+ name string
353+ scopes []string
354+ wantStatus int
355+ }{
356+ {
357+ name: "secrets write access granted",
358+ scopes: []string{"admin:secrets:write"},
359+ wantStatus: http.StatusOK,
360+ },
361+ {
362+ name: "only read access denied",
363+ scopes: []string{"admin:secrets:read"},
364+ wantStatus: http.StatusForbidden,
365+ },
366+ }
367+
368+ for _, tt := range tests {
369+ t.Run(tt.name, func(t *testing.T) {
370+ req := httptest.NewRequest("DELETE", "/admin/secrets/app.example.com/API_KEY", nil)
371+ req = req.WithContext(ctxWithScopes(tt.scopes...))
372+ rec := httptest.NewRecorder()
373+
374+ handler.ServeHTTP(rec, req)
375+
376+ assert.Equal(t, tt.wantStatus, rec.Code)
377+ })
378+ }
379+}
380+
381+// Status endpoint tests
382+
383+func TestHandler_Status_RequiresReadScope(t *testing.T) {
384+ configSvc := inmocks.NewMockConfigService(t)
385+ authSvc := inmocks.NewMockAuthService(t)
386+ containerSvc := inmocks.NewMockContainerService(t)
387+
388+ handler := NewHandler(configSvc, authSvc, containerSvc, nil, "/tmp/env", testLogger())
389+
390+ tests := []struct {
391+ name string
392+ scopes []string
393+ wantStatus int
394+ }{
395+ {
396+ name: "status read access granted",
397+ scopes: []string{"admin:status:read"},
398+ wantStatus: http.StatusOK,
399+ },
400+ {
401+ name: "all admin access granted",
402+ scopes: []string{"admin:*:*"},
403+ wantStatus: http.StatusOK,
404+ },
405+ {
406+ name: "wrong resource denied",
407+ scopes: []string{"admin:routes:read"},
408+ wantStatus: http.StatusForbidden,
409+ },
410+ }
411+
412+ for _, tt := range tests {
413+ t.Run(tt.name, func(t *testing.T) {
414+ if tt.wantStatus == http.StatusOK {
415+ configSvc.EXPECT().GetRoutes(mock.Anything).Return([]domain.Route{}).Maybe()
416+ configSvc.EXPECT().GetRegistryDomain().Return("registry.example.com").Maybe()
417+ configSvc.EXPECT().GetRegistryPort().Return(5000).Maybe()
418+ configSvc.EXPECT().GetServerPort().Return(8080).Maybe()
419+ configSvc.EXPECT().IsAutoRouteEnabled().Return(true).Maybe()
420+ configSvc.EXPECT().IsNetworkIsolationEnabled().Return(false).Maybe()
421+ }
422+
423+ req := httptest.NewRequest("GET", "/admin/status", nil)
424+ req = req.WithContext(ctxWithScopes(tt.scopes...))
425+ rec := httptest.NewRecorder()
426+
427+ handler.ServeHTTP(rec, req)
428+
429+ assert.Equal(t, tt.wantStatus, rec.Code)
430+ })
431+ }
432+}
433+
434+// Config endpoint tests
435+
436+func TestHandler_Config_RequiresReadScope(t *testing.T) {
437+ configSvc := inmocks.NewMockConfigService(t)
438+ authSvc := inmocks.NewMockAuthService(t)
439+ containerSvc := inmocks.NewMockContainerService(t)
440+
441+ handler := NewHandler(configSvc, authSvc, containerSvc, nil, "/tmp/env", testLogger())
442+
443+ tests := []struct {
444+ name string
445+ scopes []string
446+ wantStatus int
447+ }{
448+ {
449+ name: "config read access granted",
450+ scopes: []string{"admin:config:read"},
451+ wantStatus: http.StatusOK,
452+ },
453+ {
454+ name: "all admin access granted",
455+ scopes: []string{"admin:*:*"},
456+ wantStatus: http.StatusOK,
457+ },
458+ {
459+ name: "wrong resource denied",
460+ scopes: []string{"admin:routes:read"},
461+ wantStatus: http.StatusForbidden,
462+ },
463+ }
464+
465+ for _, tt := range tests {
466+ t.Run(tt.name, func(t *testing.T) {
467+ if tt.wantStatus == http.StatusOK {
468+ configSvc.EXPECT().GetServerPort().Return(8080).Maybe()
469+ configSvc.EXPECT().GetRegistryPort().Return(5000).Maybe()
470+ configSvc.EXPECT().GetRegistryDomain().Return("registry.example.com").Maybe()
471+ configSvc.EXPECT().GetDataDir().Return("/var/lib/gordon").Maybe()
472+ configSvc.EXPECT().IsAutoRouteEnabled().Return(true).Maybe()
473+ configSvc.EXPECT().IsNetworkIsolationEnabled().Return(false).Maybe()
474+ configSvc.EXPECT().GetNetworkPrefix().Return("gordon").Maybe()
475+ configSvc.EXPECT().GetRoutes(mock.Anything).Return([]domain.Route{}).Maybe()
476+ configSvc.EXPECT().GetExternalRoutes().Return(map[string]string{}).Maybe()
477+ }
478+
479+ req := httptest.NewRequest("GET", "/admin/config", nil)
480+ req = req.WithContext(ctxWithScopes(tt.scopes...))
481+ rec := httptest.NewRecorder()
482+
483+ handler.ServeHTTP(rec, req)
484+
485+ assert.Equal(t, tt.wantStatus, rec.Code)
486+ })
487+ }
488+}
489+
490+// Reload endpoint tests
491+
492+func TestHandler_Reload_RequiresWriteScope(t *testing.T) {
493+ configSvc := inmocks.NewMockConfigService(t)
494+ authSvc := inmocks.NewMockAuthService(t)
495+ containerSvc := inmocks.NewMockContainerService(t)
496+
497+ handler := NewHandler(configSvc, authSvc, containerSvc, nil, "/tmp/env", testLogger())
498+
499+ tests := []struct {
500+ name string
501+ scopes []string
502+ wantStatus int
503+ }{
504+ {
505+ name: "config write access granted",
506+ scopes: []string{"admin:config:write"},
507+ wantStatus: http.StatusOK,
508+ },
509+ {
510+ name: "all admin access granted",
511+ scopes: []string{"admin:*:*"},
512+ wantStatus: http.StatusOK,
513+ },
514+ {
515+ name: "only read access denied",
516+ scopes: []string{"admin:config:read"},
517+ wantStatus: http.StatusForbidden,
518+ },
519+ {
520+ name: "wrong resource denied",
521+ scopes: []string{"admin:routes:write"},
522+ wantStatus: http.StatusForbidden,
523+ },
524+ }
525+
526+ for _, tt := range tests {
527+ t.Run(tt.name, func(t *testing.T) {
528+ if tt.wantStatus == http.StatusOK {
529+ configSvc.EXPECT().Load(mock.Anything).Return(nil).Maybe()
530+ }
531+
532+ req := httptest.NewRequest("POST", "/admin/reload", nil)
533+ req = req.WithContext(ctxWithScopes(tt.scopes...))
534+ rec := httptest.NewRecorder()
535+
536+ handler.ServeHTTP(rec, req)
537+
538+ assert.Equal(t, tt.wantStatus, rec.Code)
539+ })
540+ }
541+}
542+
543+// Functional tests
544+
545+func TestHandler_RoutesGet_ReturnsRoutes(t *testing.T) {
546+ configSvc := inmocks.NewMockConfigService(t)
547+ authSvc := inmocks.NewMockAuthService(t)
548+ containerSvc := inmocks.NewMockContainerService(t)
549+
550+ handler := NewHandler(configSvc, authSvc, containerSvc, nil, "/tmp/env", testLogger())
551+
552+ expectedRoutes := []domain.Route{
553+ {Domain: "app1.example.com", Image: "app1:latest"},
554+ {Domain: "app2.example.com", Image: "app2:v1.0"},
555+ }
556+ configSvc.EXPECT().GetRoutes(mock.Anything).Return(expectedRoutes)
557+
558+ req := httptest.NewRequest("GET", "/admin/routes", nil)
559+ req = req.WithContext(ctxWithScopes("admin:routes:read"))
560+ rec := httptest.NewRecorder()
561+
562+ handler.ServeHTTP(rec, req)
563+
564+ assert.Equal(t, http.StatusOK, rec.Code)
565+
566+ var response map[string]any
567+ err := json.NewDecoder(rec.Body).Decode(&response)
568+ assert.NoError(t, err)
569+
570+ routes, ok := response["routes"].([]any)
571+ assert.True(t, ok)
572+ assert.Len(t, routes, 2)
573+}
574+
575+func TestHandler_RoutesGet_SingleRoute(t *testing.T) {
576+ configSvc := inmocks.NewMockConfigService(t)
577+ authSvc := inmocks.NewMockAuthService(t)
578+ containerSvc := inmocks.NewMockContainerService(t)
579+
580+ handler := NewHandler(configSvc, authSvc, containerSvc, nil, "/tmp/env", testLogger())
581+
582+ configSvc.EXPECT().GetRoutes(mock.Anything).Return([]domain.Route{
583+ {Domain: "app.example.com", Image: "app:latest"},
584+ })
585+
586+ req := httptest.NewRequest("GET", "/admin/routes/app.example.com", nil)
587+ req = req.WithContext(ctxWithScopes("admin:routes:read"))
588+ rec := httptest.NewRecorder()
589+
590+ handler.ServeHTTP(rec, req)
591+
592+ assert.Equal(t, http.StatusOK, rec.Code)
593+
594+ var route domain.Route
595+ err := json.NewDecoder(rec.Body).Decode(&route)
596+ assert.NoError(t, err)
597+ assert.Equal(t, "app.example.com", route.Domain)
598+ assert.Equal(t, "app:latest", route.Image)
599+}
600+
601+func TestHandler_RoutesGet_NotFound(t *testing.T) {
602+ configSvc := inmocks.NewMockConfigService(t)
603+ authSvc := inmocks.NewMockAuthService(t)
604+ containerSvc := inmocks.NewMockContainerService(t)
605+
606+ handler := NewHandler(configSvc, authSvc, containerSvc, nil, "/tmp/env", testLogger())
607+
608+ configSvc.EXPECT().GetRoutes(mock.Anything).Return([]domain.Route{})
609+
610+ req := httptest.NewRequest("GET", "/admin/routes/nonexistent.example.com", nil)
611+ req = req.WithContext(ctxWithScopes("admin:routes:read"))
612+ rec := httptest.NewRecorder()
613+
614+ handler.ServeHTTP(rec, req)
615+
616+ assert.Equal(t, http.StatusNotFound, rec.Code)
617+}
618+
619+func TestHandler_RoutesPost_MissingFields(t *testing.T) {
620+ configSvc := inmocks.NewMockConfigService(t)
621+ authSvc := inmocks.NewMockAuthService(t)
622+ containerSvc := inmocks.NewMockContainerService(t)
623+
624+ handler := NewHandler(configSvc, authSvc, containerSvc, nil, "/tmp/env", testLogger())
625+
626+ tests := []struct {
627+ name string
628+ body string
629+ }{
630+ {
631+ name: "missing domain",
632+ body: `{"image": "app:latest"}`,
633+ },
634+ {
635+ name: "missing image",
636+ body: `{"domain": "app.example.com"}`,
637+ },
638+ {
639+ name: "empty object",
640+ body: `{}`,
641+ },
642+ }
643+
644+ for _, tt := range tests {
645+ t.Run(tt.name, func(t *testing.T) {
646+ req := httptest.NewRequest("POST", "/admin/routes", bytes.NewBufferString(tt.body))
647+ req = req.WithContext(ctxWithScopes("admin:routes:write"))
648+ rec := httptest.NewRecorder()
649+
650+ handler.ServeHTTP(rec, req)
651+
652+ assert.Equal(t, http.StatusBadRequest, rec.Code)
653+ })
654+ }
655+}
656+
657+func TestHandler_RoutesPut_MissingDomain(t *testing.T) {
658+ configSvc := inmocks.NewMockConfigService(t)
659+ authSvc := inmocks.NewMockAuthService(t)
660+ containerSvc := inmocks.NewMockContainerService(t)
661+
662+ handler := NewHandler(configSvc, authSvc, containerSvc, nil, "/tmp/env", testLogger())
663+
664+ req := httptest.NewRequest("PUT", "/admin/routes/", bytes.NewBufferString(`{"image": "app:latest"}`))
665+ req = req.WithContext(ctxWithScopes("admin:routes:write"))
666+ rec := httptest.NewRecorder()
667+
668+ handler.ServeHTTP(rec, req)
669+
670+ assert.Equal(t, http.StatusBadRequest, rec.Code)
671+}
672+
673+func TestHandler_RoutesDelete_MissingDomain(t *testing.T) {
674+ configSvc := inmocks.NewMockConfigService(t)
675+ authSvc := inmocks.NewMockAuthService(t)
676+ containerSvc := inmocks.NewMockContainerService(t)
677+
678+ handler := NewHandler(configSvc, authSvc, containerSvc, nil, "/tmp/env", testLogger())
679+
680+ req := httptest.NewRequest("DELETE", "/admin/routes/", nil)
681+ req = req.WithContext(ctxWithScopes("admin:routes:write"))
682+ rec := httptest.NewRecorder()
683+
684+ handler.ServeHTTP(rec, req)
685+
686+ assert.Equal(t, http.StatusBadRequest, rec.Code)
687+}
688+
689+func TestHandler_Secrets_MissingDomain(t *testing.T) {
690+ configSvc := inmocks.NewMockConfigService(t)
691+ authSvc := inmocks.NewMockAuthService(t)
692+ containerSvc := inmocks.NewMockContainerService(t)
693+
694+ handler := NewHandler(configSvc, authSvc, containerSvc, nil, "/tmp/env", testLogger())
695+
696+ req := httptest.NewRequest("GET", "/admin/secrets/", nil)
697+ req = req.WithContext(ctxWithScopes("admin:secrets:read"))
698+ rec := httptest.NewRecorder()
699+
700+ handler.ServeHTTP(rec, req)
701+
702+ assert.Equal(t, http.StatusBadRequest, rec.Code)
703+}
704+
705+func TestHandler_SecretsDelete_MissingKey(t *testing.T) {
706+ configSvc := inmocks.NewMockConfigService(t)
707+ authSvc := inmocks.NewMockAuthService(t)
708+ containerSvc := inmocks.NewMockContainerService(t)
709+
710+ handler := NewHandler(configSvc, authSvc, containerSvc, nil, "/tmp/env", testLogger())
711+
712+ req := httptest.NewRequest("DELETE", "/admin/secrets/app.example.com", nil)
713+ req = req.WithContext(ctxWithScopes("admin:secrets:write"))
714+ rec := httptest.NewRecorder()
715+
716+ handler.ServeHTTP(rec, req)
717+
718+ assert.Equal(t, http.StatusBadRequest, rec.Code)
719+}
720+
721+func TestHandler_MethodNotAllowed(t *testing.T) {
722+ configSvc := inmocks.NewMockConfigService(t)
723+ authSvc := inmocks.NewMockAuthService(t)
724+ containerSvc := inmocks.NewMockContainerService(t)
725+
726+ handler := NewHandler(configSvc, authSvc, containerSvc, nil, "/tmp/env", testLogger())
727+
728+ tests := []struct {
729+ method string
730+ path string
731+ }{
732+ {"DELETE", "/admin/status"},
733+ {"PUT", "/admin/status"},
734+ {"GET", "/admin/reload"},
735+ {"DELETE", "/admin/reload"},
736+ {"POST", "/admin/config"},
737+ {"DELETE", "/admin/config"},
738+ }
739+
740+ for _, tt := range tests {
741+ t.Run(tt.method+" "+tt.path, func(t *testing.T) {
742+ req := httptest.NewRequest(tt.method, tt.path, nil)
743+ req = req.WithContext(ctxWithScopes("admin:*:*"))
744+ rec := httptest.NewRecorder()
745+
746+ handler.ServeHTTP(rec, req)
747+
748+ assert.Equal(t, http.StatusMethodNotAllowed, rec.Code)
749+ })
750+ }
751+}
752+
753+func TestHandler_NotFound(t *testing.T) {
754+ configSvc := inmocks.NewMockConfigService(t)
755+ authSvc := inmocks.NewMockAuthService(t)
756+ containerSvc := inmocks.NewMockContainerService(t)
757+
758+ handler := NewHandler(configSvc, authSvc, containerSvc, nil, "/tmp/env", testLogger())
759+
760+ req := httptest.NewRequest("GET", "/admin/unknown", nil)
761+ req = req.WithContext(ctxWithScopes("admin:*:*"))
762+ rec := httptest.NewRecorder()
763+
764+ handler.ServeHTTP(rec, req)
765+
766+ assert.Equal(t, http.StatusNotFound, rec.Code)
767+}
@@ -0,0 +1,767 @@
1+package admin
2+
3+import (
4+ "bytes"
5+ "context"
6+ "encoding/json"
7+ "net/http"
8+ "net/http/httptest"
9+ "os"
10+ "path/filepath"
11+ "testing"
12+
13+ "github.com/bnema/zerowrap"
14+ "github.com/stretchr/testify/assert"
15+ "github.com/stretchr/testify/mock"
16+
17+ inmocks "gordon/internal/boundaries/in/mocks"
18+ "gordon/internal/domain"
19+)
20+
21+func testLogger() zerowrap.Logger {
22+ return zerowrap.Default()
23+}
24+
25+func ctxWithScopes(scopes ...string) context.Context {
26+ ctx := context.Background()
27+ return context.WithValue(ctx, ContextKeyScopes, scopes)
28+}
29+
30+// Routes endpoint tests
31+
32+func TestHandler_RoutesGet_RequiresReadScope(t *testing.T) {
33+ configSvc := inmocks.NewMockConfigService(t)
34+ authSvc := inmocks.NewMockAuthService(t)
35+ containerSvc := inmocks.NewMockContainerService(t)
36+
37+ handler := NewHandler(configSvc, authSvc, containerSvc, nil, "/tmp/env", testLogger())
38+
39+ tests := []struct {
40+ name string
41+ scopes []string
42+ wantStatus int
43+ }{
44+ {
45+ name: "routes read access granted",
46+ scopes: []string{"admin:routes:read"},
47+ wantStatus: http.StatusOK,
48+ },
49+ {
50+ name: "routes wildcard access granted",
51+ scopes: []string{"admin:routes:*"},
52+ wantStatus: http.StatusOK,
53+ },
54+ {
55+ name: "all admin access granted",
56+ scopes: []string{"admin:*:*"},
57+ wantStatus: http.StatusOK,
58+ },
59+ {
60+ name: "only write access denied",
61+ scopes: []string{"admin:routes:write"},
62+ wantStatus: http.StatusForbidden,
63+ },
64+ {
65+ name: "wrong resource denied",
66+ scopes: []string{"admin:secrets:read"},
67+ wantStatus: http.StatusForbidden,
68+ },
69+ {
70+ name: "no scopes denied",
71+ scopes: []string{},
72+ wantStatus: http.StatusForbidden,
73+ },
74+ }
75+
76+ for _, tt := range tests {
77+ t.Run(tt.name, func(t *testing.T) {
78+ if tt.wantStatus == http.StatusOK {
79+ configSvc.EXPECT().GetRoutes(mock.Anything).Return([]domain.Route{}).Maybe()
80+ }
81+
82+ req := httptest.NewRequest("GET", "/admin/routes", nil)
83+ req = req.WithContext(ctxWithScopes(tt.scopes...))
84+ rec := httptest.NewRecorder()
85+
86+ handler.ServeHTTP(rec, req)
87+
88+ assert.Equal(t, tt.wantStatus, rec.Code)
89+ })
90+ }
91+}
92+
93+func TestHandler_RoutesPost_RequiresWriteScope(t *testing.T) {
94+ configSvc := inmocks.NewMockConfigService(t)
95+ authSvc := inmocks.NewMockAuthService(t)
96+ containerSvc := inmocks.NewMockContainerService(t)
97+
98+ handler := NewHandler(configSvc, authSvc, containerSvc, nil, "/tmp/env", testLogger())
99+
100+ routeJSON := `{"domain": "app.example.com", "image": "myapp:latest"}`
101+
102+ tests := []struct {
103+ name string
104+ scopes []string
105+ wantStatus int
106+ }{
107+ {
108+ name: "routes write access granted",
109+ scopes: []string{"admin:routes:write"},
110+ wantStatus: http.StatusCreated,
111+ },
112+ {
113+ name: "routes wildcard access granted",
114+ scopes: []string{"admin:routes:*"},
115+ wantStatus: http.StatusCreated,
116+ },
117+ {
118+ name: "all admin access granted",
119+ scopes: []string{"admin:*:*"},
120+ wantStatus: http.StatusCreated,
121+ },
122+ {
123+ name: "only read access denied",
124+ scopes: []string{"admin:routes:read"},
125+ wantStatus: http.StatusForbidden,
126+ },
127+ {
128+ name: "wrong resource denied",
129+ scopes: []string{"admin:secrets:write"},
130+ wantStatus: http.StatusForbidden,
131+ },
132+ }
133+
134+ for _, tt := range tests {
135+ t.Run(tt.name, func(t *testing.T) {
136+ if tt.wantStatus == http.StatusCreated {
137+ configSvc.EXPECT().AddRoute(mock.Anything, mock.AnythingOfType("domain.Route")).Return(nil).Maybe()
138+ }
139+
140+ req := httptest.NewRequest("POST", "/admin/routes", bytes.NewBufferString(routeJSON))
141+ req = req.WithContext(ctxWithScopes(tt.scopes...))
142+ rec := httptest.NewRecorder()
143+
144+ handler.ServeHTTP(rec, req)
145+
146+ assert.Equal(t, tt.wantStatus, rec.Code)
147+ })
148+ }
149+}
150+
151+func TestHandler_RoutesPut_RequiresWriteScope(t *testing.T) {
152+ configSvc := inmocks.NewMockConfigService(t)
153+ authSvc := inmocks.NewMockAuthService(t)
154+ containerSvc := inmocks.NewMockContainerService(t)
155+
156+ handler := NewHandler(configSvc, authSvc, containerSvc, nil, "/tmp/env", testLogger())
157+
158+ routeJSON := `{"image": "myapp:v2"}`
159+
160+ tests := []struct {
161+ name string
162+ scopes []string
163+ wantStatus int
164+ }{
165+ {
166+ name: "routes write access granted",
167+ scopes: []string{"admin:routes:write"},
168+ wantStatus: http.StatusOK,
169+ },
170+ {
171+ name: "only read access denied",
172+ scopes: []string{"admin:routes:read"},
173+ wantStatus: http.StatusForbidden,
174+ },
175+ }
176+
177+ for _, tt := range tests {
178+ t.Run(tt.name, func(t *testing.T) {
179+ if tt.wantStatus == http.StatusOK {
180+ configSvc.EXPECT().UpdateRoute(mock.Anything, mock.AnythingOfType("domain.Route")).Return(nil).Maybe()
181+ }
182+
183+ req := httptest.NewRequest("PUT", "/admin/routes/app.example.com", bytes.NewBufferString(routeJSON))
184+ req = req.WithContext(ctxWithScopes(tt.scopes...))
185+ rec := httptest.NewRecorder()
186+
187+ handler.ServeHTTP(rec, req)
188+
189+ assert.Equal(t, tt.wantStatus, rec.Code)
190+ })
191+ }
192+}
193+
194+func TestHandler_RoutesDelete_RequiresWriteScope(t *testing.T) {
195+ configSvc := inmocks.NewMockConfigService(t)
196+ authSvc := inmocks.NewMockAuthService(t)
197+ containerSvc := inmocks.NewMockContainerService(t)
198+
199+ handler := NewHandler(configSvc, authSvc, containerSvc, nil, "/tmp/env", testLogger())
200+
201+ tests := []struct {
202+ name string
203+ scopes []string
204+ wantStatus int
205+ }{
206+ {
207+ name: "routes write access granted",
208+ scopes: []string{"admin:routes:write"},
209+ wantStatus: http.StatusOK,
210+ },
211+ {
212+ name: "only read access denied",
213+ scopes: []string{"admin:routes:read"},
214+ wantStatus: http.StatusForbidden,
215+ },
216+ }
217+
218+ for _, tt := range tests {
219+ t.Run(tt.name, func(t *testing.T) {
220+ if tt.wantStatus == http.StatusOK {
221+ configSvc.EXPECT().RemoveRoute(mock.Anything, "app.example.com").Return(nil).Maybe()
222+ }
223+
224+ req := httptest.NewRequest("DELETE", "/admin/routes/app.example.com", nil)
225+ req = req.WithContext(ctxWithScopes(tt.scopes...))
226+ rec := httptest.NewRecorder()
227+
228+ handler.ServeHTTP(rec, req)
229+
230+ assert.Equal(t, tt.wantStatus, rec.Code)
231+ })
232+ }
233+}
234+
235+// Secrets endpoint tests
236+
237+func TestHandler_SecretsGet_RequiresReadScope(t *testing.T) {
238+ configSvc := inmocks.NewMockConfigService(t)
239+ authSvc := inmocks.NewMockAuthService(t)
240+ containerSvc := inmocks.NewMockContainerService(t)
241+
242+ // Create temp env directory
243+ tmpDir := t.TempDir()
244+
245+ handler := NewHandler(configSvc, authSvc, containerSvc, nil, tmpDir, testLogger())
246+
247+ tests := []struct {
248+ name string
249+ scopes []string
250+ wantStatus int
251+ }{
252+ {
253+ name: "secrets read access granted",
254+ scopes: []string{"admin:secrets:read"},
255+ wantStatus: http.StatusOK,
256+ },
257+ {
258+ name: "secrets wildcard access granted",
259+ scopes: []string{"admin:secrets:*"},
260+ wantStatus: http.StatusOK,
261+ },
262+ {
263+ name: "all admin access granted",
264+ scopes: []string{"admin:*:*"},
265+ wantStatus: http.StatusOK,
266+ },
267+ {
268+ name: "only write access denied",
269+ scopes: []string{"admin:secrets:write"},
270+ wantStatus: http.StatusForbidden,
271+ },
272+ {
273+ name: "wrong resource denied",
274+ scopes: []string{"admin:routes:read"},
275+ wantStatus: http.StatusForbidden,
276+ },
277+ }
278+
279+ for _, tt := range tests {
280+ t.Run(tt.name, func(t *testing.T) {
281+ req := httptest.NewRequest("GET", "/admin/secrets/app.example.com", nil)
282+ req = req.WithContext(ctxWithScopes(tt.scopes...))
283+ rec := httptest.NewRecorder()
284+
285+ handler.ServeHTTP(rec, req)
286+
287+ assert.Equal(t, tt.wantStatus, rec.Code)
288+ })
289+ }
290+}
291+
292+func TestHandler_SecretsPost_RequiresWriteScope(t *testing.T) {
293+ configSvc := inmocks.NewMockConfigService(t)
294+ authSvc := inmocks.NewMockAuthService(t)
295+ containerSvc := inmocks.NewMockContainerService(t)
296+
297+ // Create temp env directory
298+ tmpDir := t.TempDir()
299+
300+ handler := NewHandler(configSvc, authSvc, containerSvc, nil, tmpDir, testLogger())
301+
302+ secretsJSON := `{"API_KEY": "secret123"}`
303+
304+ tests := []struct {
305+ name string
306+ scopes []string
307+ wantStatus int
308+ }{
309+ {
310+ name: "secrets write access granted",
311+ scopes: []string{"admin:secrets:write"},
312+ wantStatus: http.StatusOK,
313+ },
314+ {
315+ name: "only read access denied",
316+ scopes: []string{"admin:secrets:read"},
317+ wantStatus: http.StatusForbidden,
318+ },
319+ {
320+ name: "wrong resource denied",
321+ scopes: []string{"admin:routes:write"},
322+ wantStatus: http.StatusForbidden,
323+ },
324+ }
325+
326+ for _, tt := range tests {
327+ t.Run(tt.name, func(t *testing.T) {
328+ req := httptest.NewRequest("POST", "/admin/secrets/app.example.com", bytes.NewBufferString(secretsJSON))
329+ req = req.WithContext(ctxWithScopes(tt.scopes...))
330+ rec := httptest.NewRecorder()
331+
332+ handler.ServeHTTP(rec, req)
333+
334+ assert.Equal(t, tt.wantStatus, rec.Code)
335+ })
336+ }
337+}
338+
339+func TestHandler_SecretsDelete_RequiresWriteScope(t *testing.T) {
340+ configSvc := inmocks.NewMockConfigService(t)
341+ authSvc := inmocks.NewMockAuthService(t)
342+ containerSvc := inmocks.NewMockContainerService(t)
343+
344+ // Create temp env directory with a secret
345+ tmpDir := t.TempDir()
346+ envFile := filepath.Join(tmpDir, "app_example_com.env")
347+ os.WriteFile(envFile, []byte("API_KEY=secret123\n"), 0600)
348+
349+ handler := NewHandler(configSvc, authSvc, containerSvc, nil, tmpDir, testLogger())
350+
351+ tests := []struct {
352+ name string
353+ scopes []string
354+ wantStatus int
355+ }{
356+ {
357+ name: "secrets write access granted",
358+ scopes: []string{"admin:secrets:write"},
359+ wantStatus: http.StatusOK,
360+ },
361+ {
362+ name: "only read access denied",
363+ scopes: []string{"admin:secrets:read"},
364+ wantStatus: http.StatusForbidden,
365+ },
366+ }
367+
368+ for _, tt := range tests {
369+ t.Run(tt.name, func(t *testing.T) {
370+ req := httptest.NewRequest("DELETE", "/admin/secrets/app.example.com/API_KEY", nil)
371+ req = req.WithContext(ctxWithScopes(tt.scopes...))
372+ rec := httptest.NewRecorder()
373+
374+ handler.ServeHTTP(rec, req)
375+
376+ assert.Equal(t, tt.wantStatus, rec.Code)
377+ })
378+ }
379+}
380+
381+// Status endpoint tests
382+
383+func TestHandler_Status_RequiresReadScope(t *testing.T) {
384+ configSvc := inmocks.NewMockConfigService(t)
385+ authSvc := inmocks.NewMockAuthService(t)
386+ containerSvc := inmocks.NewMockContainerService(t)
387+
388+ handler := NewHandler(configSvc, authSvc, containerSvc, nil, "/tmp/env", testLogger())
389+
390+ tests := []struct {
391+ name string
392+ scopes []string
393+ wantStatus int
394+ }{
395+ {
396+ name: "status read access granted",
397+ scopes: []string{"admin:status:read"},
398+ wantStatus: http.StatusOK,
399+ },
400+ {
401+ name: "all admin access granted",
402+ scopes: []string{"admin:*:*"},
403+ wantStatus: http.StatusOK,
404+ },
405+ {
406+ name: "wrong resource denied",
407+ scopes: []string{"admin:routes:read"},
408+ wantStatus: http.StatusForbidden,
409+ },
410+ }
411+
412+ for _, tt := range tests {
413+ t.Run(tt.name, func(t *testing.T) {
414+ if tt.wantStatus == http.StatusOK {
415+ configSvc.EXPECT().GetRoutes(mock.Anything).Return([]domain.Route{}).Maybe()
416+ configSvc.EXPECT().GetRegistryDomain().Return("registry.example.com").Maybe()
417+ configSvc.EXPECT().GetRegistryPort().Return(5000).Maybe()
418+ configSvc.EXPECT().GetServerPort().Return(8080).Maybe()
419+ configSvc.EXPECT().IsAutoRouteEnabled().Return(true).Maybe()
420+ configSvc.EXPECT().IsNetworkIsolationEnabled().Return(false).Maybe()
421+ }
422+
423+ req := httptest.NewRequest("GET", "/admin/status", nil)
424+ req = req.WithContext(ctxWithScopes(tt.scopes...))
425+ rec := httptest.NewRecorder()
426+
427+ handler.ServeHTTP(rec, req)
428+
429+ assert.Equal(t, tt.wantStatus, rec.Code)
430+ })
431+ }
432+}
433+
434+// Config endpoint tests
435+
436+func TestHandler_Config_RequiresReadScope(t *testing.T) {
437+ configSvc := inmocks.NewMockConfigService(t)
438+ authSvc := inmocks.NewMockAuthService(t)
439+ containerSvc := inmocks.NewMockContainerService(t)
440+
441+ handler := NewHandler(configSvc, authSvc, containerSvc, nil, "/tmp/env", testLogger())
442+
443+ tests := []struct {
444+ name string
445+ scopes []string
446+ wantStatus int
447+ }{
448+ {
449+ name: "config read access granted",
450+ scopes: []string{"admin:config:read"},
451+ wantStatus: http.StatusOK,
452+ },
453+ {
454+ name: "all admin access granted",
455+ scopes: []string{"admin:*:*"},
456+ wantStatus: http.StatusOK,
457+ },
458+ {
459+ name: "wrong resource denied",
460+ scopes: []string{"admin:routes:read"},
461+ wantStatus: http.StatusForbidden,
462+ },
463+ }
464+
465+ for _, tt := range tests {
466+ t.Run(tt.name, func(t *testing.T) {
467+ if tt.wantStatus == http.StatusOK {
468+ configSvc.EXPECT().GetServerPort().Return(8080).Maybe()
469+ configSvc.EXPECT().GetRegistryPort().Return(5000).Maybe()
470+ configSvc.EXPECT().GetRegistryDomain().Return("registry.example.com").Maybe()
471+ configSvc.EXPECT().GetDataDir().Return("/var/lib/gordon").Maybe()
472+ configSvc.EXPECT().IsAutoRouteEnabled().Return(true).Maybe()
473+ configSvc.EXPECT().IsNetworkIsolationEnabled().Return(false).Maybe()
474+ configSvc.EXPECT().GetNetworkPrefix().Return("gordon").Maybe()
475+ configSvc.EXPECT().GetRoutes(mock.Anything).Return([]domain.Route{}).Maybe()
476+ configSvc.EXPECT().GetExternalRoutes().Return(map[string]string{}).Maybe()
477+ }
478+
479+ req := httptest.NewRequest("GET", "/admin/config", nil)
480+ req = req.WithContext(ctxWithScopes(tt.scopes...))
481+ rec := httptest.NewRecorder()
482+
483+ handler.ServeHTTP(rec, req)
484+
485+ assert.Equal(t, tt.wantStatus, rec.Code)
486+ })
487+ }
488+}
489+
490+// Reload endpoint tests
491+
492+func TestHandler_Reload_RequiresWriteScope(t *testing.T) {
493+ configSvc := inmocks.NewMockConfigService(t)
494+ authSvc := inmocks.NewMockAuthService(t)
495+ containerSvc := inmocks.NewMockContainerService(t)
496+
497+ handler := NewHandler(configSvc, authSvc, containerSvc, nil, "/tmp/env", testLogger())
498+
499+ tests := []struct {
500+ name string
501+ scopes []string
502+ wantStatus int
503+ }{
504+ {
505+ name: "config write access granted",
506+ scopes: []string{"admin:config:write"},
507+ wantStatus: http.StatusOK,
508+ },
509+ {
510+ name: "all admin access granted",
511+ scopes: []string{"admin:*:*"},
512+ wantStatus: http.StatusOK,
513+ },
514+ {
515+ name: "only read access denied",
516+ scopes: []string{"admin:config:read"},
517+ wantStatus: http.StatusForbidden,
518+ },
519+ {
520+ name: "wrong resource denied",
521+ scopes: []string{"admin:routes:write"},
522+ wantStatus: http.StatusForbidden,
523+ },
524+ }
525+
526+ for _, tt := range tests {
527+ t.Run(tt.name, func(t *testing.T) {
528+ if tt.wantStatus == http.StatusOK {
529+ configSvc.EXPECT().Load(mock.Anything).Return(nil).Maybe()
530+ }
531+
532+ req := httptest.NewRequest("POST", "/admin/reload", nil)
533+ req = req.WithContext(ctxWithScopes(tt.scopes...))
534+ rec := httptest.NewRecorder()
535+
536+ handler.ServeHTTP(rec, req)
537+
538+ assert.Equal(t, tt.wantStatus, rec.Code)
539+ })
540+ }
541+}
542+
543+// Functional tests
544+
545+func TestHandler_RoutesGet_ReturnsRoutes(t *testing.T) {
546+ configSvc := inmocks.NewMockConfigService(t)
547+ authSvc := inmocks.NewMockAuthService(t)
548+ containerSvc := inmocks.NewMockContainerService(t)
549+
550+ handler := NewHandler(configSvc, authSvc, containerSvc, nil, "/tmp/env", testLogger())
551+
552+ expectedRoutes := []domain.Route{
553+ {Domain: "app1.example.com", Image: "app1:latest"},
554+ {Domain: "app2.example.com", Image: "app2:v1.0"},
555+ }
556+ configSvc.EXPECT().GetRoutes(mock.Anything).Return(expectedRoutes)
557+
558+ req := httptest.NewRequest("GET", "/admin/routes", nil)
559+ req = req.WithContext(ctxWithScopes("admin:routes:read"))
560+ rec := httptest.NewRecorder()
561+
562+ handler.ServeHTTP(rec, req)
563+
564+ assert.Equal(t, http.StatusOK, rec.Code)
565+
566+ var response map[string]any
567+ err := json.NewDecoder(rec.Body).Decode(&response)
568+ assert.NoError(t, err)
569+
570+ routes, ok := response["routes"].([]any)
571+ assert.True(t, ok)
572+ assert.Len(t, routes, 2)
573+}
574+
575+func TestHandler_RoutesGet_SingleRoute(t *testing.T) {
576+ configSvc := inmocks.NewMockConfigService(t)
577+ authSvc := inmocks.NewMockAuthService(t)
578+ containerSvc := inmocks.NewMockContainerService(t)
579+
580+ handler := NewHandler(configSvc, authSvc, containerSvc, nil, "/tmp/env", testLogger())
581+
582+ configSvc.EXPECT().GetRoutes(mock.Anything).Return([]domain.Route{
583+ {Domain: "app.example.com", Image: "app:latest"},
584+ })
585+
586+ req := httptest.NewRequest("GET", "/admin/routes/app.example.com", nil)
587+ req = req.WithContext(ctxWithScopes("admin:routes:read"))
588+ rec := httptest.NewRecorder()
589+
590+ handler.ServeHTTP(rec, req)
591+
592+ assert.Equal(t, http.StatusOK, rec.Code)
593+
594+ var route domain.Route
595+ err := json.NewDecoder(rec.Body).Decode(&route)
596+ assert.NoError(t, err)
597+ assert.Equal(t, "app.example.com", route.Domain)
598+ assert.Equal(t, "app:latest", route.Image)
599+}
600+
601+func TestHandler_RoutesGet_NotFound(t *testing.T) {
602+ configSvc := inmocks.NewMockConfigService(t)
603+ authSvc := inmocks.NewMockAuthService(t)
604+ containerSvc := inmocks.NewMockContainerService(t)
605+
606+ handler := NewHandler(configSvc, authSvc, containerSvc, nil, "/tmp/env", testLogger())
607+
608+ configSvc.EXPECT().GetRoutes(mock.Anything).Return([]domain.Route{})
609+
610+ req := httptest.NewRequest("GET", "/admin/routes/nonexistent.example.com", nil)
611+ req = req.WithContext(ctxWithScopes("admin:routes:read"))
612+ rec := httptest.NewRecorder()
613+
614+ handler.ServeHTTP(rec, req)
615+
616+ assert.Equal(t, http.StatusNotFound, rec.Code)
617+}
618+
619+func TestHandler_RoutesPost_MissingFields(t *testing.T) {
620+ configSvc := inmocks.NewMockConfigService(t)
621+ authSvc := inmocks.NewMockAuthService(t)
622+ containerSvc := inmocks.NewMockContainerService(t)
623+
624+ handler := NewHandler(configSvc, authSvc, containerSvc, nil, "/tmp/env", testLogger())
625+
626+ tests := []struct {
627+ name string
628+ body string
629+ }{
630+ {
631+ name: "missing domain",
632+ body: `{"image": "app:latest"}`,
633+ },
634+ {
635+ name: "missing image",
636+ body: `{"domain": "app.example.com"}`,
637+ },
638+ {
639+ name: "empty object",
640+ body: `{}`,
641+ },
642+ }
643+
644+ for _, tt := range tests {
645+ t.Run(tt.name, func(t *testing.T) {
646+ req := httptest.NewRequest("POST", "/admin/routes", bytes.NewBufferString(tt.body))
647+ req = req.WithContext(ctxWithScopes("admin:routes:write"))
648+ rec := httptest.NewRecorder()
649+
650+ handler.ServeHTTP(rec, req)
651+
652+ assert.Equal(t, http.StatusBadRequest, rec.Code)
653+ })
654+ }
655+}
656+
657+func TestHandler_RoutesPut_MissingDomain(t *testing.T) {
658+ configSvc := inmocks.NewMockConfigService(t)
659+ authSvc := inmocks.NewMockAuthService(t)
660+ containerSvc := inmocks.NewMockContainerService(t)
661+
662+ handler := NewHandler(configSvc, authSvc, containerSvc, nil, "/tmp/env", testLogger())
663+
664+ req := httptest.NewRequest("PUT", "/admin/routes/", bytes.NewBufferString(`{"image": "app:latest"}`))
665+ req = req.WithContext(ctxWithScopes("admin:routes:write"))
666+ rec := httptest.NewRecorder()
667+
668+ handler.ServeHTTP(rec, req)
669+
670+ assert.Equal(t, http.StatusBadRequest, rec.Code)
671+}
672+
673+func TestHandler_RoutesDelete_MissingDomain(t *testing.T) {
674+ configSvc := inmocks.NewMockConfigService(t)
675+ authSvc := inmocks.NewMockAuthService(t)
676+ containerSvc := inmocks.NewMockContainerService(t)
677+
678+ handler := NewHandler(configSvc, authSvc, containerSvc, nil, "/tmp/env", testLogger())
679+
680+ req := httptest.NewRequest("DELETE", "/admin/routes/", nil)
681+ req = req.WithContext(ctxWithScopes("admin:routes:write"))
682+ rec := httptest.NewRecorder()
683+
684+ handler.ServeHTTP(rec, req)
685+
686+ assert.Equal(t, http.StatusBadRequest, rec.Code)
687+}
688+
689+func TestHandler_Secrets_MissingDomain(t *testing.T) {
690+ configSvc := inmocks.NewMockConfigService(t)
691+ authSvc := inmocks.NewMockAuthService(t)
692+ containerSvc := inmocks.NewMockContainerService(t)
693+
694+ handler := NewHandler(configSvc, authSvc, containerSvc, nil, "/tmp/env", testLogger())
695+
696+ req := httptest.NewRequest("GET", "/admin/secrets/", nil)
697+ req = req.WithContext(ctxWithScopes("admin:secrets:read"))
698+ rec := httptest.NewRecorder()
699+
700+ handler.ServeHTTP(rec, req)
701+
702+ assert.Equal(t, http.StatusBadRequest, rec.Code)
703+}
704+
705+func TestHandler_SecretsDelete_MissingKey(t *testing.T) {
706+ configSvc := inmocks.NewMockConfigService(t)
707+ authSvc := inmocks.NewMockAuthService(t)
708+ containerSvc := inmocks.NewMockContainerService(t)
709+
710+ handler := NewHandler(configSvc, authSvc, containerSvc, nil, "/tmp/env", testLogger())
711+
712+ req := httptest.NewRequest("DELETE", "/admin/secrets/app.example.com", nil)
713+ req = req.WithContext(ctxWithScopes("admin:secrets:write"))
714+ rec := httptest.NewRecorder()
715+
716+ handler.ServeHTTP(rec, req)
717+
718+ assert.Equal(t, http.StatusBadRequest, rec.Code)
719+}
720+
721+func TestHandler_MethodNotAllowed(t *testing.T) {
722+ configSvc := inmocks.NewMockConfigService(t)
723+ authSvc := inmocks.NewMockAuthService(t)
724+ containerSvc := inmocks.NewMockContainerService(t)
725+
726+ handler := NewHandler(configSvc, authSvc, containerSvc, nil, "/tmp/env", testLogger())
727+
728+ tests := []struct {
729+ method string
730+ path string
731+ }{
732+ {"DELETE", "/admin/status"},
733+ {"PUT", "/admin/status"},
734+ {"GET", "/admin/reload"},
735+ {"DELETE", "/admin/reload"},
736+ {"POST", "/admin/config"},
737+ {"DELETE", "/admin/config"},
738+ }
739+
740+ for _, tt := range tests {
741+ t.Run(tt.method+" "+tt.path, func(t *testing.T) {
742+ req := httptest.NewRequest(tt.method, tt.path, nil)
743+ req = req.WithContext(ctxWithScopes("admin:*:*"))
744+ rec := httptest.NewRecorder()
745+
746+ handler.ServeHTTP(rec, req)
747+
748+ assert.Equal(t, http.StatusMethodNotAllowed, rec.Code)
749+ })
750+ }
751+}
752+
753+func TestHandler_NotFound(t *testing.T) {
754+ configSvc := inmocks.NewMockConfigService(t)
755+ authSvc := inmocks.NewMockAuthService(t)
756+ containerSvc := inmocks.NewMockContainerService(t)
757+
758+ handler := NewHandler(configSvc, authSvc, containerSvc, nil, "/tmp/env", testLogger())
759+
760+ req := httptest.NewRequest("GET", "/admin/unknown", nil)
761+ req = req.WithContext(ctxWithScopes("admin:*:*"))
762+ rec := httptest.NewRecorder()
763+
764+ handler.ServeHTTP(rec, req)
765+
766+ assert.Equal(t, http.StatusNotFound, rec.Code)
767+}
A internal/adapters/in/http/admin/middleware.go +135 -0
@@ -0,0 +1,135 @@
1+package admin
2+
3+import (
4+ "context"
5+ "net/http"
6+ "strings"
7+
8+ "github.com/bnema/zerowrap"
9+
10+ "gordon/internal/boundaries/in"
11+ "gordon/internal/domain"
12+)
13+
14+// contextKey is a custom type for context keys to avoid collisions.
15+type contextKey string
16+
17+const (
18+ // ContextKeyScopes is the context key for storing token scopes.
19+ ContextKeyScopes contextKey = "admin_scopes"
20+ // ContextKeySubject is the context key for storing token subject.
21+ ContextKeySubject contextKey = "admin_subject"
22+)
23+
24+// AuthMiddleware creates middleware that validates admin API authentication.
25+func AuthMiddleware(authSvc in.AuthService, log zerowrap.Logger) func(http.Handler) http.Handler {
26+ return func(next http.Handler) http.Handler {
27+ return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
28+ ctx := r.Context()
29+
30+ // Check if auth is enabled
31+ if !authSvc.IsEnabled() {
32+ // Auth disabled, allow all requests
33+ next.ServeHTTP(w, r)
34+ return
35+ }
36+
37+ // Extract token from Authorization header
38+ auth := r.Header.Get("Authorization")
39+ if auth == "" {
40+ sendUnauthorized(w, "missing authorization header")
41+ return
42+ }
43+
44+ // Support both "Bearer <token>" and direct token
45+ token := auth
46+ if strings.HasPrefix(auth, "Bearer ") {
47+ token = strings.TrimPrefix(auth, "Bearer ")
48+ }
49+
50+ // Validate token
51+ claims, err := authSvc.ValidateToken(ctx, token)
52+ if err != nil {
53+ log.Warn().Err(err).Msg("invalid admin token")
54+ sendUnauthorized(w, "invalid token")
55+ return
56+ }
57+
58+ // Check if token has admin scopes
59+ hasAdminScope := false
60+ for _, scope := range claims.Scopes {
61+ if strings.HasPrefix(scope, domain.ScopeTypeAdmin+":") {
62+ hasAdminScope = true
63+ break
64+ }
65+ }
66+
67+ if !hasAdminScope {
68+ log.Warn().Str("subject", claims.Subject).Msg("token missing admin scopes")
69+ sendForbidden(w, "admin scope required")
70+ return
71+ }
72+
73+ // Add claims to context for downstream handlers
74+ ctx = context.WithValue(ctx, ContextKeyScopes, claims.Scopes)
75+ ctx = context.WithValue(ctx, ContextKeySubject, claims.Subject)
76+
77+ next.ServeHTTP(w, r.WithContext(ctx))
78+ })
79+ }
80+}
81+
82+// RequireScope creates middleware that checks for a specific admin scope.
83+func RequireScope(resource, action string) func(http.Handler) http.Handler {
84+ return func(next http.Handler) http.Handler {
85+ return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
86+ ctx := r.Context()
87+
88+ // Get scopes from context
89+ scopes, ok := ctx.Value(ContextKeyScopes).([]string)
90+ if !ok {
91+ sendForbidden(w, "no scopes in context")
92+ return
93+ }
94+
95+ // Check if user has required scope
96+ if !domain.HasAdminAccess(scopes, resource, action) {
97+ sendForbidden(w, "insufficient permissions")
98+ return
99+ }
100+
101+ next.ServeHTTP(w, r)
102+ })
103+ }
104+}
105+
106+// GetSubject retrieves the authenticated subject from the context.
107+func GetSubject(ctx context.Context) string {
108+ subject, _ := ctx.Value(ContextKeySubject).(string)
109+ return subject
110+}
111+
112+// GetScopes retrieves the token scopes from the context.
113+func GetScopes(ctx context.Context) []string {
114+ scopes, _ := ctx.Value(ContextKeyScopes).([]string)
115+ return scopes
116+}
117+
118+// HasAccess checks if the context has access to the given resource and action.
119+func HasAccess(ctx context.Context, resource, action string) bool {
120+ scopes := GetScopes(ctx)
121+ return domain.HasAdminAccess(scopes, resource, action)
122+}
123+
124+func sendUnauthorized(w http.ResponseWriter, message string) {
125+ w.Header().Set("Content-Type", "application/json")
126+ w.Header().Set("WWW-Authenticate", `Bearer realm="gordon-admin"`)
127+ w.WriteHeader(http.StatusUnauthorized)
128+ w.Write([]byte(`{"error":"` + message + `"}`))
129+}
130+
131+func sendForbidden(w http.ResponseWriter, message string) {
132+ w.Header().Set("Content-Type", "application/json")
133+ w.WriteHeader(http.StatusForbidden)
134+ w.Write([]byte(`{"error":"` + message + `"}`))
135+}
@@ -0,0 +1,135 @@
1+package admin
2+
3+import (
4+ "context"
5+ "net/http"
6+ "strings"
7+
8+ "github.com/bnema/zerowrap"
9+
10+ "gordon/internal/boundaries/in"
11+ "gordon/internal/domain"
12+)
13+
14+// contextKey is a custom type for context keys to avoid collisions.
15+type contextKey string
16+
17+const (
18+ // ContextKeyScopes is the context key for storing token scopes.
19+ ContextKeyScopes contextKey = "admin_scopes"
20+ // ContextKeySubject is the context key for storing token subject.
21+ ContextKeySubject contextKey = "admin_subject"
22+)
23+
24+// AuthMiddleware creates middleware that validates admin API authentication.
25+func AuthMiddleware(authSvc in.AuthService, log zerowrap.Logger) func(http.Handler) http.Handler {
26+ return func(next http.Handler) http.Handler {
27+ return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
28+ ctx := r.Context()
29+
30+ // Check if auth is enabled
31+ if !authSvc.IsEnabled() {
32+ // Auth disabled, allow all requests
33+ next.ServeHTTP(w, r)
34+ return
35+ }
36+
37+ // Extract token from Authorization header
38+ auth := r.Header.Get("Authorization")
39+ if auth == "" {
40+ sendUnauthorized(w, "missing authorization header")
41+ return
42+ }
43+
44+ // Support both "Bearer <token>" and direct token
45+ token := auth
46+ if strings.HasPrefix(auth, "Bearer ") {
47+ token = strings.TrimPrefix(auth, "Bearer ")
48+ }
49+
50+ // Validate token
51+ claims, err := authSvc.ValidateToken(ctx, token)
52+ if err != nil {
53+ log.Warn().Err(err).Msg("invalid admin token")
54+ sendUnauthorized(w, "invalid token")
55+ return
56+ }
57+
58+ // Check if token has admin scopes
59+ hasAdminScope := false
60+ for _, scope := range claims.Scopes {
61+ if strings.HasPrefix(scope, domain.ScopeTypeAdmin+":") {
62+ hasAdminScope = true
63+ break
64+ }
65+ }
66+
67+ if !hasAdminScope {
68+ log.Warn().Str("subject", claims.Subject).Msg("token missing admin scopes")
69+ sendForbidden(w, "admin scope required")
70+ return
71+ }
72+
73+ // Add claims to context for downstream handlers
74+ ctx = context.WithValue(ctx, ContextKeyScopes, claims.Scopes)
75+ ctx = context.WithValue(ctx, ContextKeySubject, claims.Subject)
76+
77+ next.ServeHTTP(w, r.WithContext(ctx))
78+ })
79+ }
80+}
81+
82+// RequireScope creates middleware that checks for a specific admin scope.
83+func RequireScope(resource, action string) func(http.Handler) http.Handler {
84+ return func(next http.Handler) http.Handler {
85+ return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
86+ ctx := r.Context()
87+
88+ // Get scopes from context
89+ scopes, ok := ctx.Value(ContextKeyScopes).([]string)
90+ if !ok {
91+ sendForbidden(w, "no scopes in context")
92+ return
93+ }
94+
95+ // Check if user has required scope
96+ if !domain.HasAdminAccess(scopes, resource, action) {
97+ sendForbidden(w, "insufficient permissions")
98+ return
99+ }
100+
101+ next.ServeHTTP(w, r)
102+ })
103+ }
104+}
105+
106+// GetSubject retrieves the authenticated subject from the context.
107+func GetSubject(ctx context.Context) string {
108+ subject, _ := ctx.Value(ContextKeySubject).(string)
109+ return subject
110+}
111+
112+// GetScopes retrieves the token scopes from the context.
113+func GetScopes(ctx context.Context) []string {
114+ scopes, _ := ctx.Value(ContextKeyScopes).([]string)
115+ return scopes
116+}
117+
118+// HasAccess checks if the context has access to the given resource and action.
119+func HasAccess(ctx context.Context, resource, action string) bool {
120+ scopes := GetScopes(ctx)
121+ return domain.HasAdminAccess(scopes, resource, action)
122+}
123+
124+func sendUnauthorized(w http.ResponseWriter, message string) {
125+ w.Header().Set("Content-Type", "application/json")
126+ w.Header().Set("WWW-Authenticate", `Bearer realm="gordon-admin"`)
127+ w.WriteHeader(http.StatusUnauthorized)
128+ w.Write([]byte(`{"error":"` + message + `"}`))
129+}
130+
131+func sendForbidden(w http.ResponseWriter, message string) {
132+ w.Header().Set("Content-Type", "application/json")
133+ w.WriteHeader(http.StatusForbidden)
134+ w.Write([]byte(`{"error":"` + message + `"}`))
135+}
A internal/adapters/in/http/admin/secrets.go +156 -0
@@ -0,0 +1,156 @@
1+package admin
2+
3+import (
4+ "bufio"
5+ "fmt"
6+ "os"
7+ "path/filepath"
8+ "strings"
9+)
10+
11+// domainToEnvFile converts a domain to its env file path.
12+// Example: app.mydomain.com -> app_mydomain_com.env
13+// Must match the naming convention in envloader.FileLoader.getEnvFilePath
14+func (h *Handler) domainToEnvFile(domain string) string {
15+ // Replace special characters with underscores (matches envloader)
16+ filename := strings.ReplaceAll(domain, ".", "_")
17+ filename = strings.ReplaceAll(filename, ":", "_")
18+ filename = strings.ReplaceAll(filename, "/", "_")
19+ return filepath.Join(h.envDir, filename+".env")
20+}
21+
22+// listSecrets returns the list of secret keys for a domain (not values).
23+func (h *Handler) listSecrets(domain string) ([]string, error) {
24+ envFile := h.domainToEnvFile(domain)
25+
26+ file, err := os.Open(envFile)
27+ if err != nil {
28+ if os.IsNotExist(err) {
29+ return []string{}, nil
30+ }
31+ return nil, fmt.Errorf("failed to open env file: %w", err)
32+ }
33+ defer file.Close()
34+
35+ var keys []string
36+ scanner := bufio.NewScanner(file)
37+ for scanner.Scan() {
38+ line := strings.TrimSpace(scanner.Text())
39+ // Skip empty lines and comments
40+ if line == "" || strings.HasPrefix(line, "#") {
41+ continue
42+ }
43+ // Extract key from KEY=value
44+ if idx := strings.Index(line, "="); idx > 0 {
45+ keys = append(keys, line[:idx])
46+ }
47+ }
48+
49+ if err := scanner.Err(); err != nil {
50+ return nil, fmt.Errorf("failed to read env file: %w", err)
51+ }
52+
53+ return keys, nil
54+}
55+
56+// getSecrets returns all secrets for a domain as a map.
57+func (h *Handler) getSecrets(domain string) (map[string]string, error) {
58+ envFile := h.domainToEnvFile(domain)
59+
60+ file, err := os.Open(envFile)
61+ if err != nil {
62+ if os.IsNotExist(err) {
63+ return map[string]string{}, nil
64+ }
65+ return nil, fmt.Errorf("failed to open env file: %w", err)
66+ }
67+ defer file.Close()
68+
69+ secrets := make(map[string]string)
70+ scanner := bufio.NewScanner(file)
71+ for scanner.Scan() {
72+ line := strings.TrimSpace(scanner.Text())
73+ // Skip empty lines and comments
74+ if line == "" || strings.HasPrefix(line, "#") {
75+ continue
76+ }
77+ // Parse KEY=value
78+ if idx := strings.Index(line, "="); idx > 0 {
79+ key := line[:idx]
80+ value := line[idx+1:]
81+ secrets[key] = value
82+ }
83+ }
84+
85+ if err := scanner.Err(); err != nil {
86+ return nil, fmt.Errorf("failed to read env file: %w", err)
87+ }
88+
89+ return secrets, nil
90+}
91+
92+// setSecrets sets multiple secrets for a domain, merging with existing.
93+func (h *Handler) setSecrets(domain string, newSecrets map[string]string) error {
94+ // Ensure env directory exists
95+ if err := os.MkdirAll(h.envDir, 0700); err != nil {
96+ return fmt.Errorf("failed to create env directory: %w", err)
97+ }
98+
99+ // Read existing secrets
100+ existing, err := h.getSecrets(domain)
101+ if err != nil {
102+ return err
103+ }
104+
105+ // Merge new secrets with existing
106+ for key, value := range newSecrets {
107+ existing[key] = value
108+ }
109+
110+ // Write back
111+ return h.writeSecrets(domain, existing)
112+}
113+
114+// deleteSecret removes a secret from a domain's env file.
115+func (h *Handler) deleteSecret(domain, key string) error {
116+ // Read existing secrets
117+ existing, err := h.getSecrets(domain)
118+ if err != nil {
119+ return err
120+ }
121+
122+ // Remove the key
123+ delete(existing, key)
124+
125+ // Write back
126+ return h.writeSecrets(domain, existing)
127+}
128+
129+// writeSecrets writes all secrets to the domain's env file.
130+func (h *Handler) writeSecrets(domain string, secrets map[string]string) error {
131+ envFile := h.domainToEnvFile(domain)
132+
133+ // Create/truncate file with secure permissions
134+ file, err := os.OpenFile(envFile, os.O_WRONLY|os.O_CREATE|os.O_TRUNC, 0600)
135+ if err != nil {
136+ return fmt.Errorf("failed to create env file: %w", err)
137+ }
138+ defer file.Close()
139+
140+ // Write header comment
141+ if _, err := fmt.Fprintf(file, "# Environment variables for %s\n", domain); err != nil {
142+ return fmt.Errorf("failed to write header: %w", err)
143+ }
144+ if _, err := fmt.Fprintf(file, "# Managed by Gordon admin API\n\n"); err != nil {
145+ return fmt.Errorf("failed to write header: %w", err)
146+ }
147+
148+ // Write each secret
149+ for key, value := range secrets {
150+ if _, err := fmt.Fprintf(file, "%s=%s\n", key, value); err != nil {
151+ return fmt.Errorf("failed to write secret %s: %w", key, err)
152+ }
153+ }
154+
155+ return nil
156+}
@@ -0,0 +1,156 @@
1+package admin
2+
3+import (
4+ "bufio"
5+ "fmt"
6+ "os"
7+ "path/filepath"
8+ "strings"
9+)
10+
11+// domainToEnvFile converts a domain to its env file path.
12+// Example: app.mydomain.com -> app_mydomain_com.env
13+// Must match the naming convention in envloader.FileLoader.getEnvFilePath
14+func (h *Handler) domainToEnvFile(domain string) string {
15+ // Replace special characters with underscores (matches envloader)
16+ filename := strings.ReplaceAll(domain, ".", "_")
17+ filename = strings.ReplaceAll(filename, ":", "_")
18+ filename = strings.ReplaceAll(filename, "/", "_")
19+ return filepath.Join(h.envDir, filename+".env")
20+}
21+
22+// listSecrets returns the list of secret keys for a domain (not values).
23+func (h *Handler) listSecrets(domain string) ([]string, error) {
24+ envFile := h.domainToEnvFile(domain)
25+
26+ file, err := os.Open(envFile)
27+ if err != nil {
28+ if os.IsNotExist(err) {
29+ return []string{}, nil
30+ }
31+ return nil, fmt.Errorf("failed to open env file: %w", err)
32+ }
33+ defer file.Close()
34+
35+ var keys []string
36+ scanner := bufio.NewScanner(file)
37+ for scanner.Scan() {
38+ line := strings.TrimSpace(scanner.Text())
39+ // Skip empty lines and comments
40+ if line == "" || strings.HasPrefix(line, "#") {
41+ continue
42+ }
43+ // Extract key from KEY=value
44+ if idx := strings.Index(line, "="); idx > 0 {
45+ keys = append(keys, line[:idx])
46+ }
47+ }
48+
49+ if err := scanner.Err(); err != nil {
50+ return nil, fmt.Errorf("failed to read env file: %w", err)
51+ }
52+
53+ return keys, nil
54+}
55+
56+// getSecrets returns all secrets for a domain as a map.
57+func (h *Handler) getSecrets(domain string) (map[string]string, error) {
58+ envFile := h.domainToEnvFile(domain)
59+
60+ file, err := os.Open(envFile)
61+ if err != nil {
62+ if os.IsNotExist(err) {
63+ return map[string]string{}, nil
64+ }
65+ return nil, fmt.Errorf("failed to open env file: %w", err)
66+ }
67+ defer file.Close()
68+
69+ secrets := make(map[string]string)
70+ scanner := bufio.NewScanner(file)
71+ for scanner.Scan() {
72+ line := strings.TrimSpace(scanner.Text())
73+ // Skip empty lines and comments
74+ if line == "" || strings.HasPrefix(line, "#") {
75+ continue
76+ }
77+ // Parse KEY=value
78+ if idx := strings.Index(line, "="); idx > 0 {
79+ key := line[:idx]
80+ value := line[idx+1:]
81+ secrets[key] = value
82+ }
83+ }
84+
85+ if err := scanner.Err(); err != nil {
86+ return nil, fmt.Errorf("failed to read env file: %w", err)
87+ }
88+
89+ return secrets, nil
90+}
91+
92+// setSecrets sets multiple secrets for a domain, merging with existing.
93+func (h *Handler) setSecrets(domain string, newSecrets map[string]string) error {
94+ // Ensure env directory exists
95+ if err := os.MkdirAll(h.envDir, 0700); err != nil {
96+ return fmt.Errorf("failed to create env directory: %w", err)
97+ }
98+
99+ // Read existing secrets
100+ existing, err := h.getSecrets(domain)
101+ if err != nil {
102+ return err
103+ }
104+
105+ // Merge new secrets with existing
106+ for key, value := range newSecrets {
107+ existing[key] = value
108+ }
109+
110+ // Write back
111+ return h.writeSecrets(domain, existing)
112+}
113+
114+// deleteSecret removes a secret from a domain's env file.
115+func (h *Handler) deleteSecret(domain, key string) error {
116+ // Read existing secrets
117+ existing, err := h.getSecrets(domain)
118+ if err != nil {
119+ return err
120+ }
121+
122+ // Remove the key
123+ delete(existing, key)
124+
125+ // Write back
126+ return h.writeSecrets(domain, existing)
127+}
128+
129+// writeSecrets writes all secrets to the domain's env file.
130+func (h *Handler) writeSecrets(domain string, secrets map[string]string) error {
131+ envFile := h.domainToEnvFile(domain)
132+
133+ // Create/truncate file with secure permissions
134+ file, err := os.OpenFile(envFile, os.O_WRONLY|os.O_CREATE|os.O_TRUNC, 0600)
135+ if err != nil {
136+ return fmt.Errorf("failed to create env file: %w", err)
137+ }
138+ defer file.Close()
139+
140+ // Write header comment
141+ if _, err := fmt.Fprintf(file, "# Environment variables for %s\n", domain); err != nil {
142+ return fmt.Errorf("failed to write header: %w", err)
143+ }
144+ if _, err := fmt.Fprintf(file, "# Managed by Gordon admin API\n\n"); err != nil {
145+ return fmt.Errorf("failed to write header: %w", err)
146+ }
147+
148+ // Write each secret
149+ for key, value := range secrets {
150+ if _, err := fmt.Fprintf(file, "%s=%s\n", key, value); err != nil {
151+ return fmt.Errorf("failed to write secret %s: %w", key, err)
152+ }
153+ }
154+
155+ return nil
156+}

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