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
11 changes: 10 additions & 1 deletion control-plane/internal/handlers/agentic/batch.go
Original file line number Diff line number Diff line change
Expand Up @@ -77,14 +77,23 @@ func BatchHandler(router *gin.Engine) gin.HandlerFunc {
return
}

// Copy auth headers
// Copy authentication and caller-identity headers. Nested requests
// traverse the router's middleware stack again, so both the
// credential and the identity from which authorization decisions
// are derived must be preserved.
httpReq.Header.Set("Content-Type", "application/json")
if apiKey := c.GetHeader("X-API-Key"); apiKey != "" {
httpReq.Header.Set("X-API-Key", apiKey)
}
if auth := c.GetHeader("Authorization"); auth != "" {
httpReq.Header.Set("Authorization", auth)
}
if callerAgentID := c.GetHeader("X-Caller-Agent-ID"); callerAgentID != "" {
httpReq.Header.Set("X-Caller-Agent-ID", callerAgentID)
}
if agentNodeID := c.GetHeader("X-Agent-Node-ID"); agentNodeID != "" {
httpReq.Header.Set("X-Agent-Node-ID", agentNodeID)
}

// Execute against the router
w := httptest.NewRecorder()
Expand Down
303 changes: 303 additions & 0 deletions control-plane/internal/handlers/agentic/batch_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,303 @@
package agentic

import (
"bytes"
"encoding/json"
"net/http"
"net/http/httptest"
"testing"

"github.com/Agent-Field/agentfield/control-plane/internal/server/middleware"
"github.com/gin-gonic/gin"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)

func TestBatchHandler_InvalidJSON(t *testing.T) {
router := gin.New()
router.POST("/api/v1/agentic/batch", BatchHandler(router))

req := httptest.NewRequest("POST", "/api/v1/agentic/batch", bytes.NewBufferString(`{invalid`))
req.Header.Set("Content-Type", "application/json")
rec := httptest.NewRecorder()
router.ServeHTTP(rec, req)

assert.Equal(t, http.StatusBadRequest, rec.Code)

var resp AgenticResponse
require.NoError(t, json.Unmarshal(rec.Body.Bytes(), &resp))
assert.False(t, resp.OK)
assert.Equal(t, "invalid_request", resp.Error.Code)
}

