diff --git a/cmd/api/main.go b/cmd/api/main.go index 5786c50a..63fdc284 100644 --- a/cmd/api/main.go +++ b/cmd/api/main.go @@ -31,6 +31,7 @@ import ( "github.com/kernel/hypeman/lib/oapi" "github.com/kernel/hypeman/lib/otel" "github.com/kernel/hypeman/lib/registry" + "github.com/kernel/hypeman/lib/scopes" "github.com/kernel/hypeman/lib/vmm" nethttpmiddleware "github.com/oapi-codegen/nethttp-middleware" "github.com/riandyrn/otelchi" @@ -285,6 +286,7 @@ func run() error { mw.InjectLogger(logger), mw.AccessLogger(accessLogger), mw.JwtAuth(app.Config.JwtSecret), + scopes.RequireScope(scopes.InstanceWrite), mw.ResolveResource(app.ApiService.NewResolvers(), api.ResolverErrorResponder), ).Get("/instances/{id}/exec", app.ApiService.ExecHandler) @@ -296,6 +298,7 @@ func run() error { mw.InjectLogger(logger), mw.AccessLogger(accessLogger), mw.JwtAuth(app.Config.JwtSecret), + scopes.RequireScope(scopes.InstanceWrite), mw.ResolveResource(app.ApiService.NewResolvers(), api.ResolverErrorResponder), ).Get("/instances/{id}/cp", app.ApiService.CpHandler) @@ -366,6 +369,9 @@ func run() error { } r.Use(nethttpmiddleware.OapiRequestValidatorWithOptions(spec, validatorOptions)) + // Scoped permissions — enforce per-route scope requirements + r.Use(scopes.Middleware()) + // Resource resolver middleware - resolves IDs/names/prefixes before handlers // Enriches context with resolved resource and logger with resolved ID r.Use(mw.ResolveResource(app.ApiService.NewResolvers(), api.ResolverErrorResponder)) diff --git a/cmd/gen-jwt/main.go b/cmd/gen-jwt/main.go index 24a71f0f..0dbccdcd 100644 --- a/cmd/gen-jwt/main.go +++ b/cmd/gen-jwt/main.go @@ -8,6 +8,7 @@ import ( "github.com/golang-jwt/jwt/v5" "github.com/kernel/hypeman/cmd/api/config" + "github.com/kernel/hypeman/lib/scopes" "github.com/knadh/koanf/parsers/yaml" "github.com/knadh/koanf/providers/file" "github.com/knadh/koanf/v2" @@ -48,8 +49,19 @@ func getJWTSecret() string { func main() { userID := flag.String("user-id", "test-user", "User ID to include in the JWT token") duration := flag.Duration("duration", 24*time.Hour, "Token validity duration (e.g., 24h, 720h, 8760h)") + scopesFlag := flag.String("scopes", "", "Comma-separated list of permission scopes (e.g., instance:read,instance:write). Empty means full access.") + listScopes := flag.Bool("list-scopes", false, "List all available scopes and exit") flag.Parse() + if *listScopes { + fmt.Println("Available scopes:") + for _, s := range scopes.AllScopes { + fmt.Printf(" %s\n", s) + } + fmt.Println(" * (wildcard — grants all permissions)") + os.Exit(0) + } + jwtSecret := getJWTSecret() if jwtSecret == "" { fmt.Fprintf(os.Stderr, "Error: JWT_SECRET not found.\n") @@ -65,6 +77,18 @@ func main() { "iat": time.Now().Unix(), "exp": time.Now().Add(*duration).Unix(), } + + // Add scoped permissions if specified + if *scopesFlag != "" { + parsed, err := scopes.ParseScopes(*scopesFlag) + if err != nil { + fmt.Fprintf(os.Stderr, "Error: %v\n", err) + fmt.Fprintf(os.Stderr, "Run with -list-scopes to see available scopes.\n") + os.Exit(1) + } + claims["permissions"] = scopes.ScopeStrings(parsed) + } + token := jwt.NewWithClaims(jwt.SigningMethodHS256, claims) tokenString, err := token.SignedString([]byte(jwtSecret)) if err != nil { diff --git a/lib/middleware/oapi_auth.go b/lib/middleware/oapi_auth.go index 25bcd63a..6b677f3e 100644 --- a/lib/middleware/oapi_auth.go +++ b/lib/middleware/oapi_auth.go @@ -13,6 +13,7 @@ import ( "github.com/golang-jwt/jwt/v5" "github.com/gorilla/mux" "github.com/kernel/hypeman/lib/logger" + "github.com/kernel/hypeman/lib/scopes" ) // errRepoNotAllowed is returned when a valid token doesn't have access to the requested repository. @@ -120,6 +121,10 @@ func OapiAuthenticationFunc(jwtSecret string) openapi3filter.AuthenticationFunc // Update the context with user ID newCtx := context.WithValue(ctx, userIDKey, userID) + // Extract scoped permissions from the "permissions" claim. + // Tokens without this claim get full access (backward compatibility). + newCtx = extractPermissions(newCtx, claims) + // Update the request with the new context *input.RequestValidationInput.Request = *input.RequestValidationInput.Request.WithContext(newCtx) @@ -480,8 +485,40 @@ func JwtAuth(jwtSecret string) func(http.Handler) http.Handler { // Update the context with user ID newCtx := context.WithValue(r.Context(), userIDKey, userID) + // Extract scoped permissions from the "permissions" claim. + // Tokens without this claim get full access (backward compatibility). + newCtx = extractPermissions(newCtx, claims) + // Call next handler with updated context next.ServeHTTP(w, r.WithContext(newCtx)) }) } } + +// extractPermissions reads the "permissions" claim from a JWT MapClaims +// and stores the parsed scopes in the context. If the claim is absent, +// the context is returned unmodified (meaning full access). If the claim +// is present but malformed, an empty permission set is stored (deny all) +// to prevent privilege escalation. +func extractPermissions(ctx context.Context, claims jwt.MapClaims) context.Context { + raw, ok := claims["permissions"] + if !ok { + return ctx // no permissions claim — legacy full-access token + } + + // The claim is a JSON array of strings, which jwt.MapClaims decodes + // as []interface{}. + arr, ok := raw.([]interface{}) + if !ok { + // permissions claim present but not a valid array — deny all + return scopes.ContextWithPermissions(ctx, []scopes.Scope{}) + } + + perms := make([]scopes.Scope, 0, len(arr)) + for _, v := range arr { + if s, ok := v.(string); ok { + perms = append(perms, scopes.Scope(s)) + } + } + return scopes.ContextWithPermissions(ctx, perms) +} diff --git a/lib/middleware/oapi_auth_test.go b/lib/middleware/oapi_auth_test.go index ad1c241d..44c39516 100644 --- a/lib/middleware/oapi_auth_test.go +++ b/lib/middleware/oapi_auth_test.go @@ -7,6 +7,7 @@ import ( "time" "github.com/golang-jwt/jwt/v5" + "github.com/kernel/hypeman/lib/scopes" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" ) @@ -335,3 +336,98 @@ func TestJwtAuth_RequiresAuthorization(t *testing.T) { assert.Contains(t, rr.Body.String(), "invalid token") }) } + +// generateScopedToken creates a user JWT token with specific permission scopes. +func generateScopedToken(t *testing.T, userID string, perms []string) string { + claims := jwt.MapClaims{ + "sub": userID, + "iat": time.Now().Unix(), + "exp": time.Now().Add(time.Hour).Unix(), + "permissions": perms, + } + token := jwt.NewWithClaims(jwt.SigningMethodHS256, claims) + tokenString, err := token.SignedString([]byte(testJWTSecret)) + require.NoError(t, err) + return tokenString +} + +func TestJwtAuth_ScopedPermissions(t *testing.T) { + // Handler that checks what permissions are in the context + nextHandler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + perms := scopes.GetPermissions(r.Context()) + if perms == nil { + w.Header().Set("X-Perms", "full-access") + } else { + w.Header().Set("X-Perms", "scoped") + } + w.WriteHeader(http.StatusOK) + }) + + handler := JwtAuth(testJWTSecret)(nextHandler) + + t.Run("legacy token without permissions has full access", func(t *testing.T) { + token := generateUserToken(t, "user-123") + + req := httptest.NewRequest(http.MethodGet, "/instances", nil) + req.Header.Set("Authorization", "Bearer "+token) + + rr := httptest.NewRecorder() + handler.ServeHTTP(rr, req) + + assert.Equal(t, http.StatusOK, rr.Code) + assert.Equal(t, "full-access", rr.Header().Get("X-Perms")) + }) + + t.Run("scoped token has permissions in context", func(t *testing.T) { + token := generateScopedToken(t, "user-456", []string{"instance:read", "image:read"}) + + req := httptest.NewRequest(http.MethodGet, "/instances", nil) + req.Header.Set("Authorization", "Bearer "+token) + + rr := httptest.NewRecorder() + handler.ServeHTTP(rr, req) + + assert.Equal(t, http.StatusOK, rr.Code) + assert.Equal(t, "scoped", rr.Header().Get("X-Perms")) + }) + + t.Run("wildcard scope token has permissions in context", func(t *testing.T) { + token := generateScopedToken(t, "user-789", []string{"*"}) + + req := httptest.NewRequest(http.MethodGet, "/instances", nil) + req.Header.Set("Authorization", "Bearer "+token) + + rr := httptest.NewRecorder() + handler.ServeHTTP(rr, req) + + assert.Equal(t, http.StatusOK, rr.Code) + assert.Equal(t, "scoped", rr.Header().Get("X-Perms")) + }) + + t.Run("malformed permissions claim denies all", func(t *testing.T) { + // Create a token where permissions is a string instead of an array + claims := jwt.MapClaims{ + "sub": "user-bad", + "iat": time.Now().Unix(), + "exp": time.Now().Add(time.Hour).Unix(), + "permissions": "not-an-array", + } + token := jwt.NewWithClaims(jwt.SigningMethodHS256, claims) + tokenString, err := token.SignedString([]byte(testJWTSecret)) + require.NoError(t, err) + + req := httptest.NewRequest(http.MethodGet, "/instances", nil) + req.Header.Set("Authorization", "Bearer "+tokenString) + + rr := httptest.NewRecorder() + handler.ServeHTTP(rr, req) + + assert.Equal(t, http.StatusOK, rr.Code) + // Should be scoped with empty permissions (not full-access) + assert.Equal(t, "scoped", rr.Header().Get("X-Perms")) + + // Verify it actually denies access + ctx := scopes.ContextWithPermissions(req.Context(), []scopes.Scope{}) + assert.False(t, scopes.HasScope(ctx, scopes.InstanceRead)) + }) +} diff --git a/lib/scopes/README.md b/lib/scopes/README.md new file mode 100644 index 00000000..197a754d --- /dev/null +++ b/lib/scopes/README.md @@ -0,0 +1,140 @@ +# Scoped API Key Permissions + +Hypeman API keys can be restricted to specific operations using scoped permissions. This lets you create least-privilege tokens — for example, a token that can only read instance status but not create or delete anything. + +## How It Works + +Permissions are embedded in the JWT token as a `permissions` claim containing an array of scope strings. When a request hits an API endpoint, the middleware checks whether the token carries the required scope for that endpoint. If the scope is missing, the request is rejected with `403 Forbidden`. + +Tokens without a `permissions` claim are treated as having **full access**. This means all existing tokens continue to work without any changes. + +## Available Scopes + +Scopes follow the pattern `resource:action`. Each resource type supports `read`, `write`, and `delete` actions. + +### Instances + +| Scope | Grants access to | +|---|---| +| `instance:read` | List instances, get instance details, view logs, get stats, stat paths | +| `instance:write` | Create, start, stop, standby, restore, fork instances; exec and cp (WebSocket) | +| `instance:delete` | Delete instances | + +### Images + +| Scope | Grants access to | +|---|---| +| `image:read` | List images, get image details | +| `image:write` | Pull/create images | +| `image:delete` | Delete images | + +### Volumes + +| Scope | Grants access to | +|---|---| +| `volume:read` | List volumes, get volume details | +| `volume:write` | Create volumes, create from archive, attach/detach volumes | +| `volume:delete` | Delete volumes | + +### Snapshots + +| Scope | Grants access to | +|---|---| +| `snapshot:read` | List snapshots, get snapshot details | +| `snapshot:write` | Create snapshots, restore snapshots, fork from snapshots | +| `snapshot:delete` | Delete snapshots | + +### Builds + +| Scope | Grants access to | +|---|---| +| `build:read` | List builds, get build details, stream build events | +| `build:write` | Create builds | +| `build:delete` | Cancel/delete builds | + +### Devices + +| Scope | Grants access to | +|---|---| +| `device:read` | List devices, get device details, list available devices | +| `device:write` | Register devices | +| `device:delete` | Unregister devices | + +### Ingresses + +| Scope | Grants access to | +|---|---| +| `ingress:read` | List ingresses, get ingress details | +| `ingress:write` | Create ingresses | +| `ingress:delete` | Delete ingresses | + +### Resources + +| Scope | Grants access to | +|---|---| +| `resource:read` | Health check, resource capacity/allocations | + +### Wildcard + +The `*` scope grants access to all endpoints. It is equivalent to a full-access token but explicitly declared in the permissions claim. + +## Creating Scoped Tokens + +Use the `hypeman-token` CLI to generate tokens with specific scopes. + +```bash +# List all available scopes +hypeman-token -list-scopes + +# Create a read-only token for instances and images +hypeman-token -user-id myuser -scopes "instance:read,image:read" + +# Create a token that can manage instances but not delete them +hypeman-token -user-id myuser -scopes "instance:read,instance:write" + +# Create a full-access token with explicit wildcard +hypeman-token -user-id myuser -scopes "*" + +# Create a full-access token (legacy style, no permissions claim) +hypeman-token -user-id myuser +``` + +Multiple scopes are comma-separated. Whitespace around scope names is trimmed. + +## Backward Compatibility + +Existing tokens that were generated before this feature was added do not have a `permissions` claim in the JWT. These tokens are treated as having **full access** to all endpoints — no action is required to keep them working. + +Only tokens generated with the `-scopes` flag carry a `permissions` claim and are subject to scope enforcement. + +## Example Scenarios + +**Monitoring / dashboard token** — can read instance status and stats but cannot modify anything: +```bash +hypeman-token -user-id dashboard -scopes "instance:read,resource:read" +``` + +**CI/CD build token** — can create builds and pull images, but has no access to instances or volumes: +```bash +hypeman-token -user-id ci -scopes "build:read,build:write,image:read,image:write" +``` + +**Instance operator** — full instance lifecycle management without access to images or builds: +```bash +hypeman-token -user-id operator -scopes "instance:read,instance:write,instance:delete,volume:read,volume:write,snapshot:read,snapshot:write" +``` + +**Read-only audit token** — can view everything but change nothing: +```bash +hypeman-token -user-id auditor -scopes "instance:read,image:read,volume:read,snapshot:read,build:read,device:read,ingress:read,resource:read" +``` + +## Error Responses + +When a scoped token attempts an operation it does not have permission for, the API returns: + +```json +{"code": "Forbidden", "message": "missing required scope: instance:write"} +``` + +The response includes the specific scope that was required, making it straightforward to diagnose permission issues. diff --git a/lib/scopes/middleware.go b/lib/scopes/middleware.go new file mode 100644 index 00000000..f7b63073 --- /dev/null +++ b/lib/scopes/middleware.go @@ -0,0 +1,49 @@ +package scopes + +import ( + "fmt" + "net/http" + + "github.com/go-chi/chi/v5" +) + +// Middleware returns a chi middleware that enforces scoped permissions. +// It looks up the required scope for the matched route pattern and +// rejects requests that lack the required scope with 403 Forbidden. +// +// Routes not in the scope map are allowed through (e.g. health check +// or unauthenticated routes that shouldn't reach this middleware). +func Middleware() func(http.Handler) http.Handler { + return func(next http.Handler) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + // Get the chi route pattern for this request + rctx := chi.RouteContext(r.Context()) + if rctx == nil { + next.ServeHTTP(w, r) + return + } + + pattern := rctx.RoutePattern() + if pattern == "" { + next.ServeHTTP(w, r) + return + } + + required, ok := ScopeForRoute(r.Method, pattern) + if !ok { + // Route not mapped — allow through + next.ServeHTTP(w, r) + return + } + + if !HasScope(r.Context(), required) { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusForbidden) + fmt.Fprintf(w, `{"code":"Forbidden","message":"missing required scope: %s"}`, required) + return + } + + next.ServeHTTP(w, r) + }) + } +} diff --git a/lib/scopes/middleware_test.go b/lib/scopes/middleware_test.go new file mode 100644 index 00000000..2e953731 --- /dev/null +++ b/lib/scopes/middleware_test.go @@ -0,0 +1,116 @@ +package scopes_test + +import ( + "context" + "net/http" + "net/http/httptest" + "testing" + + "github.com/go-chi/chi/v5" + "github.com/kernel/hypeman/lib/scopes" + "github.com/stretchr/testify/assert" +) + +// TestMiddleware_EnforcesScopes proves that the scope middleware actually +// blocks requests when the token lacks the required scope. This is an +// integration test using a real chi router to verify RoutePattern() is +// available when the middleware runs. +func TestMiddleware_EnforcesScopes(t *testing.T) { + handler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusOK) + w.Write([]byte("ok")) + }) + + buildRouter := func() chi.Router { + r := chi.NewRouter() + r.Group(func(r chi.Router) { + r.Use(scopes.Middleware()) + // Register routes matching RouteScopes entries + r.Get("/instances", handler) + r.Post("/instances", handler) + r.Delete("/instances/{id}", handler) + r.Get("/instances/{id}", handler) + r.Get("/images", handler) + r.Post("/images", handler) + }) + return r + } + + t.Run("allows request when scope matches", func(t *testing.T) { + r := buildRouter() + ctx := scopes.ContextWithPermissions(context.Background(), []scopes.Scope{scopes.InstanceRead}) + req := httptest.NewRequest(http.MethodGet, "/instances", nil).WithContext(ctx) + rr := httptest.NewRecorder() + r.ServeHTTP(rr, req) + assert.Equal(t, http.StatusOK, rr.Code) + }) + + t.Run("blocks request when scope is missing", func(t *testing.T) { + r := buildRouter() + // Token has image:read but not instance:read + ctx := scopes.ContextWithPermissions(context.Background(), []scopes.Scope{scopes.ImageRead}) + req := httptest.NewRequest(http.MethodGet, "/instances", nil).WithContext(ctx) + rr := httptest.NewRecorder() + r.ServeHTTP(rr, req) + assert.Equal(t, http.StatusForbidden, rr.Code) + assert.Contains(t, rr.Body.String(), "instance:read") + }) + + t.Run("blocks write when only read scope present", func(t *testing.T) { + r := buildRouter() + ctx := scopes.ContextWithPermissions(context.Background(), []scopes.Scope{scopes.InstanceRead}) + req := httptest.NewRequest(http.MethodPost, "/instances", nil).WithContext(ctx) + rr := httptest.NewRecorder() + r.ServeHTTP(rr, req) + assert.Equal(t, http.StatusForbidden, rr.Code) + assert.Contains(t, rr.Body.String(), "instance:write") + }) + + t.Run("blocks delete when only read and write scopes present", func(t *testing.T) { + r := buildRouter() + ctx := scopes.ContextWithPermissions(context.Background(), []scopes.Scope{scopes.InstanceRead, scopes.InstanceWrite}) + req := httptest.NewRequest(http.MethodDelete, "/instances/abc", nil).WithContext(ctx) + rr := httptest.NewRecorder() + r.ServeHTTP(rr, req) + assert.Equal(t, http.StatusForbidden, rr.Code) + assert.Contains(t, rr.Body.String(), "instance:delete") + }) + + t.Run("wildcard allows everything", func(t *testing.T) { + r := buildRouter() + ctx := scopes.ContextWithPermissions(context.Background(), []scopes.Scope{scopes.All}) + for _, tc := range []struct { + method string + path string + }{ + {"GET", "/instances"}, + {"POST", "/instances"}, + {"DELETE", "/instances/abc"}, + {"GET", "/images"}, + {"POST", "/images"}, + } { + req := httptest.NewRequest(tc.method, tc.path, nil).WithContext(ctx) + rr := httptest.NewRecorder() + r.ServeHTTP(rr, req) + assert.Equal(t, http.StatusOK, rr.Code, "%s %s should be allowed with wildcard", tc.method, tc.path) + } + }) + + t.Run("legacy token without permissions allows everything", func(t *testing.T) { + r := buildRouter() + // No ContextWithPermissions — simulates legacy token + req := httptest.NewRequest(http.MethodDelete, "/instances/abc", nil) + rr := httptest.NewRecorder() + r.ServeHTTP(rr, req) + assert.Equal(t, http.StatusOK, rr.Code) + }) + + t.Run("empty permissions denies everything", func(t *testing.T) { + r := buildRouter() + ctx := scopes.ContextWithPermissions(context.Background(), []scopes.Scope{}) + req := httptest.NewRequest(http.MethodGet, "/instances", nil).WithContext(ctx) + rr := httptest.NewRecorder() + r.ServeHTTP(rr, req) + assert.Equal(t, http.StatusForbidden, rr.Code) + }) +} diff --git a/lib/scopes/routes_test.go b/lib/scopes/routes_test.go new file mode 100644 index 00000000..25862002 --- /dev/null +++ b/lib/scopes/routes_test.go @@ -0,0 +1,122 @@ +package scopes_test + +import ( + "fmt" + "net/http" + "strings" + "testing" + + "github.com/go-chi/chi/v5" + "github.com/kernel/hypeman/lib/oapi" + "github.com/kernel/hypeman/lib/scopes" + "github.com/stretchr/testify/assert" +) + +// TestAllRoutesHaveScopes builds a chi router identical to the production +// server and verifies that every registered route has either a scope mapping +// in RouteScopes or is explicitly listed in PublicRoutes. If a new endpoint +// is added without a scope mapping, this test fails. +func TestAllRoutesHaveScopes(t *testing.T) { + r := chi.NewRouter() + noop := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {}) + + // WebSocket endpoints registered outside OpenAPI (same as cmd/api/main.go) + r.Get("/instances/{id}/exec", noop) + r.Get("/instances/{id}/cp", noop) + + // Public/unauthenticated endpoints + r.Get("/spec.yaml", noop) + r.Get("/spec.json", noop) + r.Get("/swagger", noop) + + // Registry endpoints — scoped by registry token auth, not API key scopes + r.Route("/v2", func(r chi.Router) { + r.Get("/token", noop) + r.Handle("/*", noop) + }) + + // OpenAPI-generated routes (the bulk of the API) + oapi.HandlerWithOptions(nil, oapi.ChiServerOptions{ + BaseRouter: r, + }) + + // Collect all routes and check them + var missing []string + err := chi.Walk(r, func(method, route string, handler http.Handler, middlewares ...func(http.Handler) http.Handler) error { + // Normalize: chi.Walk returns patterns like "/instances/{id}/exec" + // Strip trailing slashes for consistency + route = strings.TrimRight(route, "/") + if route == "" { + route = "/" + } + + key := method + " " + route + + // Skip registry routes — they use a separate token auth system + if strings.HasPrefix(route, "/v2") { + return nil + } + + // Check if the route has a scope mapping or is explicitly public + if _, hasScope := scopes.RouteScopes[key]; hasScope { + return nil + } + if scopes.PublicRoutes[key] { + return nil + } + + missing = append(missing, key) + return nil + }) + assert.NoError(t, err) + + for _, route := range missing { + t.Errorf("route %s has no scope mapping — add it to RouteScopes in lib/scopes/scopes.go (or PublicRoutes if intentionally unscoped)", route) + } +} + +// TestRouteScopesHaveNoStaleEntries verifies that every entry in RouteScopes +// corresponds to a route that actually exists in the router. This catches +// stale entries left behind when endpoints are removed. +func TestRouteScopesHaveNoStaleEntries(t *testing.T) { + r := chi.NewRouter() + noop := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {}) + + // Mirror production routes + r.Get("/instances/{id}/exec", noop) + r.Get("/instances/{id}/cp", noop) + r.Get("/spec.yaml", noop) + r.Get("/spec.json", noop) + r.Get("/swagger", noop) + + oapi.HandlerWithOptions(nil, oapi.ChiServerOptions{ + BaseRouter: r, + }) + + // Collect all registered routes + registered := make(map[string]bool) + err := chi.Walk(r, func(method, route string, handler http.Handler, middlewares ...func(http.Handler) http.Handler) error { + route = strings.TrimRight(route, "/") + if route == "" { + route = "/" + } + registered[method+" "+route] = true + return nil + }) + assert.NoError(t, err) + + for key := range scopes.RouteScopes { + if !registered[key] { + t.Errorf("RouteScopes contains %q but no such route exists — remove the stale entry from lib/scopes/scopes.go", key) + } + } +} + +// TestPublicRoutesAreNotInRouteScopes ensures that routes marked as public +// don't also have a scope mapping, which would be contradictory. +func TestPublicRoutesAreNotInRouteScopes(t *testing.T) { + for key := range scopes.PublicRoutes { + _, hasScope := scopes.RouteScopes[key] + assert.False(t, hasScope, fmt.Sprintf("route %s is in both PublicRoutes and RouteScopes — pick one", key)) + } +} diff --git a/lib/scopes/scopes.go b/lib/scopes/scopes.go new file mode 100644 index 00000000..0a0cac39 --- /dev/null +++ b/lib/scopes/scopes.go @@ -0,0 +1,255 @@ +// Package scopes defines API permission scopes for hypeman API keys. +// +// Scopes follow the pattern "resource:action" where resource is one of the +// API resource types and action is read, write, or delete. Tokens without +// a "permissions" claim are treated as having full access for backward +// compatibility with existing tokens. +package scopes + +import ( + "context" + "fmt" + "net/http" + "slices" + "strings" +) + +// Scope represents a permission scope for API access. +type Scope string + +const ( + // Instance scopes + InstanceRead Scope = "instance:read" + InstanceWrite Scope = "instance:write" // create, start, stop, standby, restore, fork, exec, cp + InstanceDelete Scope = "instance:delete" + + // Image scopes + ImageRead Scope = "image:read" + ImageWrite Scope = "image:write" // pull/create + ImageDelete Scope = "image:delete" + + // Volume scopes + VolumeRead Scope = "volume:read" + VolumeWrite Scope = "volume:write" // create, attach, detach + VolumeDelete Scope = "volume:delete" + + // Snapshot scopes + SnapshotRead Scope = "snapshot:read" + SnapshotWrite Scope = "snapshot:write" // create, restore, fork + SnapshotDelete Scope = "snapshot:delete" + + // Build scopes + BuildRead Scope = "build:read" + BuildWrite Scope = "build:write" // create + BuildDelete Scope = "build:delete" // cancel + + // Device scopes + DeviceRead Scope = "device:read" + DeviceWrite Scope = "device:write" // register + DeviceDelete Scope = "device:delete" // unregister + + // Ingress scopes + IngressRead Scope = "ingress:read" + IngressWrite Scope = "ingress:write" + IngressDelete Scope = "ingress:delete" + + // Resource/health scopes (read-only) + ResourceRead Scope = "resource:read" + + // Wildcard scope — grants all permissions + All Scope = "*" +) + +// AllScopes is the complete list of valid scopes (excluding wildcard). +var AllScopes = []Scope{ + InstanceRead, InstanceWrite, InstanceDelete, + ImageRead, ImageWrite, ImageDelete, + VolumeRead, VolumeWrite, VolumeDelete, + SnapshotRead, SnapshotWrite, SnapshotDelete, + BuildRead, BuildWrite, BuildDelete, + DeviceRead, DeviceWrite, DeviceDelete, + IngressRead, IngressWrite, IngressDelete, + ResourceRead, +} + +// Valid returns true if s is a recognized scope. +func (s Scope) Valid() bool { + if s == All { + return true + } + return slices.Contains(AllScopes, s) +} + +// ParseScopes parses a comma-separated scope string into a slice of Scopes. +// Returns an error if any scope is unrecognized. +func ParseScopes(s string) ([]Scope, error) { + if s == "" { + return nil, nil + } + parts := strings.Split(s, ",") + out := make([]Scope, 0, len(parts)) + for _, p := range parts { + sc := Scope(strings.TrimSpace(p)) + if !sc.Valid() { + return nil, fmt.Errorf("unknown scope: %q", p) + } + out = append(out, sc) + } + return out, nil +} + +// ScopeStrings converts a slice of Scopes to strings. +func ScopeStrings(scopes []Scope) []string { + out := make([]string, len(scopes)) + for i, s := range scopes { + out[i] = string(s) + } + return out +} + +type permissionsContextKey struct{} + +// HasFullAccess returns true if the request context indicates full access +// (no scopes restriction). This is the case for legacy tokens without +// a permissions claim. +func HasFullAccess(ctx context.Context) bool { + v, ok := ctx.Value(permissionsContextKey{}).([]Scope) + if !ok { + // No permissions set — means full access (legacy token) + return true + } + return slices.Contains(v, All) +} + +// ContextWithPermissions stores the granted scopes in the context. +// A nil slice means full access (legacy behavior). +func ContextWithPermissions(ctx context.Context, perms []Scope) context.Context { + return context.WithValue(ctx, permissionsContextKey{}, perms) +} + +// GetPermissions extracts the granted scopes from context. +// Returns nil if no permissions are set (legacy full-access token). +func GetPermissions(ctx context.Context) []Scope { + v, _ := ctx.Value(permissionsContextKey{}).([]Scope) + return v +} + +// HasScope checks whether the context has the required scope. +// Returns true if: +// - No permissions claim was set (legacy full-access token) +// - The wildcard scope "*" is present +// - The specific scope is present +func HasScope(ctx context.Context, required Scope) bool { + perms := GetPermissions(ctx) + if perms == nil { + return true // legacy token — full access + } + if slices.Contains(perms, All) { + return true + } + return slices.Contains(perms, required) +} + +// RequireScope returns an HTTP middleware that rejects requests lacking +// the specified scope with 403 Forbidden. +func RequireScope(required Scope) func(http.Handler) http.Handler { + return func(next http.Handler) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if !HasScope(r.Context(), required) { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusForbidden) + fmt.Fprintf(w, `{"code":"Forbidden","message":"missing required scope: %s"}`, required) + return + } + next.ServeHTTP(w, r) + }) + } +} + +// PublicRoutes lists route keys ("METHOD /path") that are intentionally +// unscoped — they do not require authentication or scope checks. +// The test uses this to distinguish "intentionally public" from "forgot to +// add a scope mapping". +var PublicRoutes = map[string]bool{ + "GET /spec.yaml": true, + "GET /spec.json": true, + "GET /swagger": true, +} + +// RouteScopes maps "METHOD /path-pattern" to the required scope. +// Path patterns use chi-style {param} placeholders. +var RouteScopes = map[string]Scope{ + // Builds + "GET /builds": BuildRead, + "POST /builds": BuildWrite, + "DELETE /builds/{id}": BuildDelete, + "GET /builds/{id}": BuildRead, + "GET /builds/{id}/events": BuildRead, + + // Devices + "GET /devices": DeviceRead, + "POST /devices": DeviceWrite, + "GET /devices/available": DeviceRead, + "DELETE /devices/{id}": DeviceDelete, + "GET /devices/{id}": DeviceRead, + + // Health & Resources + "GET /health": ResourceRead, + "GET /resources": ResourceRead, + + // Images + "GET /images": ImageRead, + "POST /images": ImageWrite, + "DELETE /images/{name}": ImageDelete, + "GET /images/{name}": ImageRead, + + // Ingresses + "GET /ingresses": IngressRead, + "POST /ingresses": IngressWrite, + "DELETE /ingresses/{id}": IngressDelete, + "GET /ingresses/{id}": IngressRead, + + // Instances + "GET /instances": InstanceRead, + "POST /instances": InstanceWrite, + "DELETE /instances/{id}": InstanceDelete, + "GET /instances/{id}": InstanceRead, + "POST /instances/{id}/fork": InstanceWrite, + "GET /instances/{id}/logs": InstanceRead, + "POST /instances/{id}/restore": InstanceWrite, + "POST /instances/{id}/snapshots": SnapshotWrite, + "POST /instances/{id}/snapshots/{snapshotId}/restore": SnapshotWrite, + "POST /instances/{id}/standby": InstanceWrite, + "POST /instances/{id}/start": InstanceWrite, + "GET /instances/{id}/stat": InstanceRead, + "GET /instances/{id}/stats": InstanceRead, + "POST /instances/{id}/stop": InstanceWrite, + "DELETE /instances/{id}/volumes/{volumeId}": VolumeWrite, + "POST /instances/{id}/volumes/{volumeId}": VolumeWrite, + + // WebSocket endpoints (outside OpenAPI but still authed) + "GET /instances/{id}/exec": InstanceWrite, + "GET /instances/{id}/cp": InstanceWrite, + + // Snapshots + "GET /snapshots": SnapshotRead, + "DELETE /snapshots/{snapshotId}": SnapshotDelete, + "GET /snapshots/{snapshotId}": SnapshotRead, + "POST /snapshots/{snapshotId}/fork": SnapshotWrite, + + // Volumes + "GET /volumes": VolumeRead, + "POST /volumes": VolumeWrite, + "POST /volumes/from-archive": VolumeWrite, + "DELETE /volumes/{id}": VolumeDelete, + "GET /volumes/{id}": VolumeRead, +} + +// ScopeForRoute looks up the required scope for a given HTTP method and +// chi route pattern (e.g. "GET", "/instances/{id}"). Returns the scope +// and true if found, or ("", false) if the route is not mapped. +func ScopeForRoute(method, pattern string) (Scope, bool) { + key := method + " " + pattern + s, ok := RouteScopes[key] + return s, ok +} diff --git a/lib/scopes/scopes_test.go b/lib/scopes/scopes_test.go new file mode 100644 index 00000000..0ce296e5 --- /dev/null +++ b/lib/scopes/scopes_test.go @@ -0,0 +1,161 @@ +package scopes + +import ( + "context" + "net/http" + "net/http/httptest" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestScopeValid(t *testing.T) { + assert.True(t, InstanceRead.Valid()) + assert.True(t, All.Valid()) + assert.False(t, Scope("bogus:scope").Valid()) + assert.False(t, Scope("").Valid()) +} + +func TestParseScopes(t *testing.T) { + t.Run("empty string returns nil", func(t *testing.T) { + s, err := ParseScopes("") + require.NoError(t, err) + assert.Nil(t, s) + }) + + t.Run("single scope", func(t *testing.T) { + s, err := ParseScopes("instance:read") + require.NoError(t, err) + assert.Equal(t, []Scope{InstanceRead}, s) + }) + + t.Run("multiple scopes", func(t *testing.T) { + s, err := ParseScopes("instance:read,instance:write,image:read") + require.NoError(t, err) + assert.Equal(t, []Scope{InstanceRead, InstanceWrite, ImageRead}, s) + }) + + t.Run("wildcard", func(t *testing.T) { + s, err := ParseScopes("*") + require.NoError(t, err) + assert.Equal(t, []Scope{All}, s) + }) + + t.Run("trims whitespace", func(t *testing.T) { + s, err := ParseScopes("instance:read , image:read") + require.NoError(t, err) + assert.Equal(t, []Scope{InstanceRead, ImageRead}, s) + }) + + t.Run("unknown scope returns error", func(t *testing.T) { + _, err := ParseScopes("instance:read,bogus:thing") + assert.Error(t, err) + assert.Contains(t, err.Error(), "unknown scope") + }) +} + +func TestHasScope(t *testing.T) { + t.Run("nil permissions means full access", func(t *testing.T) { + ctx := context.Background() + assert.True(t, HasScope(ctx, InstanceRead)) + assert.True(t, HasScope(ctx, InstanceDelete)) + }) + + t.Run("wildcard grants everything", func(t *testing.T) { + ctx := ContextWithPermissions(context.Background(), []Scope{All}) + assert.True(t, HasScope(ctx, InstanceRead)) + assert.True(t, HasScope(ctx, BuildDelete)) + }) + + t.Run("specific scopes", func(t *testing.T) { + ctx := ContextWithPermissions(context.Background(), []Scope{InstanceRead, ImageRead}) + assert.True(t, HasScope(ctx, InstanceRead)) + assert.True(t, HasScope(ctx, ImageRead)) + assert.False(t, HasScope(ctx, InstanceWrite)) + assert.False(t, HasScope(ctx, BuildRead)) + }) + + t.Run("empty permissions slice denies all", func(t *testing.T) { + ctx := ContextWithPermissions(context.Background(), []Scope{}) + assert.False(t, HasScope(ctx, InstanceRead)) + }) +} + +func TestHasFullAccess(t *testing.T) { + t.Run("no permissions set", func(t *testing.T) { + assert.True(t, HasFullAccess(context.Background())) + }) + t.Run("wildcard set", func(t *testing.T) { + ctx := ContextWithPermissions(context.Background(), []Scope{All}) + assert.True(t, HasFullAccess(ctx)) + }) + t.Run("limited scopes", func(t *testing.T) { + ctx := ContextWithPermissions(context.Background(), []Scope{InstanceRead}) + assert.False(t, HasFullAccess(ctx)) + }) +} + +func TestRequireScope(t *testing.T) { + handler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusOK) + }) + + t.Run("allows when scope present", func(t *testing.T) { + ctx := ContextWithPermissions(context.Background(), []Scope{InstanceRead}) + req := httptest.NewRequest(http.MethodGet, "/instances", nil).WithContext(ctx) + rr := httptest.NewRecorder() + + RequireScope(InstanceRead)(handler).ServeHTTP(rr, req) + assert.Equal(t, http.StatusOK, rr.Code) + }) + + t.Run("allows legacy tokens without permissions", func(t *testing.T) { + req := httptest.NewRequest(http.MethodGet, "/instances", nil) + rr := httptest.NewRecorder() + + RequireScope(InstanceRead)(handler).ServeHTTP(rr, req) + assert.Equal(t, http.StatusOK, rr.Code) + }) + + t.Run("rejects when scope missing", func(t *testing.T) { + ctx := ContextWithPermissions(context.Background(), []Scope{ImageRead}) + req := httptest.NewRequest(http.MethodGet, "/instances", nil).WithContext(ctx) + rr := httptest.NewRecorder() + + RequireScope(InstanceRead)(handler).ServeHTTP(rr, req) + assert.Equal(t, http.StatusForbidden, rr.Code) + assert.Contains(t, rr.Body.String(), "instance:read") + }) + + t.Run("wildcard grants access", func(t *testing.T) { + ctx := ContextWithPermissions(context.Background(), []Scope{All}) + req := httptest.NewRequest(http.MethodGet, "/instances", nil).WithContext(ctx) + rr := httptest.NewRecorder() + + RequireScope(InstanceRead)(handler).ServeHTTP(rr, req) + assert.Equal(t, http.StatusOK, rr.Code) + }) +} + +func TestScopeForRoute(t *testing.T) { + s, ok := ScopeForRoute("GET", "/instances") + assert.True(t, ok) + assert.Equal(t, InstanceRead, s) + + s, ok = ScopeForRoute("POST", "/instances") + assert.True(t, ok) + assert.Equal(t, InstanceWrite, s) + + s, ok = ScopeForRoute("DELETE", "/instances/{id}") + assert.True(t, ok) + assert.Equal(t, InstanceDelete, s) + + _, ok = ScopeForRoute("GET", "/nonexistent") + assert.False(t, ok) +} + +func TestScopeStrings(t *testing.T) { + s := ScopeStrings([]Scope{InstanceRead, ImageWrite}) + assert.Equal(t, []string{"instance:read", "image:write"}, s) +}