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:
@@ -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")
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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
|
||||
}
|
||||
Reference in New Issue
Block a user