1package admin
2
3import (
4 "context"
5 "net/http"
6 "path"
7 "strings"
8
9 "github.com/bnema/gordon/internal/domain"
10)
11
12// LocalAuthoritySubject is the principal carried on requests served over the
13// owner-only admin Unix socket. Filesystem access to the 0600 socket is what
14// establishes this identity; the scopes below still gate each route.
15const LocalAuthoritySubject = "local-owner"
16
17// localAuthorityScopes are the least-privilege scopes required by CLI
18// commands which share the local and remote ControlPlane. The path-and-method
19// allowlist below is the second boundary: config:write, for example, permits
20// reload and prune operations but cannot reach unrelated config mutations.
21var localAuthorityScopes = []string{
22 domain.AdminScopeApps(domain.AdminActionRead, domain.AdminActionWrite),
23 domain.AdminScopeStatus(domain.AdminActionRead),
24 domain.AdminScopeConfig(domain.AdminActionRead, domain.AdminActionWrite),
25 domain.AdminScopeLogs(domain.AdminActionRead),
26 domain.AdminScopeVolumes(domain.AdminActionRead, domain.AdminActionWrite),
27}
28
29// LocalAuthority returns the handler for the owner-only admin Unix socket.
30// Filesystem ownership authenticates the caller; this wrapper then restricts
31// that identity to the canonical endpoints used by local CLI commands.
32func (h *Handler) LocalAuthority() http.Handler {
33 return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
34 // Classify and dispatch on one canonical path: the socket bypasses the
35 // ServeMux that would otherwise clean "." and ".." segments, so a
36 // non-canonical path must never be allowlisted here and interpreted
37 // differently by the router below.
38 if !strings.HasPrefix(r.URL.Path, "/admin/") && r.URL.Path != "/admin" {
39 sendForbidden(w, "local administration is limited to app endpoints")
40 return
41 }
42 trimmed := strings.TrimPrefix(r.URL.Path, "/admin")
43 cleaned := path.Clean(trimmed)
44 if cleaned != trimmed || !localPathAllowed(r.Method, cleaned) {
45 sendForbidden(w, "endpoint is not available to the local owner")
46 return
47 }
48
49 scopes := make([]string, len(localAuthorityScopes))
50 copy(scopes, localAuthorityScopes)
51
52 ctx := context.WithValue(r.Context(), domain.ContextKeyScopes, scopes)
53 ctx = context.WithValue(ctx, domain.ContextKeySubject, LocalAuthoritySubject)
54 h.handleAdminRoutes(w, r.WithContext(ctx))
55 })
56}
57
58func localPathAllowed(method, cleanedPath string) bool {
59 if localExactPathAllowed(method, cleanedPath) {
60 return true
61 }
62 parts := strings.Split(strings.TrimPrefix(cleanedPath, "/"), "/")
63 prefixRules := map[string]func(string, []string) bool{
64 "apps": localAppPathAllowed,
65 "backups": localBackupPathAllowed,
66 "logs": localLogPathAllowed,
67 "tags": localTagPathAllowed,
68 }
69 rule, ok := prefixRules[parts[0]]
70 return ok && rule(method, parts)
71}
72
73func localLogPathAllowed(method string, parts []string) bool {
74 return (len(parts) == 1 || len(parts) == 3 && parts[1] != "" && parts[2] != "") && method == http.MethodGet
75}
76
77func localTagPathAllowed(method string, parts []string) bool {
78 return len(parts) == 2 && parts[1] != "" && method == http.MethodGet
79}
80
81func localExactPathAllowed(method, cleanedPath string) bool {
82 allowed := map[string]string{
83 "/status": http.MethodGet,
84 "/tls/status": http.MethodGet,
85 "/traffic/status": http.MethodGet,
86 "/config": http.MethodGet,
87 "/networks": http.MethodGet,
88 "/volumes": http.MethodGet,
89 "/volumes/prune": http.MethodPost,
90 "/images": http.MethodGet,
91 "/images/prune": http.MethodPost,
92 "/reload": http.MethodPost,
93 "/backups": http.MethodGet,
94 "/backups/status": http.MethodGet,
95 "/backups/volumes": http.MethodGet,
96 "/backups/volumes/status": http.MethodGet,
97 }
98 return allowed[cleanedPath] == method
99}
100
101func localAppPathAllowed(method string, parts []string) bool {
102 appRoutes := map[string]string{
103 "": http.MethodGet,
104 "apply": http.MethodPost,
105 "{app}": http.MethodGet,
106 "{app}/diff": http.MethodGet,
107 "{app}/deploy": http.MethodPost,
108 "{app}/restart": http.MethodPost,
109 "{app}/stop": http.MethodPost,
110 "{app}/start": http.MethodPost,
111 "{app}/remove": http.MethodPost,
112 "{app}/secrets": http.MethodGet,
113 "{app}/secrets/set": http.MethodPost,
114 "{app}/secrets/delete": http.MethodPost,
115 "{app}/operations/by-key/{key}": http.MethodGet,
116 }
117 return appRoutes[localAppRouteShape(parts)] == method
118}
119
120func localAppRouteShape(parts []string) string {
121 if len(parts) == 1 {
122 return ""
123 }
124 if len(parts) == 2 && parts[1] == "apply" {
125 return "apply"
126 }
127 if len(parts) < 2 || parts[1] == "" {
128 return "invalid"
129 }
130 shaped := append([]string{"{app}"}, parts[2:]...)
131 if len(shaped) == 4 && shaped[1] == "operations" && shaped[2] == "by-key" && shaped[3] != "" {
132 shaped[3] = "{key}"
133 }
134 return strings.Join(shaped, "/")
135}
136
137func localBackupPathAllowed(method string, parts []string) bool {
138 if len(parts) == 2 {
139 if parts[1] == "volumes" {
140 return method == http.MethodPost
141 }
142 return parts[1] != "" && parts[1] != "status" && (method == http.MethodGet || method == http.MethodPost)
143 }
144 if len(parts) == 3 && parts[1] == "volumes" && parts[2] != "" && parts[2] != "status" {
145 return method == http.MethodGet || method == http.MethodPost
146 }
147 return false
148}