Skip to content
Merged
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
42 changes: 42 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,45 @@ 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{})
}

log := logger.FromContext(ctx)
perms := make([]scopes.Scope, 0, len(arr))
for _, v := range arr {
if s, ok := v.(string); ok {
sc := scopes.Scope(s)
if !sc.Valid() {
log.WarnContext(ctx, "invalid scope in token permissions claim", "scope", s)
}
perms = append(perms, sc)
}
}
return scopes.ContextWithPermissions(ctx, perms)
}
181 changes: 181 additions & 0 deletions lib/middleware/oapi_auth_test.go
Original file line number Diff line number Diff line change
@@ -1,12 +1,16 @@
package middleware

import (
"context"
"net/http"
"net/http/httptest"
"testing"
"time"

"github.com/getkin/kin-openapi/openapi3"
"github.com/getkin/kin-openapi/openapi3filter"
"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 +339,180 @@ 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) {

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.

// 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))
})
}

// newBearerAuthInput creates an openapi3filter.AuthenticationInput for a bearer
// token request, suitable for testing OapiAuthenticationFunc.
func newBearerAuthInput(t *testing.T, token string) *openapi3filter.AuthenticationInput {
t.Helper()
req := httptest.NewRequest(http.MethodGet, "/instances", nil)
req.Header.Set("Authorization", "Bearer "+token)
return &openapi3filter.AuthenticationInput{
RequestValidationInput: &openapi3filter.RequestValidationInput{
Request: req,
},
SecurityScheme: &openapi3.SecurityScheme{
Type: "http",
Scheme: "bearer",
},
}
}

func TestOapiAuthenticationFunc_ScopedPermissions(t *testing.T) {
authFunc := OapiAuthenticationFunc(testJWTSecret)

t.Run("legacy token without permissions has full access", func(t *testing.T) {
token := generateUserToken(t, "user-100")
input := newBearerAuthInput(t, token)

err := authFunc(context.Background(), input)
require.NoError(t, err)

// After auth, context should have no permissions (full access)
ctx := input.RequestValidationInput.Request.Context()
assert.True(t, scopes.HasFullAccess(ctx))
assert.True(t, scopes.HasScope(ctx, scopes.InstanceRead))
assert.True(t, scopes.HasScope(ctx, scopes.InstanceDelete))
})

t.Run("scoped token stores permissions in context", func(t *testing.T) {
token := generateScopedToken(t, "user-200", []string{"instance:read", "image:read"})
input := newBearerAuthInput(t, token)

err := authFunc(context.Background(), input)
require.NoError(t, err)

ctx := input.RequestValidationInput.Request.Context()
assert.False(t, scopes.HasFullAccess(ctx))
assert.True(t, scopes.HasScope(ctx, scopes.InstanceRead))
assert.True(t, scopes.HasScope(ctx, scopes.ImageRead))
assert.False(t, scopes.HasScope(ctx, scopes.InstanceWrite))
assert.False(t, scopes.HasScope(ctx, scopes.BuildRead))
})

t.Run("wildcard scoped token grants all", func(t *testing.T) {
token := generateScopedToken(t, "user-300", []string{"*"})
input := newBearerAuthInput(t, token)

err := authFunc(context.Background(), input)
require.NoError(t, err)

ctx := input.RequestValidationInput.Request.Context()
assert.True(t, scopes.HasScope(ctx, scopes.InstanceRead))
assert.True(t, scopes.HasScope(ctx, scopes.BuildDelete))
})

t.Run("malformed permissions claim denies all", func(t *testing.T) {
claims := jwt.MapClaims{
"sub": "user-400",
"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)

input := newBearerAuthInput(t, tokenString)
err = authFunc(context.Background(), input)
require.NoError(t, err)

ctx := input.RequestValidationInput.Request.Context()
assert.False(t, scopes.HasFullAccess(ctx))
assert.False(t, scopes.HasScope(ctx, scopes.InstanceRead))
})
}
Loading
Loading