Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions cmd/api/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down Expand Up @@ -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)

Expand All @@ -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)

Expand Down Expand Up @@ -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))
Expand Down
24 changes: 24 additions & 0 deletions cmd/gen-jwt/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down Expand Up @@ -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")
Expand All @@ -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 {
Expand Down
37 changes: 37 additions & 0 deletions lib/middleware/oapi_auth.go
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -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)

Expand Down Expand Up @@ -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)
}
96 changes: 96 additions & 0 deletions lib/middleware/oapi_auth_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"
)
Expand Down Expand Up @@ -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))
})
}
140 changes: 140 additions & 0 deletions lib/scopes/README.md
Original file line number Diff line number Diff line change
@@ -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.
Loading
Loading