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
13 changes: 12 additions & 1 deletion control-plane/internal/config/config.go
Original file line number Diff line number Diff line change
Expand Up @@ -307,8 +307,12 @@ 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). If empty, admin routes fall back to the standard API key.
// policy management). When empty and InsecureDisableAdminAuth is false, admin
// routes will reject requests at startup or return 401.
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 @@ -658,6 +662,13 @@ 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
27 changes: 27 additions & 0 deletions control-plane/internal/config/config_additional_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -295,6 +295,7 @@ 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 @@ -379,6 +380,7 @@ 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 @@ -444,6 +446,31 @@ func TestApplyEnvOverridesAPIAuthInsecureDisable(t *testing.T) {
})
}

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
32 changes: 28 additions & 4 deletions control-plane/internal/server/middleware/auth.go
Original file line number Diff line number Diff line change
Expand Up @@ -173,20 +173,44 @@ 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(adminToken string) gin.HandlerFunc {
func AdminTokenAuth(config AdminAuthConfig) gin.HandlerFunc {
return func(c *gin.Context) {
if adminToken == "" {
// Unauthenticated admin operation must be explicitly enabled at startup.
if config.AdminToken == "" && config.InsecureDisableAdminAuth {
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(adminToken)) != 1 {
if subtle.ConstantTimeCompare([]byte(token), []byte(config.AdminToken)) != 1 {
c.AbortWithStatusJSON(http.StatusForbidden, gin.H{
"error": "forbidden",
"message": "admin token required for this operation (use X-Admin-Token header)",
Expand Down
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
package middleware

import (
"encoding/json"
"net/http"
"net/http/httptest"
"testing"
Expand All @@ -9,42 +10,67 @@ import (
"github.com/stretchr/testify/require"
)

func TestValidateAdminTokenAuth_RejectsImplicitDisable(t *testing.T) {
err := ValidateAdminTokenAuth(AdminAuthConfig{})
require.Error(t, err)
require.Contains(t, err.Error(), "AGENTFIELD_INSECURE_ADMIN_NO_TOKEN=true")
}

func TestValidateAdminTokenAuth_AcceptsExplicitInsecure(t *testing.T) {
err := ValidateAdminTokenAuth(AdminAuthConfig{InsecureDisableAdminAuth: true})
require.NoError(t, err)
}

func TestValidateAdminTokenAuth_AcceptsConfiguredToken(t *testing.T) {
err := ValidateAdminTokenAuth(AdminAuthConfig{AdminToken: "admin-secret"})
require.NoError(t, err)
}

func TestAdminTokenAuth(t *testing.T) {
tests := []struct {
name string
adminToken string
config AdminAuthConfig
headerToken string
wantStatus int
wantBody string
}{
{
name: "disabled allows request through",
adminToken: "",
name: "empty token with explicit insecure allows request through",
config: AdminAuthConfig{InsecureDisableAdminAuth: true},
wantStatus: http.StatusOK,
},
{
name: "empty token without insecure flag returns 401",
config: AdminAuthConfig{},
wantStatus: http.StatusUnauthorized,
wantBody: "admin authentication required but admin token is not configured",
},
{
name: "valid admin token",
adminToken: "admin-secret",
config: AdminAuthConfig{AdminToken: "admin-secret"},
headerToken: "admin-secret",
wantStatus: http.StatusOK,
},
{
name: "missing admin token header",
adminToken: "admin-secret",
wantStatus: http.StatusForbidden,
name: "missing admin token header",
config: AdminAuthConfig{AdminToken: "admin-secret"},
wantStatus: http.StatusForbidden,
wantBody: "admin token required",
},
{
name: "invalid admin token header",
adminToken: "admin-secret",
config: AdminAuthConfig{AdminToken: "admin-secret"},
headerToken: "wrong-secret",
wantStatus: http.StatusForbidden,
wantBody: "admin token required",
},
}

for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
gin.SetMode(gin.TestMode)
router := gin.New()
router.Use(AdminTokenAuth(tt.adminToken))
router.Use(AdminTokenAuth(tt.config))
router.GET("/admin", func(c *gin.Context) {
c.String(http.StatusOK, "ok")
})
Expand All @@ -58,9 +84,49 @@ func TestAdminTokenAuth(t *testing.T) {
router.ServeHTTP(recorder, req)

require.Equal(t, tt.wantStatus, recorder.Code)
if tt.wantStatus == http.StatusForbidden {
require.Contains(t, recorder.Body.String(), "admin token required")
if tt.wantBody != "" {
require.Contains(t, recorder.Body.String(), tt.wantBody)
}
})
}
}

func TestAdminTokenAuth_EmptyTokenFailsClosed(t *testing.T) {
gin.SetMode(gin.TestMode)
router := gin.New()
router.Use(AdminTokenAuth(AdminAuthConfig{}))
router.GET("/admin/tags", func(c *gin.Context) {
c.String(http.StatusOK, "ok")
})

// Request with only a valid API key (simulated via no admin token header)
// should be rejected because admin token is not configured.
req := httptest.NewRequest(http.MethodGet, "/admin/tags", nil)
recorder := httptest.NewRecorder()
router.ServeHTTP(recorder, req)

require.Equal(t, http.StatusUnauthorized, recorder.Code)

var resp map[string]string
require.NoError(t, json.Unmarshal(recorder.Body.Bytes(), &resp))
require.Equal(t, "unauthorized", resp["error"])
require.Contains(t, resp["message"], "admin token is not configured")
}

func TestAdminTokenAuth_APIKeyOnlyReturns401WhenAdminTokenConfigured(t *testing.T) {
gin.SetMode(gin.TestMode)
router := gin.New()
router.Use(AdminTokenAuth(AdminAuthConfig{AdminToken: "admin-secret"}))
router.GET("/admin/policies", func(c *gin.Context) {
c.String(http.StatusOK, "ok")
})

// Request with API key header but NO admin token — should return 403
req := httptest.NewRequest(http.MethodGet, "/admin/policies", nil)
req.Header.Set("X-API-Key", "some-api-key")
recorder := httptest.NewRecorder()
router.ServeHTTP(recorder, req)

require.Equal(t, http.StatusForbidden, recorder.Code)
require.Contains(t, recorder.Body.String(), "admin token required")
}
5 changes: 4 additions & 1 deletion control-plane/internal/server/routes_admin.go
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,10 @@ func (s *AgentFieldServer) registerAdminRoutes(agentAPI *gin.RouterGroup) {
// Admin routes for tag approval and access policy management (VC-based authorization)
if s.config.Features.DID.Authorization.Enabled {
adminGroup := agentAPI.Group("")
adminGroup.Use(middleware.AdminTokenAuth(s.config.Features.DID.Authorization.AdminToken))
adminGroup.Use(middleware.AdminTokenAuth(middleware.AdminAuthConfig{
AdminToken: s.config.Features.DID.Authorization.AdminToken,
InsecureDisableAdminAuth: s.config.Features.DID.Authorization.InsecureDisableAdminAuth,
}))

// Tag approval admin routes
if s.tagApprovalService != nil {
Expand Down
25 changes: 22 additions & 3 deletions control-plane/internal/server/server.go
Original file line number Diff line number Diff line change
Expand Up @@ -104,6 +104,12 @@ func NewAgentFieldServer(cfg *config.Config) (*AgentFieldServer, error) {
return nil, fmt.Errorf("invalid API authentication configuration: %w", err)
}

if cfg.Features.DID.Authorization.Enabled {
if err := validateAdminAuthConfig(cfg.Features.DID.Authorization); err != nil {
return nil, fmt.Errorf("invalid admin authentication configuration: %w", err)
}
}

// Define agentfieldHome at the very top
agentfieldHome := os.Getenv("AGENTFIELD_HOME")
if agentfieldHome == "" {
Expand Down Expand Up @@ -285,9 +291,6 @@ func NewAgentFieldServer(cfg *config.Config) (*AgentFieldServer, error) {
didWebService = services.NewDIDWebService(domain, didService, storageProvider)

if cfg.Features.DID.Authorization.Enabled {
if cfg.Features.DID.Authorization.AdminToken == "" {
logger.Logger.Error().Msg("⚠️ SECURITY WARNING: Authorization is enabled but no admin_token is configured! Admin routes (tag approval, policy management) are unprotected. Set AGENTFIELD_AUTHORIZATION_ADMIN_TOKEN for production use.")
}
if cfg.Features.DID.Authorization.TagApprovalRules.DefaultMode == "" || cfg.Features.DID.Authorization.TagApprovalRules.DefaultMode == "auto" {
logger.Logger.Warn().Msg("⚠️ Tag approval default_mode is 'auto' — all agent tags will be auto-approved. Set tag_approval_rules.default_mode to 'manual' for production.")
}
Expand Down Expand Up @@ -550,6 +553,22 @@ func validateAPIAuthConfig(auth config.AuthConfig) error {
return nil
}

func validateAdminAuthConfig(authz config.AuthorizationConfig) error {
adminConfig := middleware.AdminAuthConfig{
AdminToken: authz.AdminToken,
InsecureDisableAdminAuth: authz.InsecureDisableAdminAuth,
}
if err := middleware.ValidateAdminTokenAuth(adminConfig); err != nil {
return err
}
if authz.AdminToken == "" {
logger.Logger.Warn().
Bool("insecure_disable_admin_auth", true).
Msg("SECURITY WARNING: Admin token authentication is explicitly disabled; admin routes (tag approval, policy management) are unauthenticated")
}
return nil
}

// configReloadFn returns a function that reloads config from the database,
// or nil if AGENTFIELD_CONFIG_SOURCE is not set to "db".
// The returned function acquires configMu to prevent data races with
Expand Down
27 changes: 27 additions & 0 deletions control-plane/internal/server/server_additional_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -139,6 +139,7 @@ func TestNewAgentFieldServer(t *testing.T) {
cfg.Features.DID.Authorization.DIDAuthEnabled = true
cfg.Features.DID.Authorization.Domain = "example.com"
cfg.Features.DID.Authorization.InternalToken = "internal-token"
cfg.Features.DID.Authorization.InsecureDisableAdminAuth = true
cfg.Features.DID.Authorization.TagApprovalRules.DefaultMode = "manual"
cfg.Features.Connector.Enabled = false

Expand Down Expand Up @@ -198,6 +199,31 @@ func TestValidateAPIAuthConfigWarnsWhenExplicitlyDisabled(t *testing.T) {
require.Contains(t, output.String(), "API key authentication is explicitly disabled")
}

func TestValidateAdminAuthConfigWarnsWhenExplicitlyDisabled(t *testing.T) {
previousLogger := logger.Logger
var output bytes.Buffer
logger.Logger = zerolog.New(&output)
t.Cleanup(func() { logger.Logger = previousLogger })

err := validateAdminAuthConfig(config.AuthorizationConfig{InsecureDisableAdminAuth: true})
require.NoError(t, err)
require.Contains(t, output.String(), `"level":"warn"`)
require.Contains(t, output.String(), "Admin token authentication is explicitly disabled")
}

func TestNewAgentFieldServerRejectsEmptyAdminToken(t *testing.T) {
cfg := baseConfigForDBTests()
cfg.API.Auth.APIKey = ""
cfg.API.Auth.InsecureDisableAuth = true
cfg.Features.DID.Enabled = false
cfg.Features.DID.Authorization.Enabled = true
cfg.Features.DID.Authorization.AdminToken = ""

srv, err := NewAgentFieldServer(&cfg)
require.Nil(t, srv)
require.ErrorContains(t, err, "AGENTFIELD_INSECURE_ADMIN_NO_TOKEN=true")
}

func TestStartAndStop(t *testing.T) {
cfg := baseConfigForDBTests()
cfg.UI.Enabled = false
Expand Down Expand Up @@ -360,6 +386,7 @@ func TestSetupRoutesWithDIDServices(t *testing.T) {
cfg.Features.DID.Authorization.DIDAuthEnabled = true
cfg.Features.DID.Authorization.Domain = "example.com"
cfg.Features.DID.Authorization.InternalToken = "internal-token"
cfg.Features.DID.Authorization.InsecureDisableAdminAuth = true
cfg.Features.DID.Authorization.TagApprovalRules.DefaultMode = "manual"
cfg.Features.Connector.Enabled = false

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -45,6 +45,7 @@ func TestNewAgentFieldServerCoversFallbacksAndOptionalServices(t *testing.T) {
cfg.Features.DID.Enabled = true
cfg.Features.DID.KeyAlgorithm = "Ed25519"
cfg.Features.DID.Authorization.Enabled = true
cfg.Features.DID.Authorization.InsecureDisableAdminAuth = true
cfg.Features.DID.Authorization.Domain = ""
cfg.Features.DID.Authorization.TagApprovalRules.DefaultMode = "auto"
cfg.Features.DID.Authorization.AccessPolicies = []config.AccessPolicyConfig{
Expand Down
Loading