Add scoped permissions for API keys#148
Conversation
Implement a permissions system for API keys so tokens can be restricted to specific operations instead of having full access. Scopes follow the pattern "resource:action" (e.g., instance:read, image:write, build:delete). Key changes: - New lib/scopes package with scope constants, context helpers, and middleware - JWT tokens can now carry a "permissions" claim with an array of scope strings - Auth middleware extracts permissions from JWT and stores in request context - Scope middleware enforces per-route permission requirements - Token CLI (hypeman-token) supports -scopes flag and -list-scopes - Backward compatible: tokens without "permissions" claim get full access - Uses "permissions" claim name to avoid collision with registry token "scope" Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Documents the feature from a user/operator perspective: available scopes by resource type, CLI usage for creating scoped tokens, backward compatibility, and example scenarios for common use cases. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Adds three tests in lib/scopes/routes_test.go: - TestAllRoutesHaveScopes: builds the full chi router (OpenAPI + manual routes) and asserts every route has a scope in RouteScopes or is explicitly marked in PublicRoutes. Fails CI if a new endpoint is added without a scope mapping. - TestRouteScopesHaveNoStaleEntries: catches stale scope entries left behind when endpoints are removed. - TestPublicRoutesAreNotInRouteScopes: ensures no route is contradictorily listed in both maps. Also exports RouteScopes and adds PublicRoutes for spec.yaml, spec.json, and swagger endpoints. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
… tests - extractPermissions now stores an empty permission set (deny all) when the "permissions" claim is present but not a valid array, preventing privilege escalation from malformed tokens. - Add middleware integration test (TestMiddleware_EnforcesScopes) that proves scope enforcement works end-to-end with a real chi router: blocks missing scopes, allows matching scopes, wildcard, legacy tokens, and empty permissions. - Add test for malformed permissions claim in JwtAuth middleware. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
| for _, p := range parts { | ||
| sc := Scope(strings.TrimSpace(p)) | ||
| if !sc.Valid() { | ||
| return nil, fmt.Errorf("unknown scope: %q", p) |
There was a problem hiding this comment.
nit: this shows the untrimmed input — if someone passes "bogus:thing" the error includes the leading space which makes it look like the space is the problem. consider using sc instead of p here
There was a problem hiding this comment.
Fixed — error message now uses the trimmed variable sc instead of the raw input p.
| ) | ||
|
|
||
| // AllScopes is the complete list of valid scopes (excluding wildcard). | ||
| var AllScopes = []Scope{ |
There was a problem hiding this comment.
nit: exported mutable slice — anything can append/modify at runtime. low risk for an internal package, but worth a comment noting it's treated as immutable (or make it a func returning a copy)
There was a problem hiding this comment.
Converted AllScopes from an exported var to a function that returns a copy of the internal slice. Callers now use AllScopes(). The unexported allScopes slice is used directly by Valid() to avoid allocation.
| "POST /instances/{id}/volumes/{volumeId}": VolumeWrite, | ||
|
|
||
| // WebSocket endpoints (outside OpenAPI but still authed) | ||
| "GET /instances/{id}/exec": InstanceWrite, |
There was a problem hiding this comment.
these RouteScopes entries for exec/cp are never checked by Middleware() — they're outside the OpenAPI router group and use RequireScope directly. they only exist to satisfy the route coverage test. could move them to PublicRoutes and adjust the test, so the map only contains entries actually used by the middleware
There was a problem hiding this comment.
Moved exec/cp entries out of RouteScopes into a new DirectScopeRoutes map for routes that enforce scopes via RequireScope directly (outside the OpenAPI middleware group). Updated the route coverage test to check DirectScopeRoutes as well, and added a test ensuring DirectScopeRoutes and RouteScopes don't overlap.
| perms := make([]scopes.Scope, 0, len(arr)) | ||
| for _, v := range arr { | ||
| if s, ok := v.(string); ok { | ||
| perms = append(perms, scopes.Scope(s)) |
There was a problem hiding this comment.
no Valid() check on extracted scopes — invalid strings silently fail to match which is safe (fail-closed), but a typo in token generation (e.g. "instnace:read") won't surface until a user hits a 403. might be worth logging or validating here
There was a problem hiding this comment.
Added a Valid() check on each scope during extraction. Invalid scopes now log a warning via log.WarnContext so typos like "instnace:read" surface in logs instead of silently failing to match.
| return tokenString | ||
| } | ||
|
|
||
| func TestJwtAuth_ScopedPermissions(t *testing.T) { |
There was a problem hiding this comment.
nice coverage on JwtAuth with scoped tokens — worth adding a test for the OapiAuthenticationFunc path with scoped tokens too, since that's the other entry point for auth
There was a problem hiding this comment.
Added TestOapiAuthenticationFunc_ScopedPermissions with four subcases: legacy token (full access), scoped token (specific permissions in context), wildcard token (grants all), and malformed permissions claim (denies all). Uses a helper newBearerAuthInput to construct the openapi3filter.AuthenticationInput directly.
masnwilliams
left a comment
There was a problem hiding this comment.
solid design — clean scope system, good backward compat (nil = full access), fail-closed on malformed claims, and the route coverage tests are a great guardrail. left a few nits, nothing blocking.
1. Use trimmed scope value in ParseScopes error message 2. Convert AllScopes from exported mutable slice to func returning a copy 3. Move exec/cp WebSocket routes from RouteScopes to DirectScopeRoutes 4. Add scope validation warning in extractPermissions for typo detection 5. Add OapiAuthenticationFunc test coverage for scoped tokens Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
There was a problem hiding this comment.
Cursor Bugbot has reviewed your changes and found 1 potential issue.
Bugbot Autofix is OFF. To automatically fix reported issues with cloud agents, enable autofix in the Cursor dashboard.
| return true | ||
| } | ||
| return slices.Contains(v, All) | ||
| } |
There was a problem hiding this comment.
Nil permissions misreported as limited access
Low Severity
HasFullAccess disagrees with HasScope when context stores []Scope(nil). ContextWithPermissions documents nil as full access, but HasFullAccess returns false due to type-assertion behavior, while HasScope returns true. This makes exported permission helpers internally inconsistent.


Summary
lib/scopespackage that defines permission scopes following theresource:actionpattern (e.g.,instance:read,image:write,build:delete)permissionsclaim with an array of scope strings; tokens without this claim retain full access (backward compatible)JwtAuthandOapiAuthenticationFunc) extracts thepermissionsclaim and stores parsed scopes in the request contexthypeman-tokenCLI gains-scopes(comma-separated scope list) and-list-scopesflagsexec,cp) enforceinstance:writescope viaRequireScopeScope mapping
All API routes are mapped to scopes in
lib/scopes/scopes.go:routeScopes. The resource types are:instance:image:volume:snapshot:build:device:ingress:resource:Each prefix supports
read,write, anddeleteactions (exceptresource:which is read-only). The wildcard*grants all permissions.Usage
Test plan
go test ./lib/scopes/— scope parsing, validation, context helpers, RequireScope middlewarego test ./lib/middleware/— legacy tokens get full access, scoped tokens have permissions in context, wildcard tokens workgo vetclean on all changed packages🤖 Generated with Claude Code
Note
Medium Risk
Introduces per-route authorization enforcement based on JWT
permissionsscopes; mistakes in scope mapping or middleware ordering could inadvertently block or expose API operations.Overview
Adds a new scoped permissions system for API-key JWTs via a
permissionsclaim, including alib/scopespackage (scope definitions, route-to-scope mapping, and chi middleware) that returns403 Forbiddenwhen required scopes are missing.Updates API auth to extract and store scopes in request context (legacy tokens without
permissionsremain full-access; malformed claims become deny-all), wires scope enforcement into the main API router, and adds explicitinstance:writegating for the WebSocketexec/cpendpoints.Extends the
hypeman-tokengenerator CLI with-scopesand-list-scopes, and adds tests to ensure scope parsing/enforcement works and that every registered API route is mapped (or intentionally public).Written by Cursor Bugbot for commit dd33c36. This will update automatically on new commits. Configure here.