func TestBatchHandler_SingleOperation(t *testing.T) {
router := gin.New()

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Note

Advisory — non-blocking. Safe to merge and address in a follow-up.

Why non-blocking: This is a test coverage gap for middleware composition, not a demonstrated production defect or reachable vulnerability.

Add a server-wiring test for the claimed authenticated agentic surface

🔵 SUGGESTION · confidence 94%

Add a server-package test that builds the production route stack to verify the authenticated /api/v1/agentic surface. The current tests replace the server router with gin.New() and register handlers directly at /query or omit middleware, bypassing setupRoutes where APIKeyAuth is installed before registerAgenticRoutes. This misses the production composition boundary where batch subrequests re-enter router.ServeHTTP and must re-establish authenticated context through the middleware stack.

Evidence

Step 1: AgentFieldServer.setupRoutes calls applyGlobalMiddleware() before creating /api/v1 and calling registerAgenticRoutes(agentAPI); applyGlobalMiddleware installs middleware.APIKeyAuth on s.Router.
Step 2: APIKeyAuth rejects a request with a missing/invalid key before handlers run, and on success sets auth_level (and optional caller-agent context) before calling Next.
Step 3: In the added tests, TestBatchHandler_SingleOperation creates gin.New() and manually installs BatchHandler(router) (batch_test.go:33-37); TestQueryHandler_DefaultLimit installs QueryHandler at /query (query_handler_test.go:34-35); discover tests either directly set auth_level or omit middleware (discover_test.go:18-19, 86-89). None executes the server's router or APIKeyAuth.
Step 4: Production BatchHandler calls router.ServeHTTP for each nested operation (batch.go:91), where router is s.Router from registerAgenticRoutes; thus a real batch subrequest runs the global middleware again, while the synthetic tests only run unprotected synthetic endpoints. A regression in route/middleware composition or in the authenticated nested-request path would therefore pass all added tests while changing real /api/v1/agentic/* behavior.

💡 Suggested Fix

Add a server-level test around setupRoutes() using a non-empty configured API key. Assert 401 without the key and successful authenticated requests at /api/v1/agentic/discover, /api/v1/agentic/query, and /api/v1/agentic/batch; make the batch operation invoke an authenticated registered route to exercise the nested middleware pass.


Public API server-composition test coverage · confidence 94%

🤖 Reviewed by AgentField PR-AF

router.GET("/api/v1/health", func(c *gin.Context) {
c.JSON(http.StatusOK, gin.H{"status": "healthy"})
})
router.POST("/api/v1/agentic/batch", BatchHandler(router))

body := `{"operations":[{"id":"op1","method":"GET","path":"/api/v1/health"}]}`
req := httptest.NewRequest("POST", "/api/v1/agentic/batch", bytes.NewBufferString(body))
req.Header.Set("Content-Type", "application/json")
rec := httptest.NewRecorder()
router.ServeHTTP(rec, req)

require.Equal(t, http.StatusOK, rec.Code)

var resp AgenticResponse
require.NoError(t, json.Unmarshal(rec.Body.Bytes(), &resp))
assert.True(t, resp.OK)

data := resp.Data.(map[string]interface{})
results := data["results"].([]interface{})
assert.Len(t, results, 1)
assert.Equal(t, float64(1), data["total"])

result := results[0].(map[string]interface{})
assert.Equal(t, "op1", result["id"])
assert.Equal(t, float64(200), result["status"])
}

func TestBatchHandler_WithPOSTBody(t *testing.T) {
router := gin.New()
router.POST("/api/v1/echo", func(c *gin.Context) {
var body map[string]interface{}
if err := c.ShouldBindJSON(&body); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
body["echoed"] = true
c.JSON(http.StatusOK, body)
})
router.POST("/api/v1/agentic/batch", BatchHandler(router))

body := `{"operations":[
{"id":"op1","method":"POST","path":"/api/v1/echo","body":{"value":42}},
{"id":"op2","method":"POST","path":"/api/v1/echo","body":{"msg":"hello"}}
]}`

req := httptest.NewRequest("POST", "/api/v1/agentic/batch", bytes.NewBufferString(body))
req.Header.Set("Content-Type", "application/json")
rec := httptest.NewRecorder()
router.ServeHTTP(rec, req)

require.Equal(t, http.StatusOK, rec.Code)

var resp AgenticResponse
require.NoError(t, json.Unmarshal(rec.Body.Bytes(), &resp))
assert.True(t, resp.OK)

data := resp.Data.(map[string]interface{})
results := data["results"].([]interface{})
assert.Len(t, results, 2)

for _, r := range results {
result := r.(map[string]interface{})
assert.Equal(t, float64(200), result["status"])
}
}

func TestBatchHandler_SubrequestError(t *testing.T) {
router := gin.New()
router.GET("/api/v1/valid", func(c *gin.Context) {
c.JSON(http.StatusOK, gin.H{"ok": true})
})
router.NoRoute(func(c *gin.Context) {
c.JSON(http.StatusNotFound, gin.H{"error": "not_found"})
})
router.POST("/api/v1/agentic/batch", BatchHandler(router))

body := `{"operations":[
{"id":"good","method":"GET","path":"/api/v1/valid"},
{"id":"bad","method":"GET","path":"/api/v1/nonexistent"}
]}`

req := httptest.NewRequest("POST", "/api/v1/agentic/batch", bytes.NewBufferString(body))
req.Header.Set("Content-Type", "application/json")
rec := httptest.NewRecorder()
router.ServeHTTP(rec, req)

require.Equal(t, http.StatusOK, rec.Code)

var resp AgenticResponse
require.NoError(t, json.Unmarshal(rec.Body.Bytes(), &resp))
assert.True(t, resp.OK)

data := resp.Data.(map[string]interface{})
results := data["results"].([]interface{})
assert.Len(t, results, 2)

good := results[0].(map[string]interface{})
assert.Equal(t, float64(200), good["status"])

bad := results[1].(map[string]interface{})
assert.Equal(t, float64(404), bad["status"])
}

func TestBatchHandler_AuthHeaderPropagation(t *testing.T) {

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Caution

Must-fix before merge. Authorization bypass vulnerability: batch handler fails to propagate caller identity headers to nested requests, allowing circumvention of scope ownership checks on real production endpoints.

Batch tests miss the caller-identity authorization divergence

🟠 IMPORTANT · confidence 99%

Add a production-stack test that asserts identical status, caller identity, and inner error body for direct and batched requests carrying X-Caller-Agent-ID and X-Agent-Node-ID.

TestBatchHandler_AuthHeaderPropagation mounts no auth or permission middleware and only asserts the two forwarded credential headers, so it cannot expose the divergence. Because the nested request is a fresh http.Request that does not propagate X-Caller-Agent-ID or X-Agent-Node-ID, APIKeyAuth sets no caller_agent_id and MemoryPermissionMiddleware denies a direct actor-scope request as scope_ownership_denied while allowing the batched subrequest through the global/anonymous path.

Evidence

Step 1: Production registers POST /api/v1/agentic/batch with agentic.BatchHandler(s.Router) on the authenticated agentAPI group (routes_agentic.go), so a batch enters APIKeyAuth once and each operation re-enters the complete router.
Step 2: APIKeyAuth sets caller_agent_id only by reading X-Caller-Agent-ID, then X-Agent-Node-ID, from the current request (middleware/auth.go:132-138).
Step 3: BatchHandler builds a new request from only c.Request.Context() and copies Content-Type, X-API-Key, and Authorization; it does not copy either caller-identity header (batch.go:64-87). A new Gin request context is created when router.ServeHTTP handles that request, so the outer Gin value is not inherited.
Step 4: MemoryPermissionMiddleware resolves identity from those request headers (memory_permission.go:144-152) and rejects a mismatched actor scope only when a caller ID is present (memory_permission.go:75-86, 220-227). Thus a direct authenticated agent-b request with X-Actor-ID: agent-a is denied (also pinned by TestMemoryPermission_ScopeOwnership_ActorScope_DifferentAgent), while its batched subrequest has no caller/scope headers and takes the allowed global/anonymous path.
Step 5: The added test at lines 137-177 mounts no auth or permission middleware and asserts only the two forwarded credential headers, so it cannot fail for this production behavior change.

💡 Suggested Fix

Mount the batch endpoint beside a protected route under the real auth/permission middleware and compare direct versus batched requests with X-Caller-Agent-ID and X-Agent-Node-ID. The implementation needs an explicit policy for propagating the identity/scope headers (or an internal identity mechanism) before equivalence can pass; include unauthorized, 5xx-envelope, and canceled/deadline subcases in the regression test.


Batch authenticated nested request coverage · confidence 99%

🤖 Reviewed by AgentField PR-AF

router := gin.New()
router.GET("/api/v1/check-auth", func(c *gin.Context) {
key := c.GetHeader("X-API-Key")
auth := c.GetHeader("Authorization")
c.JSON(http.StatusOK, gin.H{
"has_key": key != "",
"has_auth": auth != "",
"key": key,
"auth": auth,
})
})
router.POST("/api/v1/agentic/batch", BatchHandler(router))

body := `{"operations":[{"id":"op1","method":"GET","path":"/api/v1/check-auth"}]}`
req := httptest.NewRequest("POST", "/api/v1/agentic/batch", bytes.NewBufferString(body))
req.Header.Set("Content-Type", "application/json")
req.Header.Set("X-API-Key", "test-key-123")
req.Header.Set("Authorization", "Bearer token-456")
rec := httptest.NewRecorder()
router.ServeHTTP(rec, req)

require.Equal(t, http.StatusOK, rec.Code)

var resp AgenticResponse
require.NoError(t, json.Unmarshal(rec.Body.Bytes(), &resp))
assert.True(t, resp.OK)

data := resp.Data.(map[string]interface{})
results := data["results"].([]interface{})
require.Len(t, results, 1)

result := results[0].(map[string]interface{})
assert.Equal(t, float64(200), result["status"])

innerBody := result["body"].(map[string]interface{})
assert.Equal(t, true, innerBody["has_key"])
assert.Equal(t, true, innerBody["has_auth"])
assert.Equal(t, "test-key-123", innerBody["key"])
assert.Equal(t, "Bearer token-456", innerBody["auth"])
}

func TestBatchHandler_AtMaxBatchSize(t *testing.T) {
router := gin.New()
router.GET("/api/v1/ok", func(c *gin.Context) {
c.JSON(http.StatusOK, gin.H{"status": "ok"})
})
router.POST("/api/v1/agentic/batch", BatchHandler(router))

ops := make([]BatchOperation, maxBatchSize)
for i := range ops {
ops[i] = BatchOperation{
ID: "op",
Method: "GET",
Path: "/api/v1/ok",
}
}
bodyBytes, _ := json.Marshal(BatchRequest{Operations: ops})

req := httptest.NewRequest("POST", "/api/v1/agentic/batch", bytes.NewBuffer(bodyBytes))
req.Header.Set("Content-Type", "application/json")
rec := httptest.NewRecorder()
router.ServeHTTP(rec, req)

require.Equal(t, http.StatusOK, rec.Code)

var resp AgenticResponse
require.NoError(t, json.Unmarshal(rec.Body.Bytes(), &resp))
assert.True(t, resp.OK)

data := resp.Data.(map[string]interface{})
results := data["results"].([]interface{})
assert.Len(t, results, maxBatchSize)
assert.Equal(t, float64(maxBatchSize), data["total"])
}

func TestBatchHandler_ConcurrentResultIntegrity(t *testing.T) {
router := gin.New()
router.GET("/api/v1/item", func(c *gin.Context) {
c.JSON(http.StatusOK, gin.H{"item": true})
})
router.POST("/api/v1/agentic/batch", BatchHandler(router))

var ops []BatchOperation
for range 5 {
ops = append(ops, BatchOperation{
ID: "op",
Method: "GET",
Path: "/api/v1/item",
})
}
bodyBytes, _ := json.Marshal(BatchRequest{Operations: ops})

req := httptest.NewRequest("POST", "/api/v1/agentic/batch", bytes.NewBuffer(bodyBytes))
req.Header.Set("Content-Type", "application/json")
rec := httptest.NewRecorder()
router.ServeHTTP(rec, req)

require.Equal(t, http.StatusOK, rec.Code)

var resp AgenticResponse
require.NoError(t, json.Unmarshal(rec.Body.Bytes(), &resp))
assert.True(t, resp.OK)

data := resp.Data.(map[string]interface{})
results := data["results"].([]interface{})
assert.Len(t, results, 5)

for _, r := range results {
result := r.(map[string]interface{})
assert.Equal(t, float64(200), result["status"])
}
}

func TestBatchHandler_PreservesCallerIdentityThroughAuthenticatedRoute(t *testing.T) {
router := gin.New()
router.Use(middleware.APIKeyAuth(middleware.AuthConfig{APIKey: "test-api-key"}))
router.GET("/api/v1/protected", func(c *gin.Context) {
callerAgentID, ok := c.Get(string(middleware.CallerAgentIDKey))
if !ok {
c.JSON(http.StatusForbidden, gin.H{"error": "caller identity missing"})
return
}
c.JSON(http.StatusOK, gin.H{"caller_agent_id": callerAgentID})
})
router.POST("/api/v1/agentic/batch", BatchHandler(router))

body := `{"operations":[{"id":"protected","method":"GET","path":"/api/v1/protected"}]}`
req := httptest.NewRequest(http.MethodPost, "/api/v1/agentic/batch", bytes.NewBufferString(body))
req.Header.Set("Content-Type", "application/json")
req.Header.Set("X-API-Key", "test-api-key")
req.Header.Set("X-Caller-Agent-ID", "calling-agent")
rec := httptest.NewRecorder()
router.ServeHTTP(rec, req)

require.Equal(t, http.StatusOK, rec.Code)
var resp AgenticResponse
require.NoError(t, json.Unmarshal(rec.Body.Bytes(), &resp))
results := resp.Data.(map[string]interface{})["results"].([]interface{})
result := results[0].(map[string]interface{})
assert.Equal(t, float64(http.StatusOK), result["status"])
assert.Equal(t, "calling-agent", result["body"].(map[string]interface{})["caller_agent_id"])
}

func TestBatchHandler_ForwardsAgentNodeIDToSubRequests(t *testing.T) {
router := gin.New()
router.GET("/api/v1/echo-node", func(c *gin.Context) {
c.JSON(http.StatusOK, gin.H{"agent_node_id": c.GetHeader("X-Agent-Node-ID")})
})
router.POST("/api/v1/agentic/batch", BatchHandler(router))

body := `{"operations":[{"id":"echo","method":"GET","path":"/api/v1/echo-node"}]}`
req := httptest.NewRequest(http.MethodPost, "/api/v1/agentic/batch", bytes.NewBufferString(body))
req.Header.Set("Content-Type", "application/json")
req.Header.Set("X-Agent-Node-ID", "node-7")
rec := httptest.NewRecorder()
router.ServeHTTP(rec, req)

require.Equal(t, http.StatusOK, rec.Code)
var resp AgenticResponse
require.NoError(t, json.Unmarshal(rec.Body.Bytes(), &resp))
results := resp.Data.(map[string]interface{})["results"].([]interface{})
result := results[0].(map[string]interface{})
assert.Equal(t, float64(http.StatusOK), result["status"])
assert.Equal(t, "node-7", result["body"].(map[string]interface{})["agent_node_id"])
}
Loading
Loading