diff --git a/workspace-server/internal/handlers/terminal.go b/workspace-server/internal/handlers/terminal.go index 041a739f..62fe74b4 100644 --- a/workspace-server/internal/handlers/terminal.go +++ b/workspace-server/internal/handlers/terminal.go @@ -77,17 +77,26 @@ func (h *TerminalHandler) HandleConnect(c *gin.Context) { // A2A message-passing, so we apply the same hierarchy check here. // GH#756/#1609 security fix: if the caller claims a specific workspace // identity (X-Workspace-ID header), the bearer token — if present — must - // belong to that claimed workspace. ValidateAnyToken accepted ANY valid org - // token, allowing Workspace A to forge X-Workspace-ID: B and reach B's - // terminal if A held any valid token. ValidateToken binds the token to - // the claimed workspace identity. + // belong to that claimed workspace. Previously ValidateAnyToken accepted + // ANY valid org token, allowing Workspace A to forge X-Workspace-ID: B + // and reach B's terminal if A held any valid token. ValidateToken binds + // the workspace-scoped token to the claimed workspace identity. Org-level + // tokens are handled separately via the org_token_id context key. callerID := c.GetHeader("X-Workspace-ID") if callerID != "" && callerID != workspaceID { tok := wsauth.BearerTokenFromHeader(c.GetHeader("Authorization")) if tok != "" { if err := wsauth.ValidateToken(ctx, db.DB, callerID, tok); err != nil { - c.JSON(http.StatusUnauthorized, gin.H{"error": "invalid token for claimed workspace"}) - return + // Org-scoped tokens (org_api_tokens) are validated at the org level + // by WorkspaceAuth and do not have a workspace_auth_tokens row, so + // ValidateToken always returns ErrInvalidToken for them. If WorkspaceAuth + // already validated an org token (org_token_id set in context), trust + // the X-Workspace-ID claim — the hierarchy is enforced by + // canCommunicateCheck below. Reject everything else. + if c.GetString("org_token_id") == "" { + c.JSON(http.StatusUnauthorized, gin.H{"error": "invalid token for claimed workspace"}) + return + } } } if !canCommunicateCheck(callerID, workspaceID) { diff --git a/workspace-server/internal/handlers/terminal_test.go b/workspace-server/internal/handlers/terminal_test.go index 326354c6..4a3f29fd 100644 --- a/workspace-server/internal/handlers/terminal_test.go +++ b/workspace-server/internal/handlers/terminal_test.go @@ -455,3 +455,38 @@ func TestTerminalConnect_KI005_AllowsSiblingWorkspace(t *testing.T) { } } +// TestKI005_OrgToken_SkipsValidateToken verifies that when WorkspaceAuth already +// validated an org token (org_token_id set in gin context), the X-Workspace-ID +// claim is trusted without a workspace_auth_tokens lookup. The hierarchy is still +// enforced by canCommunicateCheck. Regression guard for the A2A routing regression +// introduced in GH#1885: internal routing uses org tokens which are not in +// workspace_auth_tokens, so ValidateToken would always fail for them. +func TestKI005_OrgToken_SkipsValidateToken(t *testing.T) { + setupTestDB(t) // no ValidateToken ExpectQuery — none should fire + prev := canCommunicateCheck + canCommunicateCheck = func(callerID, targetID string) bool { + // Simulate platform agent → target workspace (same org). + return callerID == "ws-platform" && targetID == "ws-target" + } + defer func() { canCommunicateCheck = prev }() + + h := NewTerminalHandler(nil) + w := httptest.NewRecorder() + c, _ := gin.CreateTestContext(w) + c.Params = gin.Params{{Key: "id", Value: "ws-target"}} + c.Request = httptest.NewRequest("GET", "/workspaces/ws-target/terminal", nil) + c.Request.Header.Set("X-Workspace-ID", "ws-platform") + c.Request.Header.Set("Authorization", "Bearer org-token-abc123") + // Simulate WorkspaceAuth having validated the org token (orgtoken.Validate + // succeeded). HandleConnect must skip ValidateToken and trust the claim. + c.Set("org_token_id", "tok-org-abc") + + h.HandleConnect(c) + + // Org token path: ValidateToken skipped → canCommunicateCheck=true → + // falls through to Docker path → 503 nil-docker (no Docker client). + if w.Code != http.StatusServiceUnavailable { + t.Errorf("org-token A2A: got %d, want 503 nil-docker (%s)", w.Code, w.Body.String()) + } +} +