fix: add private-leader gate to autopilot save + dispatch paths

- validateAutopilotAssignee squad branch: call canAccessPrivateAgent on
  the leader, returning 403 for unauthorized members at save time.
- service/autopilot.go: add canCreatorAccessPrivateLeader helper that
  mirrors the handler-level canAccessPrivateAgent logic (agent creators
  pass; member creators must be owner/admin or agent owner).
- Gate both dispatch paths (dispatchCreateIssue and dispatchRunOnly)
  with fail-closed check: if leader is private and creator lacks access,
  the run is skipped instead of triggering the private leader.

Regression tests:
- Plain member create autopilot to private-leader squad → 403
- Plain member update autopilot to private-leader squad → 403
- Owner create autopilot to private-leader squad → 201
- Owner-created autopilot dispatch → issue_created (positive)
- Legacy plain-member-created autopilot dispatch → skipped (fail-closed)

Co-authored-by: multica-agent <github@multica.ai>
This commit is contained in:
yushen
2026-06-02 15:40:48 +08:00
parent ad19a268a8
commit 6b82bb2441
3 changed files with 310 additions and 4 deletions

View File

@@ -12,6 +12,7 @@ import (
"github.com/go-chi/chi/v5"
"github.com/jackc/pgx/v5/pgtype"
"github.com/multica-ai/multica/server/internal/service"
"github.com/multica-ai/multica/server/internal/util"
db "github.com/multica-ai/multica/server/pkg/db/generated"
"github.com/multica-ai/multica/server/pkg/protocol"
)
@@ -890,6 +891,13 @@ func (h *Handler) validateAutopilotAssignee(w http.ResponseWriter, r *http.Reque
writeError(w, http.StatusUnprocessableEntity, "squad leader is archived; pick a different squad or rotate the leader before assigning autopilot")
return false
}
// Private-leader gate: the member configuring the autopilot must have
// access to the private leader, same as validateAssigneePair.
actorType, actorID := h.resolveActor(r, requestUserID(r), util.UUIDToString(workspaceID))
if !h.canAccessPrivateAgent(r.Context(), leader, actorType, actorID, util.UUIDToString(workspaceID)) {
writeError(w, http.StatusForbidden, "cannot assign autopilot to squad with private leader")
return false
}
return true
default:
writeError(w, http.StatusBadRequest, "assignee_type must be agent or squad")

View File

