Files
multica/server/internal/handler/agent_access.go
Bohan Jiang 2e9a3d0119 fix(dashboard): stop leaking private agents from the per-agent rollups (MUL-5409) (#6051)
* fix(dashboard): stop leaking private agents from the per-agent rollups (MUL-5409)

Three per-agent dashboard endpoints authorized on workspace membership alone
and returned a bare agent_id for every agent in the workspace:

  GET /api/dashboard/usage/by-agent
  GET /api/dashboard/agent-runtime
  GET /api/dashboard/failures/by-agent

That told a plain member which private agents exist, how much they spend, how
long they run and what they fail on. The client already collapsed those rows,
but client-side filtering is decoration — one curl bypasses it.

Server: rows for agents the caller may not view are now folded onto a
`__restricted_agents__` sentinel before serialization, via one shared helper.
Folded, not dropped: each of these responses is the per-agent half of a pair
whose other half (usage/daily, runtime/daily, failures/daily) is workspace-
scoped and unfiltered, so dropping rows would make the per-agent breakdown stop
adding up to the KPIs rendered beside it. The bucket keeps its provider/model
and failure_reason dimensions — both are derivable by subtraction from the
workspace-level series anyway, and the client needs them to price the bucket and
compute its failure rate.

Owner/admin and agent actors short-circuit before any extra query, so the
governance view is unchanged. Hard-deleted agents are deliberately excluded from
the fold — they have no visibility left to protect and keep their own bucket.

Client: fixes the mislabelling that shipped with this. A live private agent was
folded into a row labelled "Deleted agents" with a bin icon, and counted into
the card's "· N deleted" caption — telling the user N agents were deleted when
they are alive and still running. The restricted bucket is now its own row with
neutral copy, keeps its real Time / Tasks values, and counts as neither an agent
nor a deletion in the caption.

Tests: handler regression coverage proving a plain member's response contains no
private agent UUID while every aggregate still sums to the privileged view's
total, plus view coverage for the label and caption.

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

* fix(dashboard): fold hidden system agent carriers into the restricted bucket (MUL-5409)

Review follow-up. The first pass built the restricted set from ListAllAgents,
which filters `kind = 'user'` — so it missed the hidden `kind = 'system'`
execution carriers behind agent-builder sessions.

Those carriers run real tasks and book real usage, and all three rollups
aggregate over agent_task_queue / task_usage with no kind filter of their own.
No list endpoint returns them either (ListAgents / ListAllAgents both filter on
kind), so no client can resolve one to a name. Net effect: the exact two bugs
this PR exists to fix, still live — a bare UUID exposing one member's builder
session (with its spend and failure profile) to every other member, and, once
the agent list loads, a running agent folded into the client's "Deleted agents"
row and counted as a deletion.

restrictedAgentIDs now reads a new ListAllAgentsAnyKind and restricts every
non-user-kind agent for EVERYONE, workspace owner included — nobody can name
one, so a bare UUID row is wrong for every viewer, not just plain members. User
agents keep the per-viewer visibility rule. The invocation-target lookup is
skipped for actors that rule can never restrict (agent actors, owner/admin), so
the added cost is one indexed list query.

Because the bucket now also carries carriers that are nobody's "restricted"
agents, its copy drops to the neutral "Other agents" — the same wording the
Errors card already uses for its equivalent row, in all four locales.

Adds a regression test seeding a kind=system private carrier with tasks and
usage: no endpoint may return its UUID to either the plain member OR the
workspace owner who owns it, a bucket must be present to carry its rows, and
every metric delta (tokens, seconds, tasks, failures, runs) must equal its exact
contribution. Verified to fail on all three endpoints for both viewers with the
kind-filtered query restored.

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

---------

Co-authored-by: Bohan-J <bohan@devv.ai>
Co-authored-by: multica-agent <github@multica.ai>
2026-07-29 13:08:48 +08:00

460 lines
19 KiB
Go

package handler
import (
"context"
"net/http"
"github.com/jackc/pgx/v5/pgtype"
"github.com/multica-ai/multica/server/internal/util"
db "github.com/multica-ai/multica/server/pkg/db/generated"
)
// Agent invocation permission model (MUL-3963).
//
// Two distinct questions, previously conflated in canAccessPrivateAgent:
//
// - "can this actor SEE / open this agent in the UI" -> canAccessPrivateAgent
// - "can this actor TRIGGER a run for this agent" -> canInvokeAgent
//
// The invoke gate is the security-critical one: a workspace admin must NOT be
// able to invoke someone's private agent (and thereby use that owner's
// Composio/OAuth connections) just because they are an admin. Admin retains
// management + inventory visibility, not the ability to run.
//
// permission_mode drives invoke:
// - private -> only the agent owner may invoke; NO admin bypass, NO A2A bypass.
// - public_to -> the agent_invocation_target allow-list decides:
// * workspace target -> any workspace member (and workspace-internal
// agent/system principals) may invoke.
// * member target -> only the specific user may invoke.
// * team target -> reserved, inert in V1.
//
// A2A is judged by the top-of-chain human originator, never by the immediate
// agent actor: if user U triggers agent A and A @-mentions agent B, B is only
// invocable when U (the originator) is in B's allow-list. This prevents agents
// from forming a channel that bypasses the owner's white-list.
// canInvokeAgent reports whether a run may be enqueued for `agent` on behalf of
// the given actor. Judgement is by the *effective invoking user*:
// - member actor -> the member themselves (actorID)
// - agent actor -> the top-of-chain human originator (originatorUserID)
// - system actor -> the originator when one was resolved, else no user
//
// originatorUserID is the empty string when no human could be attributed. For
// private agents that means "deny" (unless the actor is the owner). For
// public_to agents, a workspace target still admits workspace-internal
// agent/system principals, but member/team targets fail closed without a
// matching human.
func (h *Handler) canInvokeAgent(ctx context.Context, agent db.Agent, actorType, actorID, originatorUserID, workspaceID string) bool {
effectiveUser := actorID
if actorType != "member" {
// agent / system: never trust the immediate principal, only the
// resolved human originator at the top of the chain.
effectiveUser = originatorUserID
}
// The agent owner may always invoke their own agent.
if effectiveUser != "" && uuidToString(agent.OwnerID) == effectiveUser {
return true
}
if agent.PermissionMode != "public_to" {
// private (or any unknown mode) is deny-by-default: no admin bypass,
// no A2A bypass. Only the owner branch above passes.
return false
}
targets, err := h.Queries.ListAgentInvocationTargets(ctx, agent.ID)
if err != nil {
return false
}
// Agents and system triggers are workspace-internal principals: a
// workspace target admits them even when no human originator resolved.
// This is a DELIBERATE, product-approved exception (MUL-3963): webhook /
// system / workspace-wide automation must be able to trigger a
// `public_to workspace` agent even though there is no human at the top of
// the chain. It is scoped tightly — it ONLY relaxes the *workspace* target.
// member/team targets still require a resolved human originator to match,
// so an unattributed agent/system trigger FAILS CLOSED against a
// member-/team-scoped private-ish allow-list and can never smuggle itself
// onto someone's specific-people grant.
workspaceBroad := actorType == "agent" || actorType == "system"
isWorkspaceMember := false
if effectiveUser != "" {
if _, err := h.getWorkspaceMember(ctx, effectiveUser, workspaceID); err == nil {
isWorkspaceMember = true
}
}
for _, t := range targets {
switch t.TargetType {
case "workspace":
if isWorkspaceMember || workspaceBroad {
return true
}
case "member":
// Requires a resolved human. agent/system triggers with no
// originator (effectiveUser == "") never match here — fail closed.
if effectiveUser != "" && uuidToString(t.TargetID) == effectiveUser {
return true
}
case "team":
// Reserved: team membership does not exist yet in V1, so team
// targets never admit anyone (also fail-closed for system/agent).
}
}
return false
}
// canAccessPrivateAgent gates the VIEW surfaces (list/detail navigation, chat
// transcript read, task-cancel authorization). It is NOT the trigger gate —
// see canInvokeAgent for that.
//
// Rules:
// - agent actors always pass (A2A collaboration + inspection preserved).
// - the agent owner always passes.
// - workspace owner/admin pass (governance / inventory visibility retained).
// - a regular member passes for a public_to agent only when they hit a
// workspace or member target; private agents stay owner+admin only.
func (h *Handler) canAccessPrivateAgent(ctx context.Context, agent db.Agent, actorType, actorID, workspaceID string) bool {
if actorType == "agent" {
return true
}
if uuidToString(agent.OwnerID) == actorID {
return true
}
member, err := h.getWorkspaceMember(ctx, actorID, workspaceID)
if err != nil {
return false
}
if roleAllowed(member.Role, "owner", "admin") {
return true
}
if agent.PermissionMode != "public_to" {
return false
}
targets, err := h.Queries.ListAgentInvocationTargets(ctx, agent.ID)
if err != nil {
return false
}
return memberHitsInvocationTargets(targets, actorID)
}
// memberHitsInvocationTargets is the pure predicate deciding whether a regular
// member is on a public_to agent's allow-list, used by both the single-agent
// view gate and the ListAgents batch filter. A workspace target admits any
// member; a member target admits the matching user; team targets are inert.
func memberHitsInvocationTargets(targets []db.AgentInvocationTarget, userID string) bool {
for _, t := range targets {
switch t.TargetType {
case "workspace":
return true
case "member":
if uuidToString(t.TargetID) == userID {
return true
}
}
}
return false
}
// memberAllowedToViewAgent is the ListAgents / aggregation filter predicate.
// Caller supplies the agent's already-batch-loaded invocation targets so the
// list endpoint avoids an N+1. Workspace owner/admin and the agent owner see
// everything; a regular member sees a public_to agent only when on its
// allow-list, and never sees other members' private agents.
func memberAllowedToViewAgent(agent db.Agent, targets []db.AgentInvocationTarget, userID, role string) bool {
if roleAllowed(role, "owner", "admin") {
return true
}
if uuidToString(agent.OwnerID) == userID {
return true
}
if agent.PermissionMode != "public_to" {
return false
}
return memberHitsInvocationTargets(targets, userID)
}
// invokeOriginatorFromRequest resolves the top-of-chain human user id for an
// invocation initiated over HTTP. Members are their own originator; agent
// actors inherit the originator from the task named by the X-Task-ID header
// (set by the CLI on every request), matching
// TaskService.resolveOriginatorFromTriggerComment. Returns "" when no human
// can be attributed — canInvokeAgent then fails closed for member/team targets.
func (h *Handler) invokeOriginatorFromRequest(r *http.Request, actorType, actorID string) string {
if actorType == "member" {
return actorID
}
if actorType == "agent" {
if taskIDHeader := r.Header.Get("X-Task-ID"); taskIDHeader != "" {
if taskUUID, err := util.ParseUUID(taskIDHeader); err == nil {
if task, err := h.Queries.GetAgentTask(r.Context(), taskUUID); err == nil {
return uuidToString(task.OriginatorUserID)
}
}
}
}
return ""
}
// autopilotDelegationAuthority resolves the effective invoking human for the A2A
// invoke gate (canInvokeAgent) when a trigger comment is authored by an
// UNATTRIBUTED autopilot dispatch delegating mid-chain on the very issue that
// autopilot created (MUL-4857).
//
// A schedule/webhook autopilot run carries no top-of-chain human originator by
// design (MUL-4302). Without one, canInvokeAgent fails closed for the DEFAULT
// private agent (and member-scoped public_to agents), so a mid-run @mention
// delegation silently enqueues nothing — even though the SAME autopilot's first
// dispatch was admitted via the autopilot creator (autopilotAdmitInvoke ->
// canCreatorInvokeAgent). This restores exactly that first-dispatch authority for
// the mid-run delegation path: the gate still runs, now keyed on the autopilot
// creator, so NO unrestricted agent-to-agent bypass is reopened.
//
// SECURITY (confused-deputy defense, review MUL-4857): the creator's authority is
// granted ONLY when the SPEAKING run is verified to be doing work on THIS very
// autopilot-created issue. Binding to issue provenance + an empty originator alone
// is NOT enough — an agent running a task on some OTHER issue can legitimately
// comment here (comment.go CreateComment only stamps source_task_id when the
// authoring task's issue matches), so it could otherwise borrow a stranger
// autopilot creator's invoke rights just by mentioning on that autopilot's issue.
// `task` MUST therefore come from a server-trusted source — the X-Task-ID header
// on create/preview, or the stored comment.source_task_id on reconcile/edit —
// never a client-supplied field, and authority is granted only when ALL hold:
// - the comment author is an agent and IS the task's agent;
// - the issue is autopilot-origin (origin_type=autopilot, origin_id set);
// - the speaking task is running on THIS issue (task.issue_id == issue.id).
//
// That last check is the load-bearing one: every unattributed agent task whose
// issue_id is this autopilot issue is part of the work this autopilot set in
// motion (the dispatched leader task, or a descendant it @mentioned into being),
// while a foreign run's task carries a different issue_id and is rejected. Note we
// do NOT key on autopilot_run_id: in create_issue mode (the reported scenario) the
// leader task is enqueued through the ordinary issue-assignment path and carries
// no autopilot_run_id — the run links back via its own issue_id, not the task's.
//
// Any mismatch, missing lineage, or lookup error returns "" and the gate stays
// fail-closed. Only a MEMBER-created autopilot yields a user id; an agent-created
// autopilot has no human to key the gate on, and the existing agent-actor
// workspace-target exception in canInvokeAgent already covers the one case
// (public_to workspace) it should. The returned id is used for AUTHORIZATION only
// — the enqueued task's originator/attribution is computed separately and stays
// unattributed.
func (h *Handler) autopilotDelegationAuthority(ctx context.Context, issue db.Issue, authorType, authorID string, task db.AgentTaskQueue) string {
if authorType != "agent" {
return ""
}
if !issue.OriginType.Valid || issue.OriginType.String != "autopilot" || !issue.OriginID.Valid {
return ""
}
// The speaking run must be authored by THIS agent and doing work on THIS
// autopilot issue — not a foreign run that merely commented here.
if !task.AgentID.Valid || uuidToString(task.AgentID) != authorID {
return ""
}
if !task.IssueID.Valid || uuidToString(task.IssueID) != uuidToString(issue.ID) {
return ""
}
ap, err := h.Queries.GetAutopilotInWorkspace(ctx, db.GetAutopilotInWorkspaceParams{
ID: issue.OriginID,
WorkspaceID: issue.WorkspaceID,
})
if err != nil || ap.CreatedByType != "member" || !ap.CreatedByID.Valid {
return ""
}
return uuidToString(ap.CreatedByID)
}
// autopilotDelegationAuthorityFromRequest resolves the MUL-4857 delegation
// authority for a comment being created or previewed over HTTP. The speaking task
// is taken from the server-trusted X-Task-ID header (the CLI stamps it on every
// agent request); autopilotDelegationAuthority then verifies its lineage. Returns
// "" for member actors or when no valid task is named, keeping the gate closed.
func (h *Handler) autopilotDelegationAuthorityFromRequest(r *http.Request, issue db.Issue, actorType, actorID string) string {
if actorType != "agent" {
return ""
}
task, ok := h.taskFromRequestHeader(r)
if !ok {
return ""
}
return h.autopilotDelegationAuthority(r.Context(), issue, actorType, actorID, task)
}
// autopilotDelegationAuthorityFromComment resolves the MUL-4857 delegation
// authority when reconciling an already-persisted comment (retrigger after
// cancel). The speaking task is taken from the stored comment.source_task_id — the
// same server-trusted lineage CreateComment stamped for the authoring run — and
// its lineage is verified by autopilotDelegationAuthority.
func (h *Handler) autopilotDelegationAuthorityFromComment(ctx context.Context, issue db.Issue, comment db.Comment) string {
if comment.AuthorType != "agent" || !comment.SourceTaskID.Valid {
return ""
}
task, err := h.Queries.GetAgentTask(ctx, comment.SourceTaskID)
if err != nil {
return ""
}
return h.autopilotDelegationAuthority(ctx, issue, comment.AuthorType, uuidToString(comment.AuthorID), task)
}
// commentSourceTaskIDForIssue returns the agent's currently-executing task (from
// the X-Task-ID header) when it is running on the given issue, else an invalid
// UUID. This is the exact issue-scoped lineage CreateComment stamps onto
// source_task_id; a cross-issue (or missing) task yields invalid so the persisted
// lineage — and every authority/originator resolution that reads it — fails closed
// (MUL-4857).
func (h *Handler) commentSourceTaskIDForIssue(r *http.Request, issue db.Issue) pgtype.UUID {
task, ok := h.taskFromRequestHeader(r)
if !ok || !task.IssueID.Valid || uuidToString(task.IssueID) != uuidToString(issue.ID) {
return pgtype.UUID{}
}
return task.ID
}
// taskFromRequestHeader resolves the agent's currently-executing task from the
// X-Task-ID header (set by the CLI on every request). Returns ok=false when the
// header is absent, malformed, or names no existing task.
func (h *Handler) taskFromRequestHeader(r *http.Request) (db.AgentTaskQueue, bool) {
taskIDHeader := r.Header.Get("X-Task-ID")
if taskIDHeader == "" {
return db.AgentTaskQueue{}, false
}
taskUUID, err := util.ParseUUID(taskIDHeader)
if err != nil {
return db.AgentTaskQueue{}, false
}
task, err := h.Queries.GetAgentTask(r.Context(), taskUUID)
if err != nil {
return db.AgentTaskQueue{}, false
}
return task, true
}
// 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 / non-allow-listed 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
}
targetsByAgent, ok := h.loadInvocationTargetsByAgent(ctx, agents)
if !ok {
return nil, false
}
allowed := make(map[string]struct{}, len(agents))
for _, a := range agents {
if actorType == "member" {
if !memberAllowedToViewAgent(a, targetsByAgent[uuidToString(a.ID)], actorID, role) {
continue
}
}
allowed[uuidToString(a.ID)] = struct{}{}
}
return allowed, true
}
// restrictedAgentIDs returns the ids of agents that EXIST in the workspace but
// must not be named to this actor. Aggregation endpoints that cannot simply drop
// a row (the dashboard rollups, whose per-agent totals have to keep reconciling
// with the workspace-level series rendered beside them) use it to fold those
// agents onto a single sentinel instead.
//
// Two populations land in the set, for two different reasons:
//
// 1. Every `kind != "user"` agent — the hidden execution carriers behind agent
// builder sessions. These are restricted for EVERYONE, owner and admin
// included: no list endpoint returns them (ListAgents / ListAllAgents filter
// on kind), so no client can ever resolve one to a name, yet they run real
// tasks and book real usage that the rollups aggregate all the same. Left
// alone they surface as a bare UUID whose spend and failure profile belong
// to whoever opened that builder session.
// 2. For a plain member only, the user agents they may not view.
//
// Hard-deleted agents are deliberately NOT in the set: they are already absent
// from the agent table, have no visibility left to protect, and the dashboard
// renders them as their own "deleted agents" bucket — folding them in here
// would relabel a real deletion as a permission boundary.
//
// Because of (1) this always reads the agent table, but the per-agent
// invocation-target lookup is skipped for actors who see every user agent
// (agent actors, workspace owner/admin) since nothing there can change their
// answer.
func (h *Handler) restrictedAgentIDs(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.ListAllAgentsAnyKind(ctx, wsUUID)
if err != nil {
return nil, false
}
// Whether per-agent visibility can restrict anything for this actor at all.
judgeUserAgents := actorType == "member" && !roleAllowed(role, "owner", "admin")
var targetsByAgent map[string][]db.AgentInvocationTarget
if judgeUserAgents {
var ok bool
if targetsByAgent, ok = h.loadInvocationTargetsByAgent(ctx, agents); !ok {
return nil, false
}
}
restricted := make(map[string]struct{})
for _, a := range agents {
if a.Kind == "user" {
if !judgeUserAgents ||
memberAllowedToViewAgent(a, targetsByAgent[uuidToString(a.ID)], actorID, role) {
continue
}
}
restricted[uuidToString(a.ID)] = struct{}{}
}
return restricted, true
}
// loadInvocationTargetsByAgent batch-loads invocation targets for a set of
// agents and buckets them by agent id string. Avoids the per-agent query the
// list / aggregation paths would otherwise incur.
func (h *Handler) loadInvocationTargetsByAgent(ctx context.Context, agents []db.Agent) (map[string][]db.AgentInvocationTarget, bool) {
ids := make([]pgtype.UUID, 0, len(agents))
for _, a := range agents {
ids = append(ids, a.ID)
}
out := make(map[string][]db.AgentInvocationTarget, len(agents))
if len(ids) == 0 {
return out, true
}
rows, err := h.Queries.ListAgentInvocationTargetsByAgentIDs(ctx, ids)
if err != nil {
return nil, false
}
for _, row := range rows {
aid := uuidToString(row.AgentID)
out[aid] = append(out[aid], row)
}
return out, 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 canInvokeAgent so the leader-trigger path honours invocation permission
// exactly like a direct assignment/mention. Non-public leaders require owner /
// allow-list; system-initiated triggers (e.g. github webhooks) are judged as
// system principals (workspace target only).
func (h *Handler) canEnqueueSquadLeader(ctx context.Context, leaderID pgtype.UUID, actorType, actorID, originatorUserID, workspaceID string) bool {
agent, err := h.Queries.GetAgent(ctx, leaderID)
if err != nil {
return false
}
return h.canInvokeAgent(ctx, agent, actorType, actorID, originatorUserID, workspaceID)
}