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
16 changes: 13 additions & 3 deletions backend/internal/bootstrap/router_bootstrap.go
Original file line number Diff line number Diff line change
Expand Up @@ -66,8 +66,16 @@ func createAuthValidator(appServices *Services) middleware.AuthValidator {
return func(ctx context.Context, c *gin.Context) bool {
// Check for API key authentication
if apiKey := c.GetHeader("X-API-Key"); apiKey != "" {
user, err := appServices.ApiKey.ValidateApiKey(ctx, apiKey)
return err == nil && user != nil
// User-owned API key
if user, err := appServices.ApiKey.ValidateApiKey(ctx, apiKey); err == nil && user != nil {
return true
}
// Environment bootstrap key (user_id = NULL): used by the proxy when forwarding
// requests to a remote env whose apiUrl resolves back to this manager.
if _, err := appServices.ApiKey.GetEnvironmentByApiKey(ctx, apiKey); err == nil {
return true
}
return false
}

// Check for Bearer token authentication
Expand Down Expand Up @@ -100,7 +108,9 @@ func setupRouter(ctx context.Context, cfg *config.Config, appServices *Services)
Filters: []sloggin.Filter{shouldLogRequest},
}))

authMiddleware := middleware.NewAuthMiddleware(appServices.Auth, cfg).WithApiKeyValidator(appServices.ApiKey)
authMiddleware := middleware.NewAuthMiddleware(appServices.Auth, cfg).
WithApiKeyValidator(appServices.ApiKey).
WithEnvironmentAccessTokenResolver(appServices.Environment)
corsMiddleware := middleware.NewCORSMiddleware(cfg).Add()
router.Use(corsMiddleware)

Expand Down
2 changes: 1 addition & 1 deletion backend/internal/huma/handlers/dashboard_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -105,7 +105,7 @@ func TestDashboardHandlerGetDashboardReturnsSnapshot(t *testing.T) {
Name: "expiring-soon",
KeyHash: "hash-soon",
KeyPrefix: "arc_test_handler",
UserID: "user-1",
UserID: new("user-1"),
ExpiresAt: new(time.Now().Add(12 * time.Hour)),
}).Error)

Expand Down
2 changes: 1 addition & 1 deletion backend/internal/huma/handlers/templates_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -158,7 +158,7 @@ func newTemplateFetchTestRouter(t *testing.T, httpClient *http.Client) *gin.Engi
}

api := humagin.NewWithGroup(router, apiGroup, humaConfig)
api.UseMiddleware(humamiddleware.NewAuthBridge(api, authService, nil, &config.Config{}))
api.UseMiddleware(humamiddleware.NewAuthBridge(api, authService, nil, nil, &config.Config{}))
RegisterHealth(api)
RegisterTemplates(api, templateService, nil)

Expand Down
2 changes: 1 addition & 1 deletion backend/internal/huma/huma.go
Original file line number Diff line number Diff line change
Expand Up @@ -223,7 +223,7 @@ func SetupAPI(router *gin.Engine, apiGroup *gin.RouterGroup, cfg *config.Config,
api := humagin.NewWithGroup(router, apiGroup, humaConfig)

// Add authentication middleware
api.UseMiddleware(middleware.NewAuthBridge(api, svc.Auth, svc.ApiKey, cfg))
api.UseMiddleware(middleware.NewAuthBridge(api, svc.Auth, svc.ApiKey, svc.Environment, cfg))

// Register all Huma handlers
registerHandlers(api, svc)
Expand Down
41 changes: 40 additions & 1 deletion backend/internal/huma/middleware/auth.go
Original file line number Diff line number Diff line change
Expand Up @@ -60,6 +60,10 @@ type operationProvider interface {
Operation() *huma.Operation
}

type environmentAccessTokenResolver interface {
ResolveEnvironmentByAccessToken(ctx context.Context, token string) (*models.Environment, error)
}

// parseSecurityRequirementsInternal extracts security requirements from a Huma operation.
func parseSecurityRequirementsInternal(api huma.API, ctx operationProvider) securityRequirements {
reqs := securityRequirements{}
Expand Down Expand Up @@ -122,6 +126,19 @@ func tryApiKeyAuthInternal(ctx huma.Context, apiKeyService *services.ApiKeyServi
return user, true
}

func tryEnvironmentAccessTokenAuth(ctx huma.Context, resolver environmentAccessTokenResolver, token string) (*models.User, bool) {
if resolver == nil || strings.TrimSpace(token) == "" {
return nil, false
}

env, err := resolver.ResolveEnvironmentByAccessToken(ctx.Context(), token)
if err != nil || env == nil {
return nil, false
}

return createEnvironmentSudoUser(env), true
}

// tryAgentAuthInternal checks if the request is from an authenticated agent.
// Returns a sudo agent user if the agent token is valid.
func tryAgentAuthInternal(ctx huma.Context, cfg *config.Config) (*models.User, bool) {
Expand Down Expand Up @@ -157,13 +174,22 @@ func createAgentSudoUserInternal() *models.User {
return &models.User{
BaseModel: models.BaseModel{ID: "agent"},
Email: new(email),
Username: "agent",
Roles: []string{"admin"},
}
}

func createEnvironmentSudoUser(env *models.Environment) *models.User {
return &models.User{
BaseModel: models.BaseModel{ID: "environment:" + env.ID},
Username: env.Name,
Roles: []string{"admin"},
}
}

// NewAuthBridge creates a Huma middleware that validates JWT tokens and
// enforces security requirements defined on operations.
func NewAuthBridge(api huma.API, authService *services.AuthService, apiKeyService *services.ApiKeyService, cfg *config.Config) func(ctx huma.Context, next func(huma.Context)) {
func NewAuthBridge(api huma.API, authService *services.AuthService, apiKeyService *services.ApiKeyService, envTokenResolver environmentAccessTokenResolver, cfg *config.Config) func(ctx huma.Context, next func(huma.Context)) {
return func(ctx huma.Context, next func(huma.Context)) {
if authService == nil {
next(ctx)
Expand Down Expand Up @@ -195,11 +221,24 @@ func NewAuthBridge(api huma.API, authService *services.AuthService, apiKeyServic
next(ctx)
return
}
if user, ok := tryEnvironmentAccessTokenAuth(ctx, envTokenResolver, ctx.Header(headerApiKey)); ok {
newCtx := setUserInContextInternal(ctx.Context(), user)
ctx = huma.WithContext(ctx, newCtx)
next(ctx)
return
}
// API key was present but invalid. Fail immediately.
_ = huma.WriteErr(api, ctx, http.StatusUnauthorized, "Unauthorized: invalid API key")
return
}

if user, ok := tryEnvironmentAccessTokenAuth(ctx, envTokenResolver, ctx.Header(headerAgentToken)); ok {
newCtx := setUserInContextInternal(ctx.Context(), user)
ctx = huma.WithContext(ctx, newCtx)
next(ctx)
return
}

if reqs.bearerAuth {
if user, ok := tryBearerAuthInternal(ctx, authService); ok {
newCtx := setUserInContextInternal(ctx.Context(), user)
Expand Down
76 changes: 76 additions & 0 deletions backend/internal/huma/middleware/auth_test.go
Original file line number Diff line number Diff line change
@@ -1,14 +1,90 @@
package middleware

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

"github.com/danielgtaylor/huma/v2"
"github.com/danielgtaylor/huma/v2/adapters/humagin"
"github.com/getarcaneapp/arcane/backend/internal/config"
"github.com/getarcaneapp/arcane/backend/internal/models"
"github.com/getarcaneapp/arcane/backend/internal/services"
"github.com/gin-gonic/gin"
"github.com/stretchr/testify/require"
)

type secureInput struct{}

type secureOutput struct {
Body struct {
UserID string `json:"userId"`
} `json:"body"`
}

type testEnvironmentAccessResolver struct {
env *models.Environment
}

func (r testEnvironmentAccessResolver) ResolveEnvironmentByAccessToken(_ context.Context, token string) (*models.Environment, error) {
if r.env != nil && r.env.AccessToken != nil && *r.env.AccessToken == token {
return r.env, nil
}
return nil, context.Canceled
}

func TestNewAuthBridge_AcceptsEnvironmentAccessTokenViaAPIKey(t *testing.T) {
gin.SetMode(gin.TestMode)

token := "env-access-token"
router := gin.New()
apiGroup := router.Group("/api")

humaConfig := huma.DefaultConfig("test", "1.0.0")
humaConfig.Components.SecuritySchemes = map[string]*huma.SecurityScheme{
"ApiKeyAuth": {
Type: "apiKey",
In: "header",
Name: "X-API-Key",
},
}

api := humagin.NewWithGroup(router, apiGroup, humaConfig)
api.UseMiddleware(NewAuthBridge(api, &services.AuthService{}, nil, testEnvironmentAccessResolver{
env: &models.Environment{
BaseModel: models.BaseModel{ID: "env-self"},
Name: "Self Target",
AccessToken: &token,
},
}, &config.Config{}))

huma.Register(api, huma.Operation{
OperationID: "secure",
Method: http.MethodGet,
Path: "/secure",
Security: []map[string][]string{{"ApiKeyAuth": {}}},
}, func(ctx context.Context, _ *secureInput) (*secureOutput, error) {
user, ok := GetCurrentUserFromContext(ctx)
require.True(t, ok)
require.Equal(t, "environment:env-self", user.ID)
require.Equal(t, "Self Target", user.Username)

resp := &secureOutput{}
resp.Body.UserID = user.ID
return resp, nil
})

req := httptest.NewRequest(http.MethodGet, "/api/secure", nil)
req.Header.Set("X-API-Key", token)
rec := httptest.NewRecorder()

router.ServeHTTP(rec, req)

require.Equal(t, http.StatusOK, rec.Code)
require.Contains(t, rec.Body.String(), "environment:env-self")
}

type testOperationProvider struct {
operation *huma.Operation
}
Expand Down
90 changes: 71 additions & 19 deletions backend/internal/middleware/auth_middleware.go
Original file line number Diff line number Diff line change
Expand Up @@ -31,11 +31,16 @@ type ApiKeyValidator interface {
ValidateApiKey(ctx context.Context, rawKey string) (*models.User, error)
}

type EnvironmentAccessTokenResolver interface {
ResolveEnvironmentByAccessToken(ctx context.Context, token string) (*models.Environment, error)
}

type AuthMiddleware struct {
authService *services.AuthService
apiKeyValidator ApiKeyValidator
cfg *config.Config
options AuthOptions
authService *services.AuthService
apiKeyValidator ApiKeyValidator
envTokenResolver EnvironmentAccessTokenResolver
cfg *config.Config
options AuthOptions
}

func NewAuthMiddleware(authService *services.AuthService, cfg *config.Config) *AuthMiddleware {
Expand All @@ -52,6 +57,12 @@ func (m *AuthMiddleware) WithApiKeyValidator(validator ApiKeyValidator) *AuthMid
return &clone
}

func (m *AuthMiddleware) WithEnvironmentAccessTokenResolver(resolver EnvironmentAccessTokenResolver) *AuthMiddleware {
clone := *m
clone.envTokenResolver = resolver
return &clone
}

func (m *AuthMiddleware) WithAdminNotRequired() *AuthMiddleware {
clone := *m
clone.options.AdminRequired = false
Expand Down Expand Up @@ -108,24 +119,37 @@ func (m *AuthMiddleware) agentAuth(ctx context.Context, c *gin.Context) {
}

func (m *AuthMiddleware) managerAuth(ctx context.Context, c *gin.Context) {
if agentToken := c.GetHeader(headerAgentToken); agentToken != "" {
if env, ok := m.resolveEnvironmentAccessToken(ctx, agentToken); ok {
environmentSudo(c, env)
return
}
}

// First, check for API key in X-API-Key header
if apiKey := c.GetHeader(headerApiKey); apiKey != "" && m.apiKeyValidator != nil {
user, err := m.apiKeyValidator.ValidateApiKey(ctx, apiKey)
if err == nil && user != nil {
isAdmin := pkgutils.UserHasRole(user.Roles, "admin")
if m.options.AdminRequired && !isAdmin {
c.JSON(http.StatusForbidden, models.APIError{
Code: "FORBIDDEN",
Message: "You don't have permission to access this resource",
})
c.Abort()
if apiKey := c.GetHeader(headerApiKey); apiKey != "" {
if m.apiKeyValidator != nil {
user, err := m.apiKeyValidator.ValidateApiKey(ctx, apiKey)
if err == nil && user != nil {
isAdmin := pkgutils.UserHasRole(user.Roles, "admin")
if m.options.AdminRequired && !isAdmin {
c.JSON(http.StatusForbidden, models.APIError{
Code: "FORBIDDEN",
Message: "You don't have permission to access this resource",
})
c.Abort()
return
}
c.Set("userID", user.ID)
c.Set("currentUser", user)
c.Set("userIsAdmin", isAdmin)
c.Set("authMethod", "api_key")
c.Next()
return
}
c.Set("userID", user.ID)
c.Set("currentUser", user)
c.Set("userIsAdmin", isAdmin)
c.Set("authMethod", "api_key")
c.Next()
}
if env, ok := m.resolveEnvironmentAccessToken(ctx, apiKey); ok {
environmentSudo(c, env)
return
}
// If API key validation fails, return unauthorized
Expand Down Expand Up @@ -191,6 +215,19 @@ func (m *AuthMiddleware) managerAuth(ctx context.Context, c *gin.Context) {
c.Next()
}

func (m *AuthMiddleware) resolveEnvironmentAccessToken(ctx context.Context, token string) (*models.Environment, bool) {
if m.envTokenResolver == nil {
return nil, false
}

env, err := m.envTokenResolver.ResolveEnvironmentByAccessToken(ctx, token)
if err != nil || env == nil {
return nil, false
}

return env, true
}

func isPreflight(c *gin.Context) bool {
return c.Request.Method == http.MethodOptions
}
Expand All @@ -199,11 +236,26 @@ func agentSudo(c *gin.Context) {
agentUser := &models.User{
BaseModel: models.BaseModel{ID: "agent"},
Email: new("agent@getarcane.app"),
Username: "agent",
Roles: []string{"admin"},
}
c.Set("userID", agentUser.ID)
c.Set("currentUser", agentUser)
c.Set("userIsAdmin", true)
c.Set("authMethod", "agent_token")
c.Next()
}

func environmentSudo(c *gin.Context, env *models.Environment) {
envUser := &models.User{
BaseModel: models.BaseModel{ID: "environment:" + env.ID},
Username: env.Name,
Roles: []string{"admin"},
}
c.Set("userID", envUser.ID)
c.Set("currentUser", envUser)
c.Set("userIsAdmin", true)
c.Set("authMethod", "environment_access_token")
c.Next()
}

Expand Down
Loading
Loading