feat: Enforce API token scopes from the route map

Keyed on the registered gin route pattern rather than a per-route
decorator, because a route registered without a decorator would be
unguarded. An unmapped route reached by a token is a 403, and a boot-time
check refuses to start when any /api route is missing, so the failure
lands at deploy rather than as a customer's surprise 403.
This commit is contained in:
2026-08-12 14:40:00 +00:00
parent 2f60b81962
commit be4f488db3
3 changed files with 217 additions and 0 deletions
+4
View File
@@ -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")
+4
View File
@@ -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.
+209
View File
@@ -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 — "<METHOD> <full path pattern>" — 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
}