mirror of
https://github.com/multica-ai/multica.git
synced 2026-06-17 03:38:32 +02:00
* feat(agents): gate private-agent surfaces with allowed_principals predicate
Tighten chat/@-mention, history, edit, and delete entry points so private
agents are only reachable by their owner or workspace owner/admin. Agent-to-
agent traffic still bypasses the gate so A2A collaboration keeps working.
- New canAccessPrivateAgent predicate in handler/agent_access.go; used by
comment.enqueueMentionedAgentTasks (replacing the inline check), GetAgent,
ListAgents (filter), ListAgentTasks, GetWorkspaceAgentRunCounts /
Activity30d / TaskSnapshot (workspace-wide aggregations no longer leak
private-agent existence + counts), chat.CreateChatSession,
chat.SendChatMessage (re-checks on every send so role changes can't leave
a stale session as a back-door), and autopilot.shouldSkipDispatch
(caller = autopilot creator).
- allowed_principals is computed inline as {agent.owner_id} ∪ workspace
owner/admin members. No new table — manual config is intentionally not
exposed in v1; the predicate is the extension seam.
- Front-end agent detail page distinguishes 403 (private agent the caller
can't access) from 404 (deleted/missing) and renders a "no access"
placeholder with a back-to-agents button.
- Go tests cover the pure predicate matrix + the four protected surfaces;
vitest passes for the affected views.
Co-authored-by: multica-agent <github@multica.ai>
* feat(agents): gate issue assignment with the private-agent predicate
Refactor validateAssigneePair to call the shared canAccessPrivateAgent
helper. This closes the back door where a plain member could assign a
private agent to an issue and let normal task dispatch run it, side-
stepping the chat / @-mention gate. Agent callers (X-Agent-ID) bypass
so A2A delegation onto a private assignee still works.
Add an integration test covering all three callers (workspace owner,
agent owner, plain member).
Co-authored-by: multica-agent <github@multica.ai>
* fix(agents): close three private-agent gate bypasses found in PR review
1. X-Agent-ID forgery (resolveActor): require X-Task-ID alongside
X-Agent-ID before trusting the agent identity. Without this a plain
workspace member could set X-Agent-ID to any visible agent UUID and
short-circuit the gate to "actor=agent, allow". Daemons already
pair the two headers, so legitimate A2A traffic is unaffected.
2. Chat history read path (chat.go): GetChatSession / ListChatMessages /
GetPendingChatTask / MarkChatSessionRead now go through a new
gateChatSessionForUser helper that re-applies canAccessPrivateAgent
after the ownership check, so a session creator whose role was later
downgraded loses transcript access. ListChatSessions and
ListPendingChatTasks filter their result sets by the same predicate.
3. Cross-workspace @mention (comment.enqueueMentionedAgentTasks):
resolve the mentioned agent via GetAgentInWorkspace scoped to the
issue's workspace so a UUID belonging to a different workspace's
private agent can't slip past the gate (the gate was being applied
against the current workspace's role table, which is the wrong
one).
Regression tests cover each bypass, plus an update to the resolveActor
unit test to reflect the new "X-Agent-ID without X-Task-ID falls back
to member" contract.
Co-authored-by: multica-agent <github@multica.ai>
* test(handler): seed X-Task-ID alongside X-Agent-ID in existing agent-caller tests
After tightening resolveActor to require both headers (X-Agent-ID +
X-Task-ID) for the "agent" actor identity, three existing tests that
set only X-Agent-ID started failing because their requests now resolve
to "member" instead of "agent". Add createHandlerTestTaskForAgent
helper and seed a task per agent-caller assertion. Also patch
TestAgentExplicitMentionStillTriggers — it still passed only because
the @mention path doesn't care about author type for member callers,
but the test claims to exercise the agent path, so make it faithful.
Co-authored-by: multica-agent <github@multica.ai>
* test(handler): finish X-Task-ID seeding + fix cross-workspace mention test schema
The previous CI run still failed in two places:
1. server/cmd/server integration tests — postCommentAsAgent → authRequestWithAgent
only set X-Agent-ID, so resolveActor downgraded the request to "member"
and the on_comment chain produced the wrong task counts. Fix:
authRequestWithAgent now also sets X-Task-ID, fetched or seeded by a new
ensureAgentTask(agentID) helper.
2. TestMentionAgent_RejectsCrossWorkspaceAgentUUID's hand-crafted comment
INSERT was missing comment.workspace_id, which migration 025 made
NOT NULL. Pass testWorkspaceID into the seed row.
Build + vet clean locally; both packages compile.
Co-authored-by: multica-agent <github@multica.ai>
---------
Co-authored-by: multica-agent <github@multica.ai>
334 lines
10 KiB
Go
334 lines
10 KiB
Go
package handler
|
|
|
|
import (
|
|
"context"
|
|
"encoding/json"
|
|
"net/http"
|
|
"net/http/httptest"
|
|
"testing"
|
|
|
|
db "github.com/multica-ai/multica/server/pkg/db/generated"
|
|
)
|
|
|
|
func TestSubscriberAPI(t *testing.T) {
|
|
ctx := context.Background()
|
|
|
|
// Helper: create an issue for subscriber tests
|
|
createIssue := func(t *testing.T) string {
|
|
t.Helper()
|
|
w := httptest.NewRecorder()
|
|
req := newRequest("POST", "/api/issues?workspace_id="+testWorkspaceID, map[string]any{
|
|
"title": "Subscriber test issue",
|
|
})
|
|
testHandler.CreateIssue(w, req)
|
|
if w.Code != http.StatusCreated {
|
|
t.Fatalf("CreateIssue: expected 201, got %d: %s", w.Code, w.Body.String())
|
|
}
|
|
var issue IssueResponse
|
|
json.NewDecoder(w.Body).Decode(&issue)
|
|
return issue.ID
|
|
}
|
|
|
|
// Helper: delete an issue
|
|
deleteIssue := func(t *testing.T, issueID string) {
|
|
t.Helper()
|
|
w := httptest.NewRecorder()
|
|
req := newRequest("DELETE", "/api/issues/"+issueID, nil)
|
|
req = withURLParam(req, "id", issueID)
|
|
testHandler.DeleteIssue(w, req)
|
|
}
|
|
|
|
t.Run("Subscribe", func(t *testing.T) {
|
|
issueID := createIssue(t)
|
|
defer deleteIssue(t, issueID)
|
|
|
|
w := httptest.NewRecorder()
|
|
req := newRequest("POST", "/api/issues/"+issueID+"/subscribe", nil)
|
|
req = withURLParam(req, "id", issueID)
|
|
testHandler.SubscribeToIssue(w, req)
|
|
if w.Code != http.StatusOK {
|
|
t.Fatalf("SubscribeToIssue: expected 200, got %d: %s", w.Code, w.Body.String())
|
|
}
|
|
|
|
var resp map[string]bool
|
|
json.NewDecoder(w.Body).Decode(&resp)
|
|
if !resp["subscribed"] {
|
|
t.Fatal("SubscribeToIssue: expected subscribed=true")
|
|
}
|
|
|
|
// Verify in DB
|
|
subscribed, err := testHandler.Queries.IsIssueSubscriber(ctx, db.IsIssueSubscriberParams{
|
|
IssueID: parseUUID(issueID),
|
|
UserType: "member",
|
|
UserID: parseUUID(testUserID),
|
|
})
|
|
if err != nil {
|
|
t.Fatalf("IsIssueSubscriber: %v", err)
|
|
}
|
|
if !subscribed {
|
|
t.Fatal("expected user to be subscribed in DB")
|
|
}
|
|
})
|
|
|
|
t.Run("SubscribeIdempotent", func(t *testing.T) {
|
|
issueID := createIssue(t)
|
|
defer deleteIssue(t, issueID)
|
|
|
|
// Subscribe first time
|
|
w := httptest.NewRecorder()
|
|
req := newRequest("POST", "/api/issues/"+issueID+"/subscribe", nil)
|
|
req = withURLParam(req, "id", issueID)
|
|
testHandler.SubscribeToIssue(w, req)
|
|
if w.Code != http.StatusOK {
|
|
t.Fatalf("SubscribeToIssue (1st): expected 200, got %d: %s", w.Code, w.Body.String())
|
|
}
|
|
|
|
// Subscribe second time — should also succeed
|
|
w = httptest.NewRecorder()
|
|
req = newRequest("POST", "/api/issues/"+issueID+"/subscribe", nil)
|
|
req = withURLParam(req, "id", issueID)
|
|
testHandler.SubscribeToIssue(w, req)
|
|
if w.Code != http.StatusOK {
|
|
t.Fatalf("SubscribeToIssue (2nd): expected 200, got %d: %s", w.Code, w.Body.String())
|
|
}
|
|
})
|
|
|
|
t.Run("ListSubscribers", func(t *testing.T) {
|
|
issueID := createIssue(t)
|
|
defer deleteIssue(t, issueID)
|
|
|
|
// Subscribe first
|
|
w := httptest.NewRecorder()
|
|
req := newRequest("POST", "/api/issues/"+issueID+"/subscribe", nil)
|
|
req = withURLParam(req, "id", issueID)
|
|
testHandler.SubscribeToIssue(w, req)
|
|
if w.Code != http.StatusOK {
|
|
t.Fatalf("SubscribeToIssue: expected 200, got %d: %s", w.Code, w.Body.String())
|
|
}
|
|
|
|
// List
|
|
w = httptest.NewRecorder()
|
|
req = newRequest("GET", "/api/issues/"+issueID+"/subscribers", nil)
|
|
req = withURLParam(req, "id", issueID)
|
|
testHandler.ListIssueSubscribers(w, req)
|
|
if w.Code != http.StatusOK {
|
|
t.Fatalf("ListIssueSubscribers: expected 200, got %d: %s", w.Code, w.Body.String())
|
|
}
|
|
|
|
var subscribers []SubscriberResponse
|
|
json.NewDecoder(w.Body).Decode(&subscribers)
|
|
if len(subscribers) == 0 {
|
|
t.Fatal("ListIssueSubscribers: expected at least 1 subscriber")
|
|
}
|
|
found := false
|
|
for _, s := range subscribers {
|
|
if s.UserID == testUserID && s.UserType == "member" && s.Reason == "manual" {
|
|
found = true
|
|
break
|
|
}
|
|
}
|
|
if !found {
|
|
t.Fatalf("ListIssueSubscribers: expected to find test user subscriber, got %+v", subscribers)
|
|
}
|
|
})
|
|
|
|
t.Run("Unsubscribe", func(t *testing.T) {
|
|
issueID := createIssue(t)
|
|
defer deleteIssue(t, issueID)
|
|
|
|
// Subscribe first
|
|
w := httptest.NewRecorder()
|
|
req := newRequest("POST", "/api/issues/"+issueID+"/subscribe", nil)
|
|
req = withURLParam(req, "id", issueID)
|
|
testHandler.SubscribeToIssue(w, req)
|
|
if w.Code != http.StatusOK {
|
|
t.Fatalf("SubscribeToIssue: expected 200, got %d: %s", w.Code, w.Body.String())
|
|
}
|
|
|
|
// Unsubscribe
|
|
w = httptest.NewRecorder()
|
|
req = newRequest("POST", "/api/issues/"+issueID+"/unsubscribe", nil)
|
|
req = withURLParam(req, "id", issueID)
|
|
testHandler.UnsubscribeFromIssue(w, req)
|
|
if w.Code != http.StatusOK {
|
|
t.Fatalf("UnsubscribeFromIssue: expected 200, got %d: %s", w.Code, w.Body.String())
|
|
}
|
|
|
|
var resp map[string]bool
|
|
json.NewDecoder(w.Body).Decode(&resp)
|
|
if resp["subscribed"] {
|
|
t.Fatal("UnsubscribeFromIssue: expected subscribed=false")
|
|
}
|
|
|
|
// Verify in DB
|
|
subscribed, err := testHandler.Queries.IsIssueSubscriber(ctx, db.IsIssueSubscriberParams{
|
|
IssueID: parseUUID(issueID),
|
|
UserType: "member",
|
|
UserID: parseUUID(testUserID),
|
|
})
|
|
if err != nil {
|
|
t.Fatalf("IsIssueSubscriber: %v", err)
|
|
}
|
|
if subscribed {
|
|
t.Fatal("expected user to NOT be subscribed in DB")
|
|
}
|
|
})
|
|
|
|
t.Run("SubscribeCrossWorkspaceUser", func(t *testing.T) {
|
|
issueID := createIssue(t)
|
|
defer deleteIssue(t, issueID)
|
|
|
|
foreignUserID := "00000000-0000-0000-0000-000000000099"
|
|
w := httptest.NewRecorder()
|
|
req := newRequest("POST", "/api/issues/"+issueID+"/subscribe", map[string]any{
|
|
"user_id": foreignUserID,
|
|
"user_type": "member",
|
|
})
|
|
req = withURLParam(req, "id", issueID)
|
|
testHandler.SubscribeToIssue(w, req)
|
|
if w.Code != http.StatusForbidden {
|
|
t.Fatalf("SubscribeToIssue with cross-workspace user: expected 403, got %d: %s", w.Code, w.Body.String())
|
|
}
|
|
|
|
subscribed, err := testHandler.Queries.IsIssueSubscriber(ctx, db.IsIssueSubscriberParams{
|
|
IssueID: parseUUID(issueID),
|
|
UserType: "member",
|
|
UserID: parseUUID(foreignUserID),
|
|
})
|
|
if err != nil {
|
|
t.Fatalf("IsIssueSubscriber: %v", err)
|
|
}
|
|
if subscribed {
|
|
t.Fatal("cross-workspace user should NOT be subscribed in DB")
|
|
}
|
|
})
|
|
|
|
t.Run("UnsubscribeCrossWorkspaceUser", func(t *testing.T) {
|
|
issueID := createIssue(t)
|
|
defer deleteIssue(t, issueID)
|
|
|
|
foreignUserID := "00000000-0000-0000-0000-000000000099"
|
|
w := httptest.NewRecorder()
|
|
req := newRequest("POST", "/api/issues/"+issueID+"/unsubscribe", map[string]any{
|
|
"user_id": foreignUserID,
|
|
"user_type": "member",
|
|
})
|
|
req = withURLParam(req, "id", issueID)
|
|
testHandler.UnsubscribeFromIssue(w, req)
|
|
if w.Code != http.StatusForbidden {
|
|
t.Fatalf("UnsubscribeFromIssue with cross-workspace user: expected 403, got %d: %s", w.Code, w.Body.String())
|
|
}
|
|
})
|
|
|
|
t.Run("AgentCallerSubscribesItself", func(t *testing.T) {
|
|
issueID := createIssue(t)
|
|
defer deleteIssue(t, issueID)
|
|
|
|
// Look up the agent created by the handler test fixture.
|
|
var agentID string
|
|
err := testPool.QueryRow(ctx,
|
|
`SELECT id FROM agent WHERE workspace_id = $1 AND name = $2`,
|
|
testWorkspaceID, "Handler Test Agent",
|
|
).Scan(&agentID)
|
|
if err != nil {
|
|
t.Fatalf("failed to find test agent: %v", err)
|
|
}
|
|
|
|
// Subscribe with X-Agent-ID set — no body, so the handler must default
|
|
// to subscribing the agent itself (not the member behind X-User-ID).
|
|
// resolveActor requires X-Task-ID alongside X-Agent-ID to grant the
|
|
// "agent" identity (defense against header forgery), so seed a task.
|
|
agentTask := createHandlerTestTaskForAgent(t, agentID)
|
|
w := httptest.NewRecorder()
|
|
req := newRequest("POST", "/api/issues/"+issueID+"/subscribe", nil)
|
|
req = withURLParam(req, "id", issueID)
|
|
req.Header.Set("X-Agent-ID", agentID)
|
|
req.Header.Set("X-Task-ID", agentTask)
|
|
testHandler.SubscribeToIssue(w, req)
|
|
if w.Code != http.StatusOK {
|
|
t.Fatalf("SubscribeToIssue (agent caller): expected 200, got %d: %s", w.Code, w.Body.String())
|
|
}
|
|
|
|
agentSubscribed, err := testHandler.Queries.IsIssueSubscriber(ctx, db.IsIssueSubscriberParams{
|
|
IssueID: parseUUID(issueID),
|
|
UserType: "agent",
|
|
UserID: parseUUID(agentID),
|
|
})
|
|
if err != nil {
|
|
t.Fatalf("IsIssueSubscriber (agent): %v", err)
|
|
}
|
|
if !agentSubscribed {
|
|
t.Fatal("expected agent to be subscribed in DB when X-Agent-ID is set")
|
|
}
|
|
|
|
memberSubscribed, err := testHandler.Queries.IsIssueSubscriber(ctx, db.IsIssueSubscriberParams{
|
|
IssueID: parseUUID(issueID),
|
|
UserType: "member",
|
|
UserID: parseUUID(testUserID),
|
|
})
|
|
if err != nil {
|
|
t.Fatalf("IsIssueSubscriber (member): %v", err)
|
|
}
|
|
if memberSubscribed {
|
|
t.Fatal("member must not be auto-subscribed when caller is an agent")
|
|
}
|
|
|
|
// Unsubscribe with X-Agent-ID set — same default-to-caller expectation.
|
|
// Re-use the same task as the subscribe call; resolveActor only
|
|
// validates that the task belongs to the agent, not which task.
|
|
w = httptest.NewRecorder()
|
|
req = newRequest("POST", "/api/issues/"+issueID+"/unsubscribe", nil)
|
|
req = withURLParam(req, "id", issueID)
|
|
req.Header.Set("X-Agent-ID", agentID)
|
|
req.Header.Set("X-Task-ID", agentTask)
|
|
testHandler.UnsubscribeFromIssue(w, req)
|
|
if w.Code != http.StatusOK {
|
|
t.Fatalf("UnsubscribeFromIssue (agent caller): expected 200, got %d: %s", w.Code, w.Body.String())
|
|
}
|
|
|
|
agentSubscribed, err = testHandler.Queries.IsIssueSubscriber(ctx, db.IsIssueSubscriberParams{
|
|
IssueID: parseUUID(issueID),
|
|
UserType: "agent",
|
|
UserID: parseUUID(agentID),
|
|
})
|
|
if err != nil {
|
|
t.Fatalf("IsIssueSubscriber (agent, after unsubscribe): %v", err)
|
|
}
|
|
if agentSubscribed {
|
|
t.Fatal("expected agent to be unsubscribed in DB when X-Agent-ID is set")
|
|
}
|
|
})
|
|
|
|
t.Run("ListAfterUnsubscribe", func(t *testing.T) {
|
|
issueID := createIssue(t)
|
|
defer deleteIssue(t, issueID)
|
|
|
|
// Subscribe
|
|
w := httptest.NewRecorder()
|
|
req := newRequest("POST", "/api/issues/"+issueID+"/subscribe", nil)
|
|
req = withURLParam(req, "id", issueID)
|
|
testHandler.SubscribeToIssue(w, req)
|
|
|
|
// Unsubscribe
|
|
w = httptest.NewRecorder()
|
|
req = newRequest("POST", "/api/issues/"+issueID+"/unsubscribe", nil)
|
|
req = withURLParam(req, "id", issueID)
|
|
testHandler.UnsubscribeFromIssue(w, req)
|
|
|
|
// List should be empty
|
|
w = httptest.NewRecorder()
|
|
req = newRequest("GET", "/api/issues/"+issueID+"/subscribers", nil)
|
|
req = withURLParam(req, "id", issueID)
|
|
testHandler.ListIssueSubscribers(w, req)
|
|
if w.Code != http.StatusOK {
|
|
t.Fatalf("ListIssueSubscribers: expected 200, got %d: %s", w.Code, w.Body.String())
|
|
}
|
|
|
|
var subscribers []SubscriberResponse
|
|
json.NewDecoder(w.Body).Decode(&subscribers)
|
|
if len(subscribers) != 0 {
|
|
t.Fatalf("ListIssueSubscribers: expected 0 subscribers after unsubscribe, got %d", len(subscribers))
|
|
}
|
|
})
|
|
}
|