diff --git a/server/cmd/main.go b/server/cmd/main.go index 0211c20..818cf8b 100644 --- a/server/cmd/main.go +++ b/server/cmd/main.go @@ -234,6 +234,10 @@ func serve() { r.Use(corsMiddleware()) api.RegisterRoutes(r) + if err := api.AssertScopeMapComplete(r); err != nil { + log.Fatalf("api scope map: %v", err) + } + srv := &http.Server{Addr: ":8080", Handler: r} go func() { log.Println("REST server listening on :8080") diff --git a/server/internal/api/handlers.go b/server/internal/api/handlers.go index 02ea004..d656af9 100644 --- a/server/internal/api/handlers.go +++ b/server/internal/api/handlers.go @@ -42,6 +42,10 @@ func RegisterRoutes(r *gin.Engine) { apiGroup := r.Group("/api") apiGroup.Use(auth.Middleware()) + // Scope enforcement sits between authentication and the licence gate, and + // no-ops for cookie sessions. It is mounted here rather than per route so + // a route added later is covered by where it lives, not by memory. + apiGroup.Use(RequireScopes()) // Deny by default: every non-GET route under /api is gated unless it is on // the exemption list in licence.go. A route added later is covered because // of where it is mounted, not because someone remembered. diff --git a/server/internal/api/scopes.go b/server/internal/api/scopes.go new file mode 100644 index 0000000..ef7cc65 --- /dev/null +++ b/server/internal/api/scopes.go @@ -0,0 +1,209 @@ +package api + +import ( + "fmt" + "net/http" + "sort" + "strings" + + "gitea.hostxtra.co.uk/mrhid6/vantage/server/internal/auth" + "gitea.hostxtra.co.uk/mrhid6/vantage/server/internal/services" + "github.com/gin-gonic/gin" +) + +// routeScopes maps a registered gin route — " " — to +// the scope an API token must hold to reach it. +// +// It is keyed on the route pattern rather than declared per route with a +// decorator, because a route registered without a decorator would be +// unguarded. AssertScopeMapComplete refuses to boot if any /api route is +// missing here, so the failure lands at deploy rather than as a surprise 403 +// in production. +// +// GET is read, everything else is write. The exceptions are written out rather +// than derived, because two of them are not obvious: reading a private key is +// still reading a key, and reading a container's logs is a write-level action +// because container output is arbitrary and cannot be masked. +var routeScopes = map[string]string{ + "GET /api/license": "settings:read", + "POST /api/license": "settings:write", + + "GET /api/servers": "servers:read", + "GET /api/servers/tags": "servers:read", + "POST /api/servers": "servers:write", + "GET /api/servers/new": "servers:write", + "POST /api/servers/new": "servers:write", + "GET /api/servers/:id": "servers:read", + "DELETE /api/servers/:id": "servers:write", + "POST /api/servers/:id/generate-key": "keys:write", + "POST /api/servers/:id/update-agent": "servers:write", + "POST /api/servers/:id/apply-updates": "servers:write", + "PUT /api/servers/:id/tags": "servers:write", + + "GET /api/agent/latest-version": "servers:read", + "GET /api/audit": "settings:read", + + "GET /api/settings": "settings:read", + "PUT /api/settings": "settings:write", + "POST /api/settings/secrets-token": "settings:write", + + "GET /api/secrets": "secrets:read", + "POST /api/secrets": "secrets:write", + "GET /api/secrets/:group": "secrets:read", + "PUT /api/secrets/:group": "secrets:write", + "POST /api/secrets/:group/reveal": "secrets:read", + "DELETE /api/secrets/:group": "secrets:write", + "DELETE /api/secrets/:group/:key": "secrets:write", + + "GET /api/keys": "keys:read", + "POST /api/keys": "keys:write", + "GET /api/keys/:id": "keys:read", + "GET /api/keys/:id/private-key": "keys:read", + "DELETE /api/keys/:id": "keys:write", + "POST /api/keys/:id/assign": "keys:write", + "DELETE /api/keys/:id/assign/:serverId": "keys:write", + + "POST /api/console/connect": "servers:write", + "GET /api/console/tunnel": "servers:write", + + // Workflow, step and run routes, registered by registerWorkflowRoutes. + "GET /api/steps": "workflows:read", + "POST /api/steps": "workflows:write", + "PUT /api/steps/:id": "workflows:write", + "DELETE /api/steps/:id": "workflows:write", + "GET /api/steps/:id/export": "workflows:read", + "POST /api/steps/import": "workflows:write", + "POST /api/steps/seed-defaults": "workflows:write", + "GET /api/steps/usage": "workflows:read", + "POST /api/steps/parse": "workflows:write", + + "GET /api/workflows": "workflows:read", + "POST /api/workflows": "workflows:write", + "GET /api/workflows/:id": "workflows:read", + "PUT /api/workflows/:id": "workflows:write", + "DELETE /api/workflows/:id": "workflows:write", + "POST /api/workflows/:id/run": "workflows:write", + "GET /api/workflows/:id/runs": "workflows:read", + "PUT /api/workflows/:id/schedule": "workflows:write", + "GET /api/workflows/:id/schedule/preview": "workflows:read", + + "GET /api/runs/:runId": "workflows:read", + "POST /api/runs/:runId/cancel": "workflows:write", + "GET /api/runs/:runId/servers/:serverId/logs": "workflows:read", + "GET /api/runs/:runId/servers/:serverId/logs/stream": "workflows:read", + + // Monitor and incident routes, registered by registerMonitorRoutes. + "GET /api/monitors": "monitors:read", + "POST /api/monitors": "monitors:write", + "GET /api/monitors/:id": "monitors:read", + "PUT /api/monitors/:id": "monitors:write", + "DELETE /api/monitors/:id": "monitors:write", + "GET /api/monitors/:id/incidents": "monitors:read", + "GET /api/monitors/:id/uptime": "monitors:read", + + // Channel routes, registered by registerChannelRoutes. Channels exist to + // serve alerts, so they share the monitors scope rather than getting their + // own resource. + "GET /api/channels": "monitors:read", + "POST /api/channels": "monitors:write", + "PUT /api/channels/:id": "monitors:write", + "DELETE /api/channels/:id": "monitors:write", + "POST /api/channels/:id/test": "monitors:write", + + // Instance user management and SSO configuration live on the /settings + // page in web/ (the Access group), so both share the settings scope. + "GET /api/instance/users": "settings:read", + "POST /api/instance/users": "settings:write", + "PUT /api/instance/users/:id/role": "settings:write", + "DELETE /api/instance/users/:id": "settings:write", + + "GET /api/auth/providers": "settings:read", + "POST /api/auth/providers": "settings:write", + "PUT /api/auth/providers/:id": "settings:write", + "DELETE /api/auth/providers/:id": "settings:write", + "POST /api/auth/providers/:id/test": "settings:write", + "POST /api/auth/providers/:id/ack-notice": "settings:write", + "GET /api/auth/presets": "settings:read", + + "GET /api/vulnerabilities": "vulns:read", + "GET /api/vulnerabilities/summary": "vulns:read", + "POST /api/vulnerabilities/rescan": "vulns:write", + "POST /api/vulnerabilities/:id/accept": "vulns:write", + "DELETE /api/vulnerabilities/:id/accept": "vulns:write", + "GET /api/servers/:id/vulnerabilities": "vulns:read", + "GET /api/servers/:id/packages": "vulns:read", + "GET /api/packages/search": "vulns:read", + "GET /api/vuln-rules": "vulns:read", + "POST /api/vuln-rules": "vulns:write", + "PUT /api/vuln-rules/:id": "vulns:write", + "DELETE /api/vuln-rules/:id": "vulns:write", + + "GET /api/workloads": "workloads:read", + "GET /api/servers/:id/workloads": "workloads:read", + "POST /api/servers/:id/workloads/refresh": "workloads:read", + "POST /api/servers/:id/workloads/:wid/action": "workloads:write", + "GET /api/servers/:id/workloads/:wid/logs": "workloads:write", + + "GET /api/tokens": "settings:read", + "POST /api/tokens": "settings:write", + "DELETE /api/tokens/:id": "settings:write", +} + +// RequireScopes enforces routeScopes for token-authenticated requests and does +// nothing at all for cookie sessions, whose authority is their role. +func RequireScopes() gin.HandlerFunc { + return func(c *gin.Context) { + if !auth.IsToken(c) { + c.Next() + return + } + + key := c.Request.Method + " " + c.FullPath() + required, ok := routeScopes[key] + if !ok { + // Fail closed. An unmapped route reached by a token is a route + // nobody decided the authority for. + c.AbortWithStatusJSON(http.StatusForbidden, gin.H{ + "error": "this endpoint is not available to API tokens", + "code": "scope_unmapped", + }) + return + } + + if !services.ScopeSatisfied(auth.Scopes(c), required) { + c.AbortWithStatusJSON(http.StatusForbidden, gin.H{ + "error": fmt.Sprintf("token is missing the %q scope", required), + "code": "scope_missing", + "required_scope": required, + }) + return + } + c.Next() + } +} + +// AssertScopeMapComplete fails boot when a registered /api route has no scope. +// +// Without it, adding a route silently makes it unreachable by every token, and +// the report arrives as a customer asking why their script gets 403. +func AssertScopeMapComplete(r *gin.Engine) error { + var missing []string + for _, route := range r.Routes() { + if !strings.HasPrefix(route.Path, "/api/") { + continue + } + // The ESO endpoint keeps its own bearer scheme and is deliberately + // outside the token vocabulary. + if route.Path == "/api/secrets/:group/values" { + continue + } + if _, ok := routeScopes[route.Method+" "+route.Path]; !ok { + missing = append(missing, route.Method+" "+route.Path) + } + } + if len(missing) > 0 { + sort.Strings(missing) + return fmt.Errorf("routes missing from the API token scope map: %s", strings.Join(missing, ", ")) + } + return nil +}