@@ -0,0 +1,269 @@
package handler
import (
"context"
"encoding/json"
"net/http"
"net/http/httptest"
"testing"
)
// TestCreateAutopilot_SquadPrivateLeader_PlainMemberBlocked verifies that a
// plain member cannot create an autopilot assigned to a squad whose leader
// is a private agent.
func TestCreateAutopilot_SquadPrivateLeader_PlainMemberBlocked(t *testing.T) {
if testHandler == nil || testPool == nil {
t.Skip("database not available")
}
ctx := context.Background()
agentID, _, memberID := privateAgentTestFixture(t)
var squadID string
if err := testPool.QueryRow(ctx, `
INSERT INTO squad (workspace_id, name, description, leader_id, creator_id)
VALUES ($1, 'AP Private Leader Create', '', $2, $3)
RETURNING id
`, testWorkspaceID, agentID, testUserID).Scan(&squadID); err != nil {
t.Fatalf("create squad: %v", err)
}
t.Cleanup(func() {
testPool.Exec(context.Background(), `DELETE FROM squad WHERE id = $1`, squadID)
})
w := httptest.NewRecorder()
r := newRequestAs(memberID, "POST", "/api/autopilots?workspace_id="+testWorkspaceID, map[string]any{
"title": "should be blocked",
"assignee_type": "squad",
"assignee_id": squadID,
"execution_mode": "create_issue",
})
testHandler.CreateAutopilot(w, r)
if w.Code != http.StatusForbidden {
t.Fatalf("expected 403, got %d: %s", w.Code, w.Body.String())
}
}
// TestUpdateAutopilot_SquadPrivateLeader_PlainMemberBlocked verifies that a
// plain member cannot update an autopilot to point at a private-leader squad.
func TestUpdateAutopilot_SquadPrivateLeader_PlainMemberBlocked(t *testing.T) {
if testHandler == nil || testPool == nil {
t.Skip("database not available")
}
ctx := context.Background()
agentID, _, memberID := privateAgentTestFixture(t)
// Create a non-private agent for the initial autopilot.
publicAgentID := createHandlerTestAgent(t, "ap-private-leader-public", nil)
var squadID string
if err := testPool.QueryRow(ctx, `
INSERT INTO squad (workspace_id, name, description, leader_id, creator_id)
VALUES ($1, 'AP Private Leader Update', '', $2, $3)
RETURNING id
`, testWorkspaceID, agentID, testUserID).Scan(&squadID); err != nil {
t.Fatalf("create squad: %v", err)
}
t.Cleanup(func() {
testPool.Exec(context.Background(), `DELETE FROM squad WHERE id = $1`, squadID)
})
// Create autopilot as workspace owner assigned to the public agent.
w := httptest.NewRecorder()
r := newRequest("POST", "/api/autopilots?workspace_id="+testWorkspaceID, map[string]any{
"title": "update target ap",
"assignee_id": publicAgentID,
"execution_mode": "create_issue",
})
testHandler.CreateAutopilot(w, r)
if w.Code != http.StatusCreated {
t.Fatalf("CreateAutopilot: expected 201, got %d: %s", w.Code, w.Body.String())
}
var ap AutopilotResponse
if err := json.NewDecoder(w.Body).Decode(&ap); err != nil {
t.Fatalf("decode: %v", err)
}
t.Cleanup(func() {
testPool.Exec(context.Background(), `DELETE FROM autopilot WHERE id = $1`, ap.ID)
})
// Plain member tries to update to the private-leader squad.
squadType := "squad"
w = httptest.NewRecorder()
r = newRequestAs(memberID, "PATCH", "/api/autopilots/"+ap.ID+"?workspace_id="+testWorkspaceID, map[string]any{
"assignee_type": squadType,
"assignee_id": squadID,
})
r = withURLParam(r, "id", ap.ID)
testHandler.UpdateAutopilot(w, r)
if w.Code != http.StatusForbidden {
t.Fatalf("expected 403, got %d: %s", w.Code, w.Body.String())
}
}
// TestCreateAutopilot_SquadPrivateLeader_OwnerAllowed verifies that a
// workspace owner CAN create an autopilot assigned to a private-leader squad.
func TestCreateAutopilot_SquadPrivateLeader_OwnerAllowed(t *testing.T) {
if testHandler == nil || testPool == nil {
t.Skip("database not available")
}
ctx := context.Background()
agentID, _, _ := privateAgentTestFixture(t)
var squadID string
if err := testPool.QueryRow(ctx, `
INSERT INTO squad (workspace_id, name, description, leader_id, creator_id)
VALUES ($1, 'AP Private Leader Owner', '', $2, $3)
RETURNING id
`, testWorkspaceID, agentID, testUserID).Scan(&squadID); err != nil {
t.Fatalf("create squad: %v", err)
}
t.Cleanup(func() {
testPool.Exec(context.Background(), `DELETE FROM squad WHERE id = $1`, squadID)
})
// testUserID is workspace owner — should succeed.
w := httptest.NewRecorder()
r := newRequest("POST", "/api/autopilots?workspace_id="+testWorkspaceID, map[string]any{
"title": "owner creates private-leader squad ap",
"assignee_type": "squad",
"assignee_id": squadID,
"execution_mode": "create_issue",
})
testHandler.CreateAutopilot(w, r)
if w.Code != http.StatusCreated {
t.Fatalf("expected 201, got %d: %s", w.Code, w.Body.String())
}
var ap AutopilotResponse
if err := json.NewDecoder(w.Body).Decode(&ap); err != nil {
t.Fatalf("decode: %v", err)
}
t.Cleanup(func() {
testPool.Exec(context.Background(), `DELETE FROM autopilot WHERE id = $1`, ap.ID)
})
}
// TestTriggerAutopilot_SquadPrivateLeader_OwnerCanDispatch verifies that a
// squad autopilot with private leader configured by an owner triggers
// correctly at dispatch time.
func TestTriggerAutopilot_SquadPrivateLeader_OwnerCanDispatch(t *testing.T) {
if testHandler == nil || testPool == nil {
t.Skip("database not available")
}
ctx := context.Background()
agentID, _, _ := privateAgentTestFixture(t)
var squadID string
if err := testPool.QueryRow(ctx, `
INSERT INTO squad (workspace_id, name, description, leader_id, creator_id)
VALUES ($1, 'AP Private Leader Dispatch', '', $2, $3)
RETURNING id
`, testWorkspaceID, agentID, testUserID).Scan(&squadID); err != nil {
t.Fatalf("create squad: %v", err)
}
t.Cleanup(func() {
testPool.Exec(context.Background(), `DELETE FROM squad WHERE id = $1`, squadID)
})
// Create autopilot as owner.
w := httptest.NewRecorder()
r := newRequest("POST", "/api/autopilots?workspace_id="+testWorkspaceID, map[string]any{
"title": "dispatch test private leader squad",
"assignee_type": "squad",
"assignee_id": squadID,
"execution_mode": "create_issue",
})
testHandler.CreateAutopilot(w, r)
if w.Code != http.StatusCreated {
t.Fatalf("CreateAutopilot: expected 201, got %d: %s", w.Code, w.Body.String())
}
var ap AutopilotResponse
if err := json.NewDecoder(w.Body).Decode(&ap); err != nil {
t.Fatalf("decode: %v", err)
}
t.Cleanup(func() {
testPool.Exec(context.Background(), `DELETE FROM autopilot_run WHERE autopilot_id = $1`, ap.ID)
testPool.Exec(context.Background(), `DELETE FROM agent_task_queue WHERE issue_id IN (SELECT id FROM issue WHERE workspace_id = $1 AND title LIKE 'dispatch test private leader squad%')`, testWorkspaceID)
testPool.Exec(context.Background(), `DELETE FROM issue WHERE workspace_id = $1 AND title LIKE 'dispatch test private leader squad%'`, testWorkspaceID)
testPool.Exec(context.Background(), `DELETE FROM autopilot WHERE id = $1`, ap.ID)
})
// Trigger — should succeed since owner created it.
w = httptest.NewRecorder()
r = newRequest("POST", "/api/autopilots/"+ap.ID+"/trigger?workspace_id="+testWorkspaceID, nil)
r = withURLParam(r, "id", ap.ID)
testHandler.TriggerAutopilot(w, r)
if w.Code != http.StatusOK {
t.Fatalf("TriggerAutopilot: expected 200, got %d: %s", w.Code, w.Body.String())
}
var run AutopilotRunResponse
if err := json.NewDecoder(w.Body).Decode(&run); err != nil {
t.Fatalf("decode run: %v", err)
}
if run.Status != "issue_created" {
t.Fatalf("run status = %q, want issue_created", run.Status)
}
}
// TestTriggerAutopilot_SquadPrivateLeader_PlainMemberCreator_Blocked verifies
// that if an autopilot pointing to a private-leader squad was somehow saved
// by a plain member (legacy data), dispatch is blocked at runtime.
func TestTriggerAutopilot_SquadPrivateLeader_PlainMemberCreator_Blocked(t *testing.T) {
if testHandler == nil || testPool == nil {
t.Skip("database not available")
}
ctx := context.Background()
agentID, _, memberID := privateAgentTestFixture(t)
var squadID string
if err := testPool.QueryRow(ctx, `
INSERT INTO squad (workspace_id, name, description, leader_id, creator_id)
VALUES ($1, 'AP Private Leader Blocked Dispatch', '', $2, $3)
RETURNING id
`, testWorkspaceID, agentID, testUserID).Scan(&squadID); err != nil {
t.Fatalf("create squad: %v", err)
}
t.Cleanup(func() {
testPool.Exec(context.Background(), `DELETE FROM squad WHERE id = $1`, squadID)
})
// Directly insert an autopilot with the plain member as creator
// (simulating legacy data before the save-time gate).
var apID string
if err := testPool.QueryRow(ctx, `
INSERT INTO autopilot (workspace_id, title, assignee_type, assignee_id,
execution_mode, created_by_type, created_by_id, status)
VALUES ($1, 'legacy illegal ap', 'squad', $2, 'create_issue', 'member', $3, 'active')
RETURNING id
`, testWorkspaceID, squadID, memberID).Scan(&apID); err != nil {
t.Fatalf("create autopilot: %v", err)
}
t.Cleanup(func() {
testPool.Exec(context.Background(), `DELETE FROM autopilot_run WHERE autopilot_id = $1`, apID)
testPool.Exec(context.Background(), `DELETE FROM autopilot WHERE id = $1`, apID)
})
// Trigger as workspace owner — the dispatch should fail because the
// autopilot's creator (plain member) cannot access the private leader.
w := httptest.NewRecorder()
r := newRequest("POST", "/api/autopilots/"+apID+"/trigger?workspace_id="+testWorkspaceID, nil)
r = withURLParam(r, "id", apID)
testHandler.TriggerAutopilot(w, r)
// Dispatch returns 200 with status=skipped (or failed) — the run is created
// but the dispatch is blocked by the private-leader gate.
if w.Code != http.StatusOK {
t.Fatalf("TriggerAutopilot: expected 200, got %d: %s", w.Code, w.Body.String())
}
var run AutopilotRunResponse
if err := json.NewDecoder(w.Body).Decode(&run); err != nil {
t.Fatalf("decode run: %v", err)
}
// The dispatch-time gate should cause a skipped or failed run.
if run.Status == "issue_created" || run.Status == "running" {
t.Fatalf("run status = %q; want skipped/failed since creator is plain member", run.Status)
}
}

