diff --git a/server/internal/handler/autopilot.go b/server/internal/handler/autopilot.go index 908d07220..242c6b18e 100644 --- a/server/internal/handler/autopilot.go +++ b/server/internal/handler/autopilot.go @@ -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") diff --git a/server/internal/handler/autopilot_private_leader_test.go b/server/internal/handler/autopilot_private_leader_test.go new file mode 100644 index 000000000..3d0d11fd1 --- /dev/null +++ b/server/internal/handler/autopilot_private_leader_test.go @@ -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) + } +} diff --git a/server/internal/service/autopilot.go b/server/internal/service/autopilot.go index 5a086b222..e545a2ec6 100644 --- a/server/internal/service/autopilot.go +++ b/server/internal/service/autopilot.go @@ -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" +}