mirror of
https://github.com/multica-ai/multica.git
synced 2026-07-06 05:49:12 +02:00
* 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>
92 lines
3.2 KiB
Go
92 lines
3.2 KiB
Go
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)
|
||
}
|