View File

@@ -231,10 +231,12 @@ func (s *AutopilotService) dispatchCreateIssue(ctx context.Context, ap db.Autopi
// MUL-2429); agent-assigned autopilots go through the standard issue
// path. Both code paths land in agent_task_queue with agent_id = leader.
if ap.AssigneeType == "squad" {
// NOTE: private-leader gate not needed here — the autopilot actor is
// always the leader agent itself (actorType="agent"), which passes
// canAccessPrivateAgent unconditionally. If this assumption changes,
// add a canEnqueueSquadLeader check.
// Fail-closed private-leader gate: if the leader is private, verify
// the autopilot creator still has access. This catches illegitimate
// configs that were saved before the save-time gate was added.
if leader.Visibility == "private" && !s.canCreatorAccessPrivateLeader(ctx, ap, leader) {
return fmt.Errorf("autopilot creator cannot access private squad leader")
}
if _, err := s.TaskSvc.EnqueueTaskForSquadLeader(ctx, issue, leader.ID, pgtype.UUID{}); err != nil {
return fmt.Errorf("enqueue squad leader task: %w", err)
}
@@ -297,6 +299,11 @@ func (s *AutopilotService) dispatchRunOnly(ctx context.Context, ap db.Autopilot,
return &errDispatchSkipped{reason: formatAdmissionReason(ap, reason)}
}
// Fail-closed private-leader gate for squad autopilots.
if ap.AssigneeType == "squad" && agent.Visibility == "private" && !s.canCreatorAccessPrivateLeader(ctx, ap, agent) {
return &errDispatchSkipped{reason: formatAdmissionReason(ap, "creator cannot access private squad leader")}
}
task, err := s.Queries.CreateAutopilotTask(ctx, db.CreateAutopilotTaskParams{
AgentID: agent.ID,
RuntimeID: agent.RuntimeID,
@@ -1044,3 +1051,25 @@ func (s *AutopilotService) getIssuePrefix(workspaceID pgtype.UUID) string {
}
return ws.IssuePrefix
}
// canCreatorAccessPrivateLeader checks whether the autopilot's creator still
// has access to a private leader agent. Mirrors handler.canAccessPrivateAgent
// logic: agent creators always pass; member creators must be the agent owner
// or a workspace owner/admin. Returns false (fail-closed) on any lookup error.
func (s *AutopilotService) canCreatorAccessPrivateLeader(ctx context.Context, ap db.Autopilot, leader db.Agent) bool {
if ap.CreatedByType == "agent" {
return true
}
creatorID := util.UUIDToString(ap.CreatedByID)
if util.UUIDToString(leader.OwnerID) == creatorID {
return true
}
member, err := s.Queries.GetMemberByUserAndWorkspace(ctx, db.GetMemberByUserAndWorkspaceParams{
UserID: ap.CreatedByID,
WorkspaceID: ap.WorkspaceID,
})
if err != nil {
return false
}
return member.Role == "owner" || member.Role == "admin"
}