Skip to content

Add scoped permissions for API keys#148

Merged
sjmiller609 merged 6 commits into
mainfrom
hypeship/scoped-api-key-permissions
Mar 19, 2026
Merged

Add scoped permissions for API keys#148
sjmiller609 merged 6 commits into
mainfrom
hypeship/scoped-api-key-permissions

Conversation

@sjmiller609

@sjmiller609 sjmiller609 commented Mar 17, 2026

Copy link
Copy Markdown
Collaborator

Summary

  • Adds a lib/scopes package that defines permission scopes following the resource:action pattern (e.g., instance:read, image:write, build:delete)
  • JWT tokens can now carry a permissions claim with an array of scope strings; tokens without this claim retain full access (backward compatible)
  • Auth middleware (JwtAuth and OapiAuthenticationFunc) extracts the permissions claim and stores parsed scopes in the request context
  • A new scope enforcement middleware rejects requests missing the required scope with 403 Forbidden
  • The hypeman-token CLI gains -scopes (comma-separated scope list) and -list-scopes flags
  • WebSocket endpoints (exec, cp) enforce instance:write scope via RequireScope

Scope mapping

All API routes are mapped to scopes in lib/scopes/scopes.go:routeScopes. The resource types are:

Scope prefix Resources
instance: instances, exec, cp, start/stop/standby/restore/fork
image: images
volume: volumes, attach/detach
snapshot: snapshots, instance snapshots
build: builds
device: devices
ingress: ingresses
resource: health, resources

Each prefix supports read, write, and delete actions (except resource: which is read-only). The wildcard * grants all permissions.

Usage

# Full-access token (backward compatible, no permissions claim)
hypeman-token -user-id myuser -duration 8760h

# Read-only token for instances and images
hypeman-token -user-id myuser -scopes "instance:read,image:read"

# Token that can manage instances but not delete them
hypeman-token -user-id myuser -scopes "instance:read,instance:write"

# List available scopes
hypeman-token -list-scopes

Test plan

  • go test ./lib/scopes/ — scope parsing, validation, context helpers, RequireScope middleware
  • go test ./lib/middleware/ — legacy tokens get full access, scoped tokens have permissions in context, wildcard tokens work
  • go vet clean on all changed packages
  • Integration test with live API: create a scoped token, verify allowed endpoints succeed and denied endpoints return 403

🤖 Generated with Claude Code


Note

Medium Risk
Introduces per-route authorization enforcement based on JWT permissions scopes; 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 permissions claim, including a lib/scopes package (scope definitions, route-to-scope mapping, and chi middleware) that returns 403 Forbidden when required scopes are missing.

Updates API auth to extract and store scopes in request context (legacy tokens without permissions remain full-access; malformed claims become deny-all), wires scope enforcement into the main API router, and adds explicit instance:write gating for the WebSocket exec/cp endpoints.

Extends the hypeman-token generator CLI with -scopes and -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.

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>
Comment thread lib/scopes/scopes.go
sjmiller609 and others added 2 commits March 17, 2026 14:29
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>
@sjmiller609 sjmiller609 marked this pull request as ready for review March 17, 2026 15:20
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>
Comment thread lib/scopes/middleware.go
Comment thread lib/middleware/oapi_auth.go Outdated
… 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>
@sjmiller609 sjmiller609 requested review from hiroTamada, masnwilliams and rgarcia and removed request for hiroTamada and rgarcia March 17, 2026 15:41
Comment thread lib/scopes/scopes.go Outdated
for _, p := range parts {
sc := Scope(strings.TrimSpace(p))
if !sc.Valid() {
return nil, fmt.Errorf("unknown scope: %q", p)

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed — error message now uses the trimmed variable sc instead of the raw input p.

Comment thread lib/scopes/scopes.go Outdated
)

// AllScopes is the complete list of valid scopes (excluding wildcard).
var AllScopes = []Scope{

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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)

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Comment thread lib/scopes/scopes.go Outdated
"POST /instances/{id}/volumes/{volumeId}": VolumeWrite,

// WebSocket endpoints (outside OpenAPI but still authed)
"GET /instances/{id}/exec": InstanceWrite,

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Comment thread lib/middleware/oapi_auth.go Outdated
perms := make([]scopes.Scope, 0, len(arr))
for _, v := range arr {
if s, ok := v.(string); ok {
perms = append(perms, scopes.Scope(s))

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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) {

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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 masnwilliams left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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>

@cursor cursor Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Cursor Bugbot has reviewed your changes and found 1 potential issue.

Fix All in Cursor

Bugbot Autofix is OFF. To automatically fix reported issues with cloud agents, enable autofix in the Cursor dashboard.

Comment thread lib/scopes/scopes.go
return true
}
return slices.Contains(v, All)
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Fix in Cursor Fix in Web

@sjmiller609 sjmiller609 merged commit 0cee62d into main Mar 19, 2026
6 checks passed
@sjmiller609 sjmiller609 deleted the hypeship/scoped-api-key-permissions branch March 19, 2026 17:40
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants