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
1 change: 0 additions & 1 deletion control-plane/cmd/af/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -216,7 +216,6 @@ func loadConfig(configFile string) (*config.Config, error) {
// This is needed because Viper's AutomaticEnv only works for keys that exist in config
_ = viper.BindEnv("api.auth.api_key", "AGENTFIELD_API_KEY")
_ = viper.BindEnv("api.auth.api_key", "AGENTFIELD_API_AUTH_API_KEY")
_ = viper.BindEnv("api.auth.insecure_disable_auth", "AGENTFIELD_INSECURE_DISABLE_AUTH", "AGENTFIELD_API_AUTH_INSECURE_DISABLE_AUTH")

// Get the directory where the binary is located for UI paths
execPath, err := os.Executable()
Expand Down
1 change: 0 additions & 1 deletion control-plane/cmd/agentfield-server/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -245,7 +245,6 @@ func loadConfig(configFile string) (*config.Config, error) {
// This is needed because Viper's AutomaticEnv only works for keys that exist in config
_ = viper.BindEnv("api.auth.api_key", "AGENTFIELD_API_KEY")
_ = viper.BindEnv("api.auth.api_key", "AGENTFIELD_API_AUTH_API_KEY")
_ = viper.BindEnv("api.auth.insecure_disable_auth", "AGENTFIELD_INSECURE_DISABLE_AUTH", "AGENTFIELD_API_AUTH_INSECURE_DISABLE_AUTH")
// AutomaticEnv makes viper.IsSet("features.did.enabled") return true once
// AGENTFIELD_FEATURES_DID_ENABLED is set, but Unmarshal won't actually
// populate the struct field unless the key is bound. Without this, setting
Expand Down
3 changes: 0 additions & 3 deletions control-plane/config/agentfield.yaml

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

3 changes: 0 additions & 3 deletions control-plane/config/docker-perf.yaml

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

25 changes: 2 additions & 23 deletions control-plane/internal/config/config.go
Original file line number Diff line number Diff line change
Expand Up @@ -307,12 +307,8 @@ type AuthorizationConfig struct {
// DefaultApprovalDurationHours is the default duration for permission approvals
DefaultApprovalDurationHours int `yaml:"default_approval_duration_hours" mapstructure:"default_approval_duration_hours" default:"720"`
// AdminToken is a separate token required for admin operations (tag approval,
// policy management). When empty and InsecureDisableAdminAuth is false, admin
// routes will reject requests at startup or return 401.
// policy management). If empty, admin routes fall back to the standard API key.
AdminToken string `yaml:"admin_token" mapstructure:"admin_token"`
// InsecureDisableAdminAuth explicitly permits running admin routes without
// an admin token. This should only be enabled for trusted local development.
InsecureDisableAdminAuth bool `yaml:"insecure_disable_admin_auth" mapstructure:"insecure_disable_admin_auth"`
// InternalToken is sent as Authorization: Bearer header when the control plane
// forwards execution requests to agents. Agents with RequireOriginAuth enabled
// validate this token, preventing direct access to their HTTP ports.
Expand Down Expand Up @@ -399,11 +395,8 @@ type CORSConfig struct {

// AuthConfig holds API authentication configuration.
type AuthConfig struct {
// APIKey is checked against headers or query params.
// APIKey is checked against headers or query params. Empty disables auth.
APIKey string `yaml:"api_key" mapstructure:"api_key"`
// InsecureDisableAuth explicitly permits running without API-key authentication.
// This should only be enabled for trusted local development environments.
InsecureDisableAuth bool `yaml:"insecure_disable_auth" mapstructure:"insecure_disable_auth"`
// SkipPaths allows bypassing auth for specific endpoints (e.g., health).
SkipPaths []string `yaml:"skip_paths" mapstructure:"skip_paths"`
}
Expand Down Expand Up @@ -537,13 +530,6 @@ func ApplyEnvOverrides(cfg *Config) {
if apiKey := os.Getenv("AGENTFIELD_API_AUTH_API_KEY"); apiKey != "" {
cfg.API.Auth.APIKey = apiKey
}
if val, ok := os.LookupEnv("AGENTFIELD_INSECURE_DISABLE_AUTH"); ok {
cfg.API.Auth.InsecureDisableAuth = parseEnvBool(val)
}
// Also support the nested path format for consistency.
if val, ok := os.LookupEnv("AGENTFIELD_API_AUTH_INSECURE_DISABLE_AUTH"); ok {
cfg.API.Auth.InsecureDisableAuth = parseEnvBool(val)
}

if val := os.Getenv("AGENTFIELD_REGISTRATION_SERVERLESS_DISCOVERY_ALLOWED_HOSTS"); val != "" {
parts := strings.Split(val, ",")
Expand Down Expand Up @@ -662,13 +648,6 @@ func ApplyEnvOverrides(cfg *Config) {
if val := os.Getenv("AGENTFIELD_AUTHORIZATION_ADMIN_TOKEN"); val != "" {
cfg.Features.DID.Authorization.AdminToken = val
}
if val, ok := os.LookupEnv("AGENTFIELD_INSECURE_ADMIN_NO_TOKEN"); ok {
cfg.Features.DID.Authorization.InsecureDisableAdminAuth = parseEnvBool(val)
}
// Also support the nested path format for consistency.
if val, ok := os.LookupEnv("AGENTFIELD_AUTHORIZATION_INSECURE_DISABLE_ADMIN_AUTH"); ok {
cfg.Features.DID.Authorization.InsecureDisableAdminAuth = parseEnvBool(val)
}
if val := os.Getenv("AGENTFIELD_AUTHORIZATION_INTERNAL_TOKEN"); val != "" {
cfg.Features.DID.Authorization.InternalToken = val
}
Expand Down
57 changes: 1 addition & 56 deletions control-plane/internal/config/config_additional_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -103,7 +103,7 @@ func TestLoadConfig(t *testing.T) {
t.Run("loads explicit config path", func(t *testing.T) {
dir := t.TempDir()
path := filepath.Join(dir, "custom.yaml")
if err := os.WriteFile(path, []byte("agentfield:\n port: 7777\napi:\n auth:\n insecure_disable_auth: true\n"), 0o644); err != nil {
if err := os.WriteFile(path, []byte("agentfield:\n port: 7777\n"), 0o644); err != nil {
t.Fatalf("write config: %v", err)
}

Expand All @@ -114,9 +114,6 @@ func TestLoadConfig(t *testing.T) {
if cfg.AgentField.Port != 7777 {
t.Fatalf("expected port 7777, got %d", cfg.AgentField.Port)
}
if !cfg.API.Auth.InsecureDisableAuth {
t.Fatal("expected insecure_disable_auth to load from YAML")
}
})

t.Run("falls back to config directory default path", func(t *testing.T) {
Expand Down Expand Up @@ -295,7 +292,6 @@ func TestApplyEnvOverrides(t *testing.T) {
"AGENTFIELD_AUTHORIZATION_DID_AUTH_ENABLED": "1",
"AGENTFIELD_AUTHORIZATION_DOMAIN": "auth.local",
"AGENTFIELD_AUTHORIZATION_ADMIN_TOKEN": "admin-token",
"AGENTFIELD_INSECURE_ADMIN_NO_TOKEN": "true",
"AGENTFIELD_AUTHORIZATION_INTERNAL_TOKEN": "internal-token",
"AGENTFIELD_AUTHORIZATION_DEFAULT_DENY": "true",
"AGENTFIELD_NODE_LOG_PROXY_CONNECT_TIMEOUT": "21s",
Expand Down Expand Up @@ -380,7 +376,6 @@ func TestApplyEnvOverrides(t *testing.T) {
!cfg.Features.DID.Authorization.DIDAuthEnabled ||
cfg.Features.DID.Authorization.Domain != "auth.local" ||
cfg.Features.DID.Authorization.AdminToken != "admin-token" ||
!cfg.Features.DID.Authorization.InsecureDisableAdminAuth ||
cfg.Features.DID.Authorization.InternalToken != "internal-token" ||
!cfg.Features.DID.Authorization.DefaultDeny {
t.Fatalf("unexpected authorization overrides: %+v", cfg.Features.DID.Authorization)
Expand Down Expand Up @@ -421,56 +416,6 @@ func TestApplyEnvOverrides(t *testing.T) {
}
}

func TestApplyEnvOverridesAPIAuthInsecureDisable(t *testing.T) {
t.Run("short environment name", func(t *testing.T) {
cfg := &Config{}
t.Setenv("AGENTFIELD_INSECURE_DISABLE_AUTH", "true")

ApplyEnvOverrides(cfg)

if !cfg.API.Auth.InsecureDisableAuth {
t.Fatal("expected insecure API auth disable from environment")
}
})

t.Run("nested environment name takes precedence", func(t *testing.T) {
cfg := &Config{}
t.Setenv("AGENTFIELD_INSECURE_DISABLE_AUTH", "true")
t.Setenv("AGENTFIELD_API_AUTH_INSECURE_DISABLE_AUTH", "false")

ApplyEnvOverrides(cfg)

if cfg.API.Auth.InsecureDisableAuth {
t.Fatal("expected nested insecure API auth setting to take precedence")
}
})
}

func TestApplyEnvOverridesInsecureAdminNoToken(t *testing.T) {
t.Run("short environment name", func(t *testing.T) {
cfg := &Config{}
t.Setenv("AGENTFIELD_INSECURE_ADMIN_NO_TOKEN", "true")

ApplyEnvOverrides(cfg)

if !cfg.Features.DID.Authorization.InsecureDisableAdminAuth {
t.Fatal("expected insecure admin auth disable from environment")
}
})

t.Run("nested environment name takes precedence", func(t *testing.T) {
cfg := &Config{}
t.Setenv("AGENTFIELD_INSECURE_ADMIN_NO_TOKEN", "true")
t.Setenv("AGENTFIELD_AUTHORIZATION_INSECURE_DISABLE_ADMIN_AUTH", "false")

ApplyEnvOverrides(cfg)

if cfg.Features.DID.Authorization.InsecureDisableAdminAuth {
t.Fatal("expected nested insecure admin auth setting to take precedence")
}
})
}

func TestApplyEnvOverridesIgnoresInvalidValues(t *testing.T) {
cfg := &Config{
AgentField: AgentFieldConfig{
Expand Down
55 changes: 6 additions & 49 deletions control-plane/internal/server/middleware/auth.go
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,6 @@ package middleware

import (
"crypto/subtle"
"errors"
"net/http"
"strings"

Expand All @@ -12,20 +11,11 @@ import (
// AuthConfig mirrors server configuration for HTTP authentication.
type AuthConfig struct {
APIKey string
InsecureDisableAuth bool
SkipPaths []string
SkipPrefixes []string
QueryAPIKeyAllowedPaths []string
}

// ValidateAPIKeyAuth rejects an implicit unauthenticated configuration.
func ValidateAPIKeyAuth(config AuthConfig) error {
if config.APIKey == "" && !config.InsecureDisableAuth {
return errors.New("API key is required; set AGENTFIELD_API_KEY or explicitly set AGENTFIELD_INSECURE_DISABLE_AUTH=true")
}
return nil
}

// APIKeyAuth enforces API key authentication via header or bearer token.
func APIKeyAuth(config AuthConfig) gin.HandlerFunc {
skipPathSet := make(map[string]struct{}, len(config.SkipPaths))
Expand All @@ -38,8 +28,8 @@ func APIKeyAuth(config AuthConfig) gin.HandlerFunc {
}

return func(c *gin.Context) {
// Unauthenticated operation must be explicitly enabled at startup.
if config.APIKey == "" && config.InsecureDisableAuth {
// No auth configured, allow everything.
if config.APIKey == "" {
c.Next()
return
}
Expand Down Expand Up @@ -103,15 +93,6 @@ func APIKeyAuth(config AuthConfig) gin.HandlerFunc {
return
}

if config.APIKey == "" {
c.Set("auth_level", "public")
c.AbortWithStatusJSON(http.StatusUnauthorized, gin.H{
"error": "unauthorized",
"message": "authentication required but API key is not configured on the server",
})
return
}

apiKey := ""

// Preferred: X-API-Key header
Expand Down Expand Up @@ -173,44 +154,20 @@ func queryAPIKeyAllowed(c *gin.Context, allowedPaths map[string]struct{}) bool {
return false
}

// AdminAuthConfig mirrors server configuration for admin token authentication.
type AdminAuthConfig struct {
AdminToken string
InsecureDisableAdminAuth bool
}

// ValidateAdminTokenAuth rejects an implicit unauthenticated admin configuration.
func ValidateAdminTokenAuth(config AdminAuthConfig) error {
if config.AdminToken == "" && !config.InsecureDisableAdminAuth {
return errors.New("admin token is required when authorization is enabled; set AGENTFIELD_AUTHORIZATION_ADMIN_TOKEN or explicitly set AGENTFIELD_INSECURE_ADMIN_NO_TOKEN=true")
}
return nil
}

// AdminTokenAuth enforces a separate admin token for admin routes.
// If adminToken is empty, the middleware is a no-op (falls back to global API key auth).
// Admin tokens must be sent via the X-Admin-Token header only (not Bearer) to avoid
// collision with the API key Bearer token namespace.
func AdminTokenAuth(config AdminAuthConfig) gin.HandlerFunc {
func AdminTokenAuth(adminToken string) gin.HandlerFunc {
return func(c *gin.Context) {
// Unauthenticated admin operation must be explicitly enabled at startup.
if config.AdminToken == "" && config.InsecureDisableAdminAuth {
if adminToken == "" {
c.Next()
return
}

// Fail-closed: if the admin token is not configured and insecure mode
// was not explicitly enabled, reject all requests.
if config.AdminToken == "" {
c.AbortWithStatusJSON(http.StatusUnauthorized, gin.H{
"error": "unauthorized",
"message": "admin authentication required but admin token is not configured on the server",
})
return
}

token := c.GetHeader("X-Admin-Token")

if subtle.ConstantTimeCompare([]byte(token), []byte(config.AdminToken)) != 1 {
if subtle.ConstantTimeCompare([]byte(token), []byte(adminToken)) != 1 {
c.AbortWithStatusJSON(http.StatusForbidden, gin.H{
"error": "forbidden",
"message": "admin token required for this operation (use X-Admin-Token header)",
Expand Down
Loading
Loading