Files
multica/server/internal/handler/agent_access.go
LinYushen 2348301d2b fix: gate private squad leader bypass (MUL-2860) (#3648)
* fix: gate private squad leader from being triggered by unauthorized members

Add canEnqueueSquadLeader helper that checks canAccessPrivateAgent before
allowing a squad leader to be enqueued. Gate all EnqueueTaskForSquadLeader
call sites:

1. enqueueSquadLeaderTask (comment trigger, assign trigger, backlog→todo)
2. triggerChildDoneSquad (child-done → parent squad leader)
3. autopilot.go (defensive comment; actor is always agent → always passes)

Also fix validateAssigneePair's squad branch to run canAccessPrivateAgent
on the squad leader, returning 403 'cannot assign to squad with private
leader' when the actor lacks access.

Thread actorType/actorID through notifyParentOfChildDone →
dispatchParentAssigneeTrigger → triggerChildDoneSquad so the child-done
path can enforce the private-leader gate.

Regression tests:
- Plain member blocked from create-issue to private-leader squad (403)
- Plain member blocked from update-issue to private-leader squad (403)
- Owner allowed to assign private-leader squad
- Plain member comment on squad-assigned issue doesn't trigger private leader
- Child-done by plain member doesn't trigger parent's private leader
- Agent actor can still trigger private leader via comment

Closes MUL-2860

Co-authored-by: multica-agent <github@multica.ai>

* 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>

* test: add run_only legacy private-leader squad dispatch regression test

Covers the dispatchRunOnly path explicitly, complementing the existing
create_issue dispatch test. Both dispatch branches now have direct test
coverage for the private-leader fail-closed gate.

Co-authored-by: multica-agent <github@multica.ai>

---------

Co-authored-by: multica-agent <github@multica.ai>
2026-06-02 15:47:57 +08:00

92 lines
3.2 KiB
Go
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
package handler
import (
"context"
"github.com/jackc/pgx/v5/pgtype"
"github.com/multica-ai/multica/server/internal/util"
db "github.com/multica-ai/multica/server/pkg/db/generated"
)
// canAccessPrivateAgent gates the four protected surfaces for private
// agents: chat / @-mention dispatch, viewing the agent's history, editing
// configuration, and deletion.
//
// Public agents are unrestricted — the predicate returns true unconditionally.
//
// Agent-to-agent traffic is always allowed (actorType == "agent"); this is
// what preserves A2A collaboration even with private agents. The trust
// boundary is at member↔agent, not agent↔agent.
//
// For members, the implicit allowed_principals set is computed inline as:
// {agent.owner_id} workspace owner/admin members. Manual configuration of
// allowed_principals is not exposed in v1; future work can extend this set
// without changing call sites.
func (h *Handler) canAccessPrivateAgent(ctx context.Context, agent db.Agent, actorType, actorID, workspaceID string) bool {
if agent.Visibility != "private" {
return true
}
if actorType == "agent" {
return true
}
if uuidToString(agent.OwnerID) == actorID {
return true
}
member, err := h.getWorkspaceMember(ctx, actorID, workspaceID)
if err != nil {
return false
}
return roleAllowed(member.Role, "owner", "admin")
}
// memberAllowedForPrivateAgent is the pure predicate used by both
// canAccessPrivateAgent and the ListAgents filter loop. Caller must have
// already confirmed agent.Visibility == "private".
func memberAllowedForPrivateAgent(agent db.Agent, userID, role string) bool {
if roleAllowed(role, "owner", "admin") {
return true
}
return uuidToString(agent.OwnerID) == userID
}
// accessibleAgentIDs returns the set of agent IDs in the workspace the actor
// is allowed to see, for use by workspace-wide aggregation endpoints
// (run counts, activity histograms, task snapshots) that need to filter out
// private agents the member can't access. Returns nil and false on error.
func (h *Handler) accessibleAgentIDs(ctx context.Context, workspaceID, actorType, actorID, role string) (map[string]struct{}, bool) {
wsUUID, err := util.ParseUUID(workspaceID)
if err != nil {
return nil, false
}
agents, err := h.Queries.ListAllAgents(ctx, wsUUID)
if err != nil {
return nil, false
}
allowed := make(map[string]struct{}, len(agents))
for _, a := range agents {
if a.Visibility == "private" && actorType == "member" {
if !memberAllowedForPrivateAgent(a, actorID, role) {
continue
}
}
allowed[uuidToString(a.ID)] = struct{}{}
}
return allowed, true
}
// canEnqueueSquadLeader returns true when the given actor is allowed to
// trigger the squad's private leader. It loads the leader agent and delegates
// to canAccessPrivateAgent. Non-private leaders always pass. System-initiated
// triggers (e.g. github webhooks) pass by treating "system" like "agent".
func (h *Handler) canEnqueueSquadLeader(ctx context.Context, leaderID pgtype.UUID, actorType, actorID, workspaceID string) bool {
agent, err := h.Queries.GetAgent(ctx, leaderID)
if err != nil {
return false
}
if actorType == "system" {
actorType = "agent"
}
return h.canAccessPrivateAgent(ctx, agent, actorType, actorID, workspaceID)
}