-
Notifications
You must be signed in to change notification settings - Fork 318
fix(control-plane): authorize execution note writes(#420) #575
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: main
Are you sure you want to change the base?
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -2,12 +2,14 @@ package handlers | |
|
|
||
| import ( | ||
| "context" | ||
| "errors" | ||
| "fmt" | ||
| "net/http" | ||
| "strings" | ||
| "time" | ||
|
|
||
| "github.com/Agent-Field/agentfield/control-plane/internal/events" | ||
| "github.com/Agent-Field/agentfield/control-plane/internal/server/middleware" | ||
| "github.com/Agent-Field/agentfield/control-plane/pkg/types" | ||
|
|
||
| "github.com/gin-gonic/gin" | ||
|
|
@@ -20,6 +22,22 @@ type ExecutionNoteStorage interface { | |
| GetExecutionEventBus() *events.ExecutionEventBus | ||
| } | ||
|
|
||
| type executionNoteDIDDocumentLookup interface { | ||
| GetDIDDocument(ctx context.Context, did string) (*types.DIDDocumentRecord, error) | ||
| } | ||
|
|
||
| type executionNoteAgentDIDLister interface { | ||
| ListAgentDIDs(ctx context.Context) ([]*types.AgentDIDInfo, error) | ||
| } | ||
|
|
||
| type executionNoteAuthorizationError struct { | ||
| message string | ||
| } | ||
|
|
||
| func (e *executionNoteAuthorizationError) Error() string { | ||
| return e.message | ||
| } | ||
|
|
||
| // AddNoteRequest represents the request body for adding a note to an execution | ||
| type AddNoteRequest struct { | ||
| Message string `json:"message" binding:"required"` | ||
|
|
@@ -76,12 +94,21 @@ func AddExecutionNoteHandler(storageProvider ExecutionNoteStorage) gin.HandlerFu | |
| } | ||
|
|
||
| // Update the execution with the new note | ||
| ctx := context.Background() | ||
| ctx := c.Request.Context() | ||
| callerAgentID, err := executionNoteCallerAgentID(ctx, c, storageProvider) | ||
| if err != nil { | ||
| c.JSON(http.StatusInternalServerError, gin.H{"error": fmt.Sprintf("Failed to resolve caller identity: %v", err)}) | ||
| return | ||
| } | ||
|
|
||
| var runID string | ||
| updated, err := storageProvider.UpdateExecutionRecord(ctx, executionID, func(execution *types.Execution) (*types.Execution, error) { | ||
| if execution == nil { | ||
| return nil, fmt.Errorf("execution with ID %s not found", executionID) | ||
| } | ||
| if err := ensureExecutionNoteOwnership(callerAgentID, execution); err != nil { | ||
| return nil, err | ||
| } | ||
|
|
||
| // Store run ID for SSE event (run_id is the workflow ID equivalent) | ||
| runID = execution.RunID | ||
|
|
@@ -99,6 +126,14 @@ func AddExecutionNoteHandler(storageProvider ExecutionNoteStorage) gin.HandlerFu | |
| }) | ||
|
|
||
| if err != nil { | ||
| var authzErr *executionNoteAuthorizationError | ||
| if errors.As(err, &authzErr) { | ||
| c.JSON(http.StatusForbidden, gin.H{ | ||
| "error": "execution_ownership_mismatch", | ||
| "message": authzErr.message, | ||
| }) | ||
| return | ||
| } | ||
| c.JSON(http.StatusInternalServerError, gin.H{"error": fmt.Sprintf("Failed to add note: %v", err)}) | ||
| return | ||
| } | ||
|
|
@@ -130,6 +165,71 @@ func AddExecutionNoteHandler(storageProvider ExecutionNoteStorage) gin.HandlerFu | |
| } | ||
| } | ||
|
|
||
| func ensureExecutionNoteOwnership(callerAgentID string, execution *types.Execution) error { | ||
| ownerAgentID := strings.TrimSpace(execution.AgentNodeID) | ||
| if ownerAgentID == "" { | ||
| return &executionNoteAuthorizationError{message: "execution owner is required to add notes"} | ||
| } | ||
|
|
||
| if callerAgentID == "" { | ||
| return &executionNoteAuthorizationError{message: "caller agent identity is required to add notes to this execution"} | ||
| } | ||
| if callerAgentID != ownerAgentID { | ||
| return &executionNoteAuthorizationError{message: "this execution does not belong to the requesting agent"} | ||
| } | ||
|
|
||
| return nil | ||
| } | ||
|
|
||
| func executionNoteCallerAgentID(ctx context.Context, c *gin.Context, storageProvider ExecutionNoteStorage) (string, error) { | ||
|
AbirAbbas marked this conversation as resolved.
|
||
| if callerDID := strings.TrimSpace(middleware.GetVerifiedCallerDID(c)); callerDID != "" { | ||
| return resolveExecutionNoteAgentIDByDID(ctx, storageProvider, callerDID) | ||
| } | ||
|
|
||
| if callerID, exists := c.Get(string(middleware.CallerAgentIDKey)); exists { | ||
|
AbirAbbas marked this conversation as resolved.
|
||
| if id, ok := callerID.(string); ok { | ||
| if id = strings.TrimSpace(id); id != "" { | ||
| return id, nil | ||
| } | ||
| } | ||
| } | ||
| if agentID := strings.TrimSpace(c.GetHeader("X-Caller-Agent-ID")); agentID != "" { | ||
| return agentID, nil | ||
| } | ||
| if agentID := strings.TrimSpace(c.GetHeader("X-Agent-Node-ID")); agentID != "" { | ||
| return agentID, nil | ||
| } | ||
|
|
||
| return "", nil | ||
| } | ||
|
|
||
| func resolveExecutionNoteAgentIDByDID(ctx context.Context, storageProvider ExecutionNoteStorage, callerDID string) (string, error) { | ||
|
AbirAbbas marked this conversation as resolved.
|
||
| if lookup, ok := storageProvider.(executionNoteDIDDocumentLookup); ok { | ||
|
AbirAbbas marked this conversation as resolved.
|
||
| if record, err := lookup.GetDIDDocument(ctx, callerDID); err == nil && record != nil { | ||
|
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🟡 [MEDIUM] DID resolution lookup doesn't filter revoked records
Defense-in-depth concern, not a direct bypass for did:web:
Fix: After the
🤖 Reviewed by AgentField PR-AF
Contributor
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Agreed. Even though did:web revocation is already enforced by DID auth, the handler resolver should fail closed too. I’ll reject revoked DIDDocumentRecords, skip non-active AgentDIDInfo entries, and add coverage for revoked DID resolution. |
||
| return strings.TrimSpace(record.AgentID), nil | ||
| } | ||
| } | ||
|
|
||
| lister, ok := storageProvider.(executionNoteAgentDIDLister) | ||
| if !ok { | ||
| return "", nil | ||
| } | ||
| agentDIDs, err := lister.ListAgentDIDs(ctx) | ||
| if err != nil { | ||
| return "", fmt.Errorf("failed to resolve caller DID: %w", err) | ||
| } | ||
| for _, info := range agentDIDs { | ||
| if info == nil { | ||
| continue | ||
| } | ||
| if strings.TrimSpace(info.DID) == callerDID { | ||
| return strings.TrimSpace(info.AgentNodeID), nil | ||
| } | ||
| } | ||
|
|
||
| return "", nil | ||
| } | ||
|
|
||
| // GetExecutionNotesHandler handles GET /api/v1/executions/:execution_id/notes | ||
| // Retrieves notes for a specific execution with optional tag filtering | ||
| func GetExecutionNotesHandler(storageProvider ExecutionNoteStorage) gin.HandlerFunc { | ||
|
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🟠 [IMPORTANT] Read path leaks execution notes — no ownership check PR fixes IDOR on write path but Notes carry workflow state — phase progress, intermediate results, confidence reasoning. See No code comment, test, or PR description text explains whether the open read is intentional. Likely oversight given the write-side fix. Fix: Mirror write path — resolve caller via
🤖 Reviewed by AgentField PR-AF
Contributor
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Agreed. The read path should mirror the write path here. I’ll add caller resolution + ensureExecutionNoteOwnership to GetExecutionNotesHandler, return 403 on ownership mismatch, and add tests for owner vs non-owner reads. |
||
|
|
||
Uh oh!
There was an error while loading. Please reload this page.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
🔴 [CRITICAL] Raw-header fallback becomes sole identity source under default config
executionNoteCallerAgentIDhas 3 tiers: (1) verified DID, (2)CallerAgentIDKeycontext value, (3) rawX-Caller-Agent-ID/X-Agent-Node-IDheaders. Tiers 1 & 2 are config-gated. Under defaults (APIKey="",did_auth_enabled=false), both are skipped — tier 3 accepts attacker-controlled headers with zero validation, flowing directly toensureExecutionNoteOwnership.Evidence:
routes_middleware.go:77— DID middleware not installed when disabledauth.go:26-28— APIKeyAuth no-ops whenAPIKey=="", never sets context keyexecution_notes.go:196-201— raw header read with no validationauth.go:118-124), so the fallback is either dead code or active bypass — never legitimateFix: Delete the raw-header fallback. Add a startup assertion in
routes_middleware.gothat refuses to register write routes when both auth methods are disabled.Compound Analysis· confidence 95%🤖 Reviewed by AgentField PR-AF
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Agreed. This is a real bypass under the default config. I’ll remove the raw-header fallback, require caller identity to come from verified DID or authenticated middleware context only, and add tests for the spoofed-header/no-auth case. I’ll also add a startup/config guard so the write route fails closed when both API key auth and DID auth are disabled.
in this same PR right?