diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 1c959524e32..768514afb4a 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -72,7 +72,7 @@ jobs: - name: Setup Go uses: actions/setup-go@v5 with: - go-version: "1.26.x" + go-version: "1.26.9" check-latest: true cache-dependency-path: server/go.sum @@ -349,8 +349,8 @@ jobs: uses: actions/setup-go@v5 with: # Deliberately independent from the minimum patch in server/go.mod: - # CI follows the newest 1.26 patch available to setup-go. - go-version: "1.26.x" + # CI pins Go 1.26.9 for a consistent patched toolchain. + go-version: "1.26.9" check-latest: true cache: false @@ -450,7 +450,7 @@ jobs: id: go uses: actions/setup-go@v5 with: - go-version: "1.26.x" + go-version: "1.26.9" check-latest: true cache: false @@ -489,8 +489,8 @@ jobs: uses: actions/setup-go@v5 with: # Deliberately independent from the minimum patch in server/go.mod: - # CI follows the newest 1.26 patch available to setup-go. - go-version: "1.26.x" + # CI pins Go 1.26.9 for a consistent patched toolchain. + go-version: "1.26.9" check-latest: true cache-dependency-path: server/go.sum @@ -528,8 +528,8 @@ jobs: uses: actions/setup-go@v5 with: # Deliberately independent from the minimum patch in server/go.mod: - # CI follows the newest 1.26 patch available to setup-go. - go-version: "1.26.x" + # CI pins Go 1.26.9 for a consistent patched toolchain. + go-version: "1.26.9" check-latest: true cache-dependency-path: server/go.sum @@ -788,7 +788,7 @@ jobs: - uses: actions/checkout@v6 - uses: actions/setup-go@v5 with: - go-version: "1.26.x" + go-version: "1.26.9" cache-dependency-path: server/go.sum - name: Verify Cursor background lifecycle and watchdog races shell: bash diff --git a/server/cmd/server/listeners.go b/server/cmd/server/listeners.go index 5dc32524fe8..679c4eedf5d 100644 --- a/server/cmd/server/listeners.go +++ b/server/cmd/server/listeners.go @@ -1,6 +1,7 @@ package main import ( + "context" "encoding/json" "fmt" "log/slog" @@ -12,6 +13,104 @@ import ( "github.com/multica-ai/multica/server/pkg/protocol" ) +type eventAudienceResolver func(context.Context, events.Event) ([]string, bool) + +func eventObjectReference(e events.Event) (objectType, objectID string, scoped bool) { + if e.TaskID != "" { + return "task", e.TaskID, true + } + var payload map[string]any + encoded, err := json.Marshal(e.Payload) + if err == nil { + _ = json.Unmarshal(encoded, &payload) + } + if payload != nil { + for _, key := range []string{"issue_id", "issueId"} { + if id := findPayloadString(payload, key); id != "" { + return "issue", id, true + } + } + for _, key := range []string{"project_id", "projectId"} { + if id := findPayloadString(payload, key); id != "" { + return "project", id, true + } + } + for _, key := range []string{"squad_id", "squadId"} { + if id := findPayloadString(payload, key); id != "" { + return "squad", id, true + } + } + for _, ref := range []struct{ key, typ string }{{"issue", "issue"}, {"project", "project"}, {"squad", "squad"}} { + if id := findNestedObjectID(payload, ref.key); id != "" { + return ref.typ, id, true + } + } + if id, ok := payload["task_id"].(string); ok && id != "" { + return "task", id, true + } + } + t := strings.ToLower(e.Type) + for _, prefix := range []string{"issue", "task", "project", "squad", "comment", "attachment", "reaction", "issue_metadata", "issue_labels", "issue_properties", "issue_status", "wakeup"} { + if strings.HasPrefix(t, prefix) { + return "", "", true + } + } + return "", "", false +} + +func findPayloadString(value any, key string) string { + switch current := value.(type) { + case map[string]any: + if id, ok := current[key].(string); ok && id != "" { + return id + } + for _, child := range current { + if id := findPayloadString(child, key); id != "" { + return id + } + } + case []any: + for _, child := range current { + if id := findPayloadString(child, key); id != "" { + return id + } + } + } + return "" +} + +func findNestedObjectID(value any, key string) string { + switch current := value.(type) { + case map[string]any: + if object, ok := current[key].(map[string]any); ok { + if id, ok := object["id"].(string); ok && id != "" { + return id + } + } + for _, child := range current { + if id := findNestedObjectID(child, key); id != "" { + return id + } + } + case []any: + for _, child := range current { + if id := findNestedObjectID(child, key); id != "" { + return id + } + } + } + return "" +} + +func audienceContains(audience []string, userID string) bool { + for _, candidate := range audience { + if candidate == userID { + return true + } + } + return false +} + // internalOnlyPayloadKeys lists payload keys that exist purely for in-process // listeners and must never be serialized to a WebSocket client. // @@ -76,7 +175,23 @@ func projectOutbound(eventType string, payload any) any { // for a Redis-backed relay or a feature-flagged dual-write implementation // without touching any of the event listeners below. This is Phase 0 of the // horizontal-scaling plan tracked in MUL-1138. -func registerListeners(bus *events.Bus, b realtime.Broadcaster) { +func registerListeners(bus *events.Bus, b realtime.Broadcaster, audienceResolvers ...eventAudienceResolver) { + var resolveAudience eventAudienceResolver + if len(audienceResolvers) > 0 { + resolveAudience = audienceResolvers[0] + } + eventVisibleTo := func(e events.Event, recipientID string) bool { + if e.RecipientIDs != nil { + return audienceContains(e.RecipientIDs, recipientID) + } + if resolveAudience != nil { + audience, scoped := resolveAudience(context.Background(), e) + if scoped { + return audienceContains(audience, recipientID) + } + } + return true + } // Personal events should NOT be broadcast to the whole workspace. personalEvents := map[string]bool{ protocol.EventInboxNew: true, @@ -93,7 +208,7 @@ func registerListeners(bus *events.Bus, b realtime.Broadcaster) { // Helper: marshal event and send to a specific user. sendToRecipient := func(b realtime.Broadcaster, e events.Event, recipientID string) { - if recipientID == "" { + if recipientID == "" || !eventVisibleTo(e, recipientID) { return } data, err := json.Marshal(map[string]any{"type": e.Type, "payload": projectOutbound(e.Type, e.Payload), "actor_id": e.ActorID, "actor_type": e.ActorType}) @@ -245,6 +360,21 @@ func registerListeners(bus *events.Bus, b realtime.Broadcaster) { if personalEvents[e.Type] { return } + if e.RecipientIDs != nil { + for _, recipientID := range e.RecipientIDs { + sendToRecipient(b, e, recipientID) + } + return + } + if resolveAudience != nil { + if recipients, scoped := resolveAudience(context.Background(), e); scoped { + e.RecipientIDs = recipients + for _, recipientID := range recipients { + sendToRecipient(b, e, recipientID) + } + return + } + } msg := map[string]any{ "type": e.Type, diff --git a/server/cmd/server/main.go b/server/cmd/server/main.go index 6f6773cf898..834294462c1 100644 --- a/server/cmd/server/main.go +++ b/server/cmd/server/main.go @@ -22,6 +22,7 @@ import ( "github.com/multica-ai/multica/server/internal/dbstartup" "github.com/multica-ai/multica/server/internal/events" "github.com/multica-ai/multica/server/internal/handler" + "github.com/multica-ai/multica/server/internal/util" "github.com/multica-ai/multica/server/internal/integrations/wecom" "github.com/multica-ai/multica/server/internal/logger" "github.com/multica-ai/multica/server/internal/maintenance" @@ -602,13 +603,41 @@ func main() { channelLeaseRedis = newNamedRedisClient(opts, "channel-lease") } } - registerListeners(bus, broadcaster) - analyticsClient := analytics.NewFromEnv() defer analyticsClient.Close() queries := db.New(pool) hub.SetAuthorizer(newScopeAuthorizer(queries)) + visibilityGate := &handler.Handler{Queries: queries, DB: pool} + resolveEventAudience := func(ctx context.Context, e events.Event) ([]string, bool) { + objectType, objectID, scoped := eventObjectReference(e) + if !scoped { + return nil, false + } + if objectType == "task" { + taskID, err := util.ParseUUID(objectID) + if err != nil { + return nil, true + } + task, err := queries.GetAgentTask(ctx, taskID) + if err != nil { + return nil, true + } + if !task.IssueID.Valid { + // Standalone tasks do not yet have a business-object audience + // resolver. Keep them scoped and fail closed instead of falling + // back to workspace-wide delivery. + return nil, true + } + objectType, objectID = "issue", util.UUIDToString(task.IssueID) + } + audience, err := visibilityGate.BusinessObjectRecipients(ctx, e.WorkspaceID, objectType, objectID) + if err != nil { + return nil, true + } + return audience, true + } + registerListeners(bus, broadcaster, resolveEventAudience) // Order matters: subscriber listeners must register BEFORE notification listeners. // The notification listener queries the subscriber table to determine recipients, // so subscribers must be written first within the same synchronous event dispatch. diff --git a/server/cmd/server/notification_listeners_test.go b/server/cmd/server/notification_listeners_test.go index 6de35127cc5..0ffddbb2919 100644 --- a/server/cmd/server/notification_listeners_test.go +++ b/server/cmd/server/notification_listeners_test.go @@ -14,6 +14,18 @@ import ( // notificationTest helpers — reuse the integration test fixtures from TestMain // (testPool, testUserID, testWorkspaceID are set in integration_test.go). +func createNotificationTestUser(t *testing.T, email string) string { + t.Helper() + userID := createTestUser(t, email) + if _, err := testPool.Exec(context.Background(), ` + INSERT INTO member (workspace_id, user_id, role) + VALUES ($1, $2, 'member') + `, testWorkspaceID, userID); err != nil { + t.Fatalf("add notification recipient to workspace: %v", err) + } + return userID +} + // inboxItemsForRecipient returns all non-archived inbox items for a given recipient. func inboxItemsForRecipient(t *testing.T, queries *db.Queries, recipientID string) []db.ListInboxItemsRow { t.Helper() @@ -84,7 +96,7 @@ func TestNotification_IssueCreated_AssigneeNotified(t *testing.T) { bus := newNotificationBus(t, queries) assigneeEmail := "notif-assignee-created@multica.ai" - assigneeID := createTestUser(t, assigneeEmail) + assigneeID := createNotificationTestUser(t, assigneeEmail) t.Cleanup(func() { cleanupTestUser(t, assigneeEmail) }) issueID := createTestIssue(t, testWorkspaceID, testUserID) @@ -244,11 +256,11 @@ func TestNotification_StatusChanged(t *testing.T) { // Create two extra users as subscribers sub1Email := "notif-sub1-status@multica.ai" - sub1ID := createTestUser(t, sub1Email) + sub1ID := createNotificationTestUser(t, sub1Email) t.Cleanup(func() { cleanupTestUser(t, sub1Email) }) sub2Email := "notif-sub2-status@multica.ai" - sub2ID := createTestUser(t, sub2Email) + sub2ID := createNotificationTestUser(t, sub2Email) t.Cleanup(func() { cleanupTestUser(t, sub2Email) }) issueID := createTestIssue(t, testWorkspaceID, testUserID) @@ -323,11 +335,11 @@ func TestNotification_StatusChanged_Muted(t *testing.T) { bus := newNotificationBus(t, queries) mutedEmail := "notif-muted-status@multica.ai" - mutedID := createTestUser(t, mutedEmail) + mutedID := createNotificationTestUser(t, mutedEmail) t.Cleanup(func() { cleanupTestUser(t, mutedEmail) }) normalEmail := "notif-normal-status@multica.ai" - normalID := createTestUser(t, normalEmail) + normalID := createNotificationTestUser(t, normalEmail) t.Cleanup(func() { cleanupTestUser(t, normalEmail) }) issueID := createTestIssue(t, testWorkspaceID, testUserID) @@ -382,11 +394,11 @@ func TestNotification_CommentCreated(t *testing.T) { bus := newNotificationBus(t, queries) commenterEmail := "notif-commenter@multica.ai" - commenterID := createTestUser(t, commenterEmail) + commenterID := createNotificationTestUser(t, commenterEmail) t.Cleanup(func() { cleanupTestUser(t, commenterEmail) }) sub1Email := "notif-sub1-comment@multica.ai" - sub1ID := createTestUser(t, sub1Email) + sub1ID := createNotificationTestUser(t, sub1Email) t.Cleanup(func() { cleanupTestUser(t, sub1Email) }) issueID := createTestIssue(t, testWorkspaceID, testUserID) @@ -462,13 +474,13 @@ func TestNotification_SystemCommentSkipsInboxAndMentions(t *testing.T) { // Subscriber on the issue who would normally receive new_comment. subEmail := "notif-system-comment-sub@multica.ai" - subID := createTestUser(t, subEmail) + subID := createNotificationTestUser(t, subEmail) t.Cleanup(func() { cleanupTestUser(t, subEmail) }) // A second member whose UUID we will smuggle into the system-comment // body as a fake mention to prove the listener does not parse it. targetEmail := "notif-system-comment-target@multica.ai" - targetID := createTestUser(t, targetEmail) + targetID := createNotificationTestUser(t, targetEmail) t.Cleanup(func() { cleanupTestUser(t, targetEmail) }) issueID := createTestIssue(t, testWorkspaceID, testUserID) @@ -556,15 +568,15 @@ func TestNotification_AssigneeChanged(t *testing.T) { bus := newNotificationBus(t, queries) oldAssigneeEmail := "notif-old-assignee@multica.ai" - oldAssigneeID := createTestUser(t, oldAssigneeEmail) + oldAssigneeID := createNotificationTestUser(t, oldAssigneeEmail) t.Cleanup(func() { cleanupTestUser(t, oldAssigneeEmail) }) newAssigneeEmail := "notif-new-assignee@multica.ai" - newAssigneeID := createTestUser(t, newAssigneeEmail) + newAssigneeID := createNotificationTestUser(t, newAssigneeEmail) t.Cleanup(func() { cleanupTestUser(t, newAssigneeEmail) }) bystanderEmail := "notif-bystander@multica.ai" - bystanderID := createTestUser(t, bystanderEmail) + bystanderID := createNotificationTestUser(t, bystanderEmail) t.Cleanup(func() { cleanupTestUser(t, bystanderEmail) }) issueID := createTestIssue(t, testWorkspaceID, testUserID) @@ -735,7 +747,7 @@ func TestNotification_PriorityChanged(t *testing.T) { bus := newNotificationBus(t, queries) sub1Email := "notif-sub1-priority@multica.ai" - sub1ID := createTestUser(t, sub1Email) + sub1ID := createNotificationTestUser(t, sub1Email) t.Cleanup(func() { cleanupTestUser(t, sub1Email) }) issueID := createTestIssue(t, testWorkspaceID, testUserID) @@ -800,7 +812,7 @@ func TestNotification_DueDateChanged(t *testing.T) { bus := newNotificationBus(t, queries) sub1Email := "notif-sub1-duedate@multica.ai" - sub1ID := createTestUser(t, sub1Email) + sub1ID := createNotificationTestUser(t, sub1Email) t.Cleanup(func() { cleanupTestUser(t, sub1Email) }) issueID := createTestIssue(t, testWorkspaceID, testUserID) @@ -861,7 +873,7 @@ func TestNotification_StartDateChanged(t *testing.T) { bus := newNotificationBus(t, queries) sub1Email := "notif-sub1-startdate@multica.ai" - sub1ID := createTestUser(t, sub1Email) + sub1ID := createNotificationTestUser(t, sub1Email) t.Cleanup(func() { cleanupTestUser(t, sub1Email) }) issueID := createTestIssue(t, testWorkspaceID, testUserID) @@ -921,7 +933,7 @@ func TestNotification_ParentBubble_StatusChanged(t *testing.T) { bus := newNotificationBus(t, queries) parentSubEmail := "notif-parent-sub-status@multica.ai" - parentSubID := createTestUser(t, parentSubEmail) + parentSubID := createNotificationTestUser(t, parentSubEmail) t.Cleanup(func() { cleanupTestUser(t, parentSubEmail) }) parentID := createTestIssue(t, testWorkspaceID, testUserID) @@ -983,11 +995,11 @@ func TestNotification_ParentBubble_NewCommentSuppressed(t *testing.T) { bus := newNotificationBus(t, queries) commenterEmail := "notif-parent-bubble-commenter@multica.ai" - commenterID := createTestUser(t, commenterEmail) + commenterID := createNotificationTestUser(t, commenterEmail) t.Cleanup(func() { cleanupTestUser(t, commenterEmail) }) parentSubEmail := "notif-parent-sub-comment@multica.ai" - parentSubID := createTestUser(t, parentSubEmail) + parentSubID := createNotificationTestUser(t, parentSubEmail) t.Cleanup(func() { cleanupTestUser(t, parentSubEmail) }) parentID := createTestIssue(t, testWorkspaceID, testUserID) @@ -1035,7 +1047,7 @@ func TestNotification_ParentBubble_PriorityChangeSuppressed(t *testing.T) { bus := newNotificationBus(t, queries) parentSubEmail := "notif-parent-sub-priority@multica.ai" - parentSubID := createTestUser(t, parentSubEmail) + parentSubID := createNotificationTestUser(t, parentSubEmail) t.Cleanup(func() { cleanupTestUser(t, parentSubEmail) }) parentID := createTestIssue(t, testWorkspaceID, testUserID) @@ -1141,7 +1153,7 @@ func TestNotification_StatusChange_ArchivesStaleTaskFailed(t *testing.T) { bus := newNotificationBus(t, queries) subEmail := "notif-archive-task-failed-sub@multica.ai" - subID := createTestUser(t, subEmail) + subID := createNotificationTestUser(t, subEmail) t.Cleanup(func() { cleanupTestUser(t, subEmail) }) issueID := createTestIssue(t, testWorkspaceID, testUserID) diff --git a/server/internal/events/bus.go b/server/internal/events/bus.go index 3636cadbc4c..0a1fa087cfa 100644 --- a/server/internal/events/bus.go +++ b/server/internal/events/bus.go @@ -12,6 +12,9 @@ type Event struct { ActorType string // "member", "agent", or "system" ActorID string Payload any // JSON-serializable, same shape as current WS payloads + // RecipientIDs is an optional precomputed audience for events whose object + // has already been deleted and can no longer be authorized from the DB. + RecipientIDs []string // Optional scope hints used by the realtime fanout layer to route the // event to a more specific scope than `workspace:{WorkspaceID}`. When set diff --git a/server/internal/handler/daemon.go b/server/internal/handler/daemon.go index 8945dbdb31b..cb58b3f8dc6 100644 --- a/server/internal/handler/daemon.go +++ b/server/internal/handler/daemon.go @@ -5615,6 +5615,7 @@ func (h *Handler) ListTasksByIssue(w http.ResponseWriter, r *http.Request) { rows, err := h.Queries.ListActiveTasksByIssueFamily(r.Context(), db.ListActiveTasksByIssueFamilyParams{ WorkspaceID: issue.WorkspaceID, RootIssueID: root, + UserID: parseUUID(requestUserID(r)), RowLimit: familyActiveRunCap + 1, }) if err != nil { @@ -5625,6 +5626,19 @@ func (h *Handler) ListTasksByIssue(w http.ResponseWriter, r *http.Request) { rows = rows[:familyActiveRunCap] w.Header().Set(HeaderActiveRunsTruncated, "true") } + visibility, ok := h.objectVisibilityForMember(r.Context(), workspaceID, requestUserID(r)) + if !ok { + writeError(w, http.StatusInternalServerError, "failed to resolve object visibility") + return + } + if visibility.rescue { + for _, row := range rows { + if !h.auditRescueObject(r.Context(), visibility, "issue", row.IssueID, "daemon_family_summary") { + writeError(w, http.StatusInternalServerError, "failed to record rescue access") + return + } + } + } summaries := make([]ActiveRunSummary, len(rows)) for i, row := range rows { summaries[i] = ActiveRunSummary{ @@ -5810,6 +5824,11 @@ func (h *Handler) ListTaskMessagesByUser(w http.ResponseWriter, r *http.Request) writeError(w, http.StatusNotFound, "task not found") return } + if task.IssueID.Valid { + if _, ok := h.loadIssueForUser(w, r, uuidToString(task.IssueID)); !ok { + return + } + } var ( messages []db.TaskMessage diff --git a/server/internal/handler/file.go b/server/internal/handler/file.go index cac6a7b3150..dfc0b778e84 100644 --- a/server/internal/handler/file.go +++ b/server/internal/handler/file.go @@ -772,10 +772,66 @@ func (h *Handler) loadAttachmentForRequest(w http.ResponseWriter, r *http.Reques writeError(w, http.StatusNotFound, "attachment not found") return db.Attachment{}, false } + if !h.authorizeAttachmentIssue(w, r, att) { + return db.Attachment{}, false + } return att, true } +// authorizeAttachmentIssue makes issue/comment/source-context/task attachments +// inherit the parent issue's object visibility. Chat-only attachments have no +// issue parent and continue through their existing chat authorization path. +func (h *Handler) authorizeAttachmentIssue(w http.ResponseWriter, r *http.Request, att db.Attachment) bool { + issueID := att.IssueID + if !issueID.Valid && att.CommentID.Valid { + comment, err := h.Queries.GetComment(r.Context(), att.CommentID) + if err != nil || comment.WorkspaceID != att.WorkspaceID { + writeError(w, http.StatusNotFound, "attachment not found") + return false + } + issueID = comment.IssueID + } + if !issueID.Valid && att.SourceContextID.Valid { + sourceContext, err := h.Queries.GetIssueSourceContextByID(r.Context(), db.GetIssueSourceContextByIDParams{ + ID: att.SourceContextID, WorkspaceID: att.WorkspaceID, + }) + if err != nil { + writeError(w, http.StatusNotFound, "attachment not found") + return false + } + issueID = sourceContext.IssueID + } + if !issueID.Valid && att.TaskID.Valid { + task, err := h.Queries.GetAgentTask(r.Context(), att.TaskID) + if err == nil { + issueID = task.IssueID + } + } + if !issueID.Valid { + return true + } + userID, ok := requireUserID(w, r) + if !ok { + return false + } + issue, err := h.Queries.GetIssueInWorkspace(r.Context(), db.GetIssueInWorkspaceParams{ID: issueID, WorkspaceID: att.WorkspaceID}) + if err != nil { + writeError(w, http.StatusNotFound, "attachment not found") + return false + } + scope, ok := h.objectVisibilityForMember(r.Context(), uuidToString(att.WorkspaceID), userID) + if !ok { + writeError(w, http.StatusInternalServerError, "failed to resolve object visibility") + return false + } + if !h.canReadIssue(r.Context(), scope, issue) { + writeError(w, http.StatusNotFound, "attachment not found") + return false + } + return true +} + // loadAttachmentForDownload is a workspace-self-resolving variant used by the // /api/attachments/{id}/download endpoint. It looks the attachment up by ID // alone, then enforces that the authenticated user is a member of the @@ -820,6 +876,9 @@ func (h *Handler) loadAttachmentForDownload(w http.ResponseWriter, r *http.Reque return db.Attachment{}, false } if h.MembershipCache.Get(r.Context(), userID, workspaceID) { + if !h.authorizeAttachmentIssue(w, r, att) { + return db.Attachment{}, false + } return att, true } if _, err := h.getWorkspaceMember(r.Context(), userID, workspaceID); err != nil { @@ -827,6 +886,9 @@ func (h *Handler) loadAttachmentForDownload(w http.ResponseWriter, r *http.Reque return db.Attachment{}, false } h.MembershipCache.Set(r.Context(), userID, workspaceID) + if !h.authorizeAttachmentIssue(w, r, att) { + return db.Attachment{}, false + } return att, true } @@ -1442,6 +1504,9 @@ func (h *Handler) DeleteAttachment(w http.ResponseWriter, r *http.Request) { writeError(w, http.StatusNotFound, "attachment not found") return } + if !h.authorizeAttachmentIssue(w, r, att) { + return + } // Captured-context attachments are immutable historical copies. They are // deleted only with their target issue, workspace, or abandoned context. if att.SourceContextID.Valid { diff --git a/server/internal/handler/handler.go b/server/internal/handler/handler.go index 25b478822f1..0a92f595e30 100644 --- a/server/internal/handler/handler.go +++ b/server/internal/handler/handler.go @@ -45,6 +45,7 @@ import ( db "github.com/multica-ai/multica/server/pkg/db/generated" "github.com/multica-ai/multica/server/pkg/featureflag" "github.com/multica-ai/multica/server/pkg/llm" + "github.com/multica-ai/multica/server/pkg/protocol" ) // randomID returns a random 16-byte hex string used as a request ID for @@ -755,6 +756,28 @@ func (h *Handler) publish(eventType, workspaceID, actorType, actorID string, pay }) } +func (h *Handler) publishWithRecipients(eventType, workspaceID, actorType, actorID string, payload any, recipients []string) { + h.Bus.Publish(events.Event{ + Type: eventType, + WorkspaceID: workspaceID, + ActorType: actorType, + ActorID: actorID, + Payload: payload, + RecipientIDs: recipients, + }) +} + +func (h *Handler) publishDeletedIssue(ctx context.Context, issue db.Issue, actorType, actorID string) { + h.Bus.Publish(events.Event{ + Type: protocol.EventIssueDeleted, + WorkspaceID: uuidToString(issue.WorkspaceID), + ActorType: actorType, + ActorID: actorID, + Payload: map[string]any{"issue_id": uuidToString(issue.ID)}, + RecipientIDs: h.issueEventRecipients(ctx, issue), + }) +} + func (h *Handler) notifyDaemonWorkspacesChanged(userIDs ...string) { if h.DaemonWorkspaceRefresh == nil { return @@ -1058,6 +1081,12 @@ func (h *Handler) loadIssueForUser(w http.ResponseWriter, r *http.Request, issue // silently returns false for non-identifier strings, falling through to // the UUID path below. if issue, ok := h.resolveIssueByIdentifier(r.Context(), issueID, workspaceID); ok { + userID := requestUserID(r) + scope, scopeOK := h.objectVisibilityForMember(r.Context(), workspaceID, userID) + if !scopeOK || !h.canReadIssue(r.Context(), scope, issue) { + writeError(w, http.StatusNotFound, "issue not found") + return db.Issue{}, false + } return issue, true } @@ -1081,6 +1110,12 @@ func (h *Handler) loadIssueForUser(w http.ResponseWriter, r *http.Request, issue writeError(w, http.StatusNotFound, "issue not found") return db.Issue{}, false } + userID := requestUserID(r) + scope, scopeOK := h.objectVisibilityForMember(r.Context(), workspaceID, userID) + if !scopeOK || !h.canReadIssue(r.Context(), scope, issue) { + writeError(w, http.StatusNotFound, "issue not found") + return db.Issue{}, false + } return issue, true } @@ -1252,5 +1287,10 @@ func (h *Handler) loadInboxItemForUser(w http.ResponseWriter, r *http.Request, i writeError(w, http.StatusNotFound, "inbox item not found") return db.InboxItem{}, false } + if item.IssueID.Valid { + if _, ok := h.loadIssueForUser(w, r, uuidToString(item.IssueID)); !ok { + return db.InboxItem{}, false + } + } return item, true } diff --git a/server/internal/handler/handler_test.go b/server/internal/handler/handler_test.go index 7009c55d000..7fc3bde7097 100644 --- a/server/internal/handler/handler_test.go +++ b/server/internal/handler/handler_test.go @@ -654,9 +654,9 @@ func TestCreateIssueRejectsCrossWorkspaceProject(t *testing.T) { "title": "Should be rejected", "project_id": foreignProjectID, }) - w := testutil.Call(t, testHandler.CreateIssue, req).Want(http.StatusBadRequest) - if !strings.Contains(w.Body.String(), "project not found in this workspace") { - t.Fatalf("CreateIssue with foreign project: expected boundary error message, got %s", w.Body.String()) + w := testutil.Call(t, testHandler.CreateIssue, req).Want(http.StatusNotFound) + if !strings.Contains(w.Body.String(), "project not found") { + t.Fatalf("CreateIssue with inaccessible project: expected not-found response, got %s", w.Body.String()) } var count int diff --git a/server/internal/handler/inbox.go b/server/internal/handler/inbox.go index 15b0c756c90..42ffbd2f9be 100644 --- a/server/internal/handler/inbox.go +++ b/server/internal/handler/inbox.go @@ -3,6 +3,7 @@ package handler import ( "context" "encoding/json" + "fmt" "log/slog" "net/http" @@ -33,6 +34,40 @@ type InboxItemResponse struct { Details json.RawMessage `json:"details"` } +func (h *Handler) auditInboxRescue(ctx context.Context, scope objectVisibility, workspaceID, userID pgtype.UUID, archived, unreadOnly bool) error { + if !scope.rescue { + return nil + } + rows, err := h.DB.Query(ctx, `SELECT DISTINCT i.issue_id +FROM inbox_item i +WHERE i.workspace_id = $1 AND i.recipient_type = 'member' AND i.recipient_id = $2 + AND i.archived = $3 AND (NOT $4 OR i.read = false) + AND i.issue_id IS NOT NULL AND can_member_read_issue(i.workspace_id, i.issue_id, i.recipient_id)`, workspaceID, userID, archived, unreadOnly) + if err != nil { + return err + } + issueIDs := make([]pgtype.UUID, 0) + for rows.Next() { + var issueID pgtype.UUID + if err := rows.Scan(&issueID); err != nil { + rows.Close() + return err + } + issueIDs = append(issueIDs, issueID) + } + if err := rows.Err(); err != nil { + rows.Close() + return err + } + rows.Close() + for _, issueID := range issueIDs { + if !h.auditRescueObject(ctx, scope, "issue", issueID, "inbox_read") { + return fmt.Errorf("record rescue audit") + } + } + return nil +} + func inboxToResponse(i db.InboxItem) InboxItemResponse { return InboxItemResponse{ ID: uuidToString(i.ID), @@ -173,6 +208,15 @@ func (h *Handler) ListInbox(w http.ResponseWriter, r *http.Request) { writeError(w, http.StatusInternalServerError, "failed to list inbox") return } + visibility, ok := h.objectVisibilityForMember(r.Context(), workspaceID, userID) + if !ok { + writeError(w, http.StatusInternalServerError, "failed to resolve object visibility") + return + } + if err := h.auditInboxRescue(r.Context(), visibility, wsUUID, parseUUID(userID), false, false); err != nil { + writeError(w, http.StatusInternalServerError, "failed to record rescue access") + return + } resp := make([]InboxItemResponse, len(items)) for i, item := range items { @@ -211,6 +255,15 @@ func (h *Handler) ListArchivedInbox(w http.ResponseWriter, r *http.Request) { writeError(w, http.StatusInternalServerError, "failed to list archived inbox") return } + visibility, ok := h.objectVisibilityForMember(r.Context(), workspaceID, userID) + if !ok { + writeError(w, http.StatusInternalServerError, "failed to resolve object visibility") + return + } + if err := h.auditInboxRescue(r.Context(), visibility, wsUUID, parseUUID(userID), true, false); err != nil { + writeError(w, http.StatusInternalServerError, "failed to record rescue access") + return + } resp := make([]InboxItemResponse, len(items)) for i, item := range items { @@ -358,6 +411,15 @@ func (h *Handler) CountUnreadInbox(w http.ResponseWriter, r *http.Request) { if !ok { return } + visibility, ok := h.objectVisibilityForMember(r.Context(), workspaceID, userID) + if !ok { + writeError(w, http.StatusInternalServerError, "failed to resolve object visibility") + return + } + if err := h.auditInboxRescue(r.Context(), visibility, wsUUID, parseUUID(userID), false, true); err != nil { + writeError(w, http.StatusInternalServerError, "failed to record rescue access") + return + } count, err := h.Queries.CountUnreadInbox(r.Context(), db.CountUnreadInboxParams{ WorkspaceID: wsUUID, @@ -396,6 +458,18 @@ func (h *Handler) UnreadInboxSummary(w http.ResponseWriter, r *http.Request) { writeError(w, http.StatusInternalServerError, "failed to summarize unread inbox") return } + for _, row := range rows { + workspaceID := uuidToString(row.WorkspaceID) + visibility, ok := h.objectVisibilityForMember(r.Context(), workspaceID, userID) + if !ok { + writeError(w, http.StatusInternalServerError, "failed to resolve object visibility") + return + } + if err := h.auditInboxRescue(r.Context(), visibility, row.WorkspaceID, parseUUID(userID), false, true); err != nil { + writeError(w, http.StatusInternalServerError, "failed to record rescue access") + return + } + } resp := make([]InboxWorkspaceUnreadResponse, len(rows)) for i, row := range rows { diff --git a/server/internal/handler/inbox_archive.go b/server/internal/handler/inbox_archive.go index bdf33694bbb..9ea903f96d6 100644 --- a/server/internal/handler/inbox_archive.go +++ b/server/internal/handler/inbox_archive.go @@ -141,6 +141,19 @@ func (h *Handler) ListArchivedInboxPage(w http.ResponseWriter, r *http.Request) writeError(w, http.StatusInternalServerError, "failed to load archived inbox page") return } + visibility, ok := h.objectVisibilityForMember(r.Context(), uuidToString(filters.WorkspaceID), uuidToString(filters.RecipientID)) + if !ok { + writeError(w, http.StatusInternalServerError, "failed to resolve object visibility") + return + } + if visibility.rescue { + for _, row := range rows { + if row.InboxItem.IssueID.Valid && !h.auditRescueObject(r.Context(), visibility, "issue", row.InboxItem.IssueID, "inbox_archive_read") { + writeError(w, http.StatusInternalServerError, "failed to record rescue access") + return + } + } + } resp := archivedInboxPageResponse{Items: make([]InboxItemResponse, 0, limit), HasMore: len(rows) > limit} if resp.HasMore { rows = rows[:limit] @@ -181,6 +194,17 @@ func (h *Handler) GetArchivedInboxFacets(w http.ResponseWriter, r *http.Request) writeError(w, http.StatusInternalServerError, "failed to load archived inbox filters") return } + visibility, ok := h.objectVisibilityForMember(r.Context(), uuidToString(p.WorkspaceID), uuidToString(p.RecipientID)) + if !ok { + writeError(w, http.StatusInternalServerError, "failed to resolve object visibility") + return + } + if visibility.rescue { + if err := h.auditInboxRescue(r.Context(), visibility, p.WorkspaceID, p.RecipientID, true, false); err != nil { + writeError(w, http.StatusInternalServerError, "failed to record rescue access") + return + } + } resp := archivedInboxFacetsResponse{Statuses: map[string]int64{}, Priorities: map[string]int64{}, Actors: map[string]int64{}} for _, row := range rows { switch row.Dimension { diff --git a/server/internal/handler/issue.go b/server/internal/handler/issue.go index 879cf4ac580..9c98802feff 100644 --- a/server/internal/handler/issue.go +++ b/server/internal/handler/issue.go @@ -22,6 +22,7 @@ import ( "github.com/jackc/pgx/v5/pgtype" "github.com/multica-ai/multica/server/internal/channelmedia" "github.com/multica-ai/multica/server/internal/dispatch" + "github.com/multica-ai/multica/server/internal/events" "github.com/multica-ai/multica/server/internal/issueguard" "github.com/multica-ai/multica/server/internal/issuestatus" "github.com/multica-ai/multica/server/internal/logger" @@ -806,7 +807,7 @@ type searchResult struct { // case-insensitive LIKE semantics as the legacy query. The pipeline deliberately // trades the title, description, and comment content GIN fast paths for one // predictable pass over each relation within the selected workspace. -func buildSearchQuery(phrase string, terms []string, queryNum int, hasNum bool, includeClosed bool, terminalStatusKeys []string) (string, []any) { +func buildSearchQuery(phrase string, terms []string, queryNum int, hasNum bool, includeClosed bool, terminalStatusKeys []string, visibilityScopes ...objectVisibility) (string, []any) { // Lowercase in Go so SQL only needs LOWER() on the column side. phrase = strings.ToLower(phrase) for i, term := range terms { @@ -846,6 +847,10 @@ func buildSearchQuery(phrase string, terms []string, queryNum int, hasNum bool, limitParam := nextArg(nil) offsetParam := nextArg(nil) + visibilityPredicate := "TRUE" + if len(visibilityScopes) > 0 { + visibilityPredicate = visibilityScopes[0].issueVisibilityPredicate("i", nextArg) + } // Stage one scans this workspace's issues once and retains only the narrow // flags and sort fields needed to choose a page. Do not force this CTE to be @@ -885,7 +890,7 @@ func buildSearchQuery(phrase string, terms []string, queryNum int, hasNum bool, ) } - issueWhere := "i.workspace_id = " + wsParam + issueWhere := "i.workspace_id = " + wsParam + " AND " + visibilityPredicate if terminalStatusesParam != "" { issueWhere += fmt.Sprintf(" AND NOT (i.status = ANY(%s::text[]))", terminalStatusesParam) } @@ -1150,6 +1155,15 @@ func (h *Handler) SearchIssues(w http.ResponseWriter, r *http.Request) { if !ok { return } + userID, ok := requireUserID(w, r) + if !ok { + return + } + visibility, ok := h.objectVisibilityForMember(ctx, workspaceID, userID) + if !ok { + writeError(w, http.StatusInternalServerError, "failed to resolve object visibility") + return + } terms := splitSearchTerms(q) queryNum, hasNum := parseQueryNumber(q) var terminalStatusKeys []string @@ -1163,11 +1177,19 @@ func (h *Handler) SearchIssues(w http.ResponseWriter, r *http.Request) { terminalStatusKeys = resolvedKeys } - sqlQuery, args := buildSearchQuery(q, terms, queryNum, hasNum, includeClosed, terminalStatusKeys) - // Fill placeholder args: $4 = workspace_id, last two = limit, offset + sqlQuery, args := buildSearchQuery(q, terms, queryNum, hasNum, includeClosed, terminalStatusKeys, visibility) + // Fill the reserved placeholders. The visibility predicate is appended + // after limit/offset for non-rescue members, so account for that optional + // argument instead of relying on fixed positions near the end of args. args[3] = wsUUID - args[len(args)-2] = limit - args[len(args)-1] = offset + limitIndex := len(args) - 2 + offsetIndex := len(args) - 1 + if !visibility.rescue { + limitIndex-- + offsetIndex-- + } + args[limitIndex] = limit + args[offsetIndex] = offset var results []searchResult err := runSearchQuery(ctx, h.TxStarter, sqlQuery, args, func(rows pgx.Rows) error { @@ -1225,6 +1247,14 @@ func (h *Handler) SearchIssues(w http.ResponseWriter, r *http.Request) { return } + if visibility.rescue { + for _, result := range results { + if !h.auditRescueObject(ctx, visibility, "issue", result.issue.ID, "search") { + writeError(w, http.StatusInternalServerError, "failed to record rescue access") + return + } + } + } prefix := h.getIssuePrefix(ctx, wsUUID) var originals []pgtype.UUID for _, sr := range results { @@ -1290,6 +1320,15 @@ func (h *Handler) ListIssues(w http.ResponseWriter, r *http.Request) { if !ok { return } + userID, ok := requireUserID(w, r) + if !ok { + return + } + visibility, ok := h.objectVisibilityForMember(ctx, workspaceID, userID) + if !ok { + writeError(w, http.StatusInternalServerError, "failed to resolve object visibility") + return + } // Parse optional filter params. Malformed UUIDs in filters return 400 — // silently coercing them to a zero UUID would mask a client bug and let @@ -1395,7 +1434,16 @@ func (h *Handler) ListIssues(w http.ResponseWriter, r *http.Request) { writeError(w, http.StatusInternalServerError, "failed to list issues") return } - + visibleIssues := issues[:0] + for _, issue := range issues { + if h.canReadIssue(ctx, visibility, db.Issue{ + ID: issue.ID, CreatorType: issue.CreatorType, CreatorID: issue.CreatorID, + AssigneeType: issue.AssigneeType, AssigneeID: issue.AssigneeID, + }) { + visibleIssues = append(visibleIssues, issue) + } + } + issues = visibleIssues prefix := h.getIssuePrefix(ctx, wsUUID) ids := make([]pgtype.UUID, len(issues)) var originals []pgtype.UUID @@ -1547,6 +1595,7 @@ func (h *Handler) ListIssues(w http.ResponseWriter, r *http.Request) { args = append(args, v) return "$" + strconv.Itoa(len(args)) } + where = append(where, visibility.issueVisibilityPredicate("i", addArg)) if len(statusCategoriesFilter) > 0 { // Expanded to concrete status keys rather than filtered through // issue_effective_status(): wrapping the column in a function makes the @@ -1804,6 +1853,14 @@ LIMIT %s OFFSET %s`, whereSql, orderBy, limitRef, offsetRef) writeError(w, http.StatusInternalServerError, "failed to list issues") return } + if visibility.rescue { + for _, issue := range issues { + if !h.auditRescueObject(ctx, visibility, "issue", issue.ID, "list") { + writeError(w, http.StatusInternalServerError, "failed to record rescue access") + return + } + } + } // Get the true total count for pagination awareness. countQuery := fmt.Sprintf(`SELECT COUNT(*) FROM issue i WHERE %s`, whereSql) @@ -2000,7 +2057,6 @@ func (h *Handler) ListGroupedIssues(w http.ResponseWriter, r *http.Request) { writeError(w, http.StatusInternalServerError, "database is unavailable") return } - groupBy := r.URL.Query().Get("group_by") if groupBy == "" { groupBy = "assignee" @@ -2015,6 +2071,15 @@ func (h *Handler) ListGroupedIssues(w http.ResponseWriter, r *http.Request) { if !ok { return } + userID, ok := requireUserID(w, r) + if !ok { + return + } + visibility, ok := h.objectVisibilityForMember(ctx, workspaceID, userID) + if !ok { + writeError(w, http.StatusInternalServerError, "failed to resolve object visibility") + return + } limit := 50 offset := 0 @@ -2038,6 +2103,7 @@ func (h *Handler) ListGroupedIssues(w http.ResponseWriter, r *http.Request) { args = append(args, v) return "$" + strconv.Itoa(len(args)) } + where = append(where, visibility.issueVisibilityPredicate("i", addArg)) statuses := splitCommaParam(r.URL.Query().Get("statuses")) if len(statuses) == 0 { @@ -2421,6 +2487,14 @@ ORDER BY } groupedRows = append(groupedRows, row) } + if visibility.rescue { + for _, row := range groupedRows { + if !h.auditRescueObject(ctx, visibility, "issue", row.ID, "grouped_list") { + writeError(w, http.StatusInternalServerError, "failed to record rescue access") + return + } + } + } if err := rows.Err(); err != nil { slog.Warn("ListGroupedIssues rows failed", "error", err) writeError(w, http.StatusInternalServerError, "failed to list grouped issues") @@ -2531,6 +2605,23 @@ func (h *Handler) ListChildIssues(w http.ResponseWriter, r *http.Request) { writeError(w, http.StatusInternalServerError, "failed to list child issues") return } + userID := requestUserID(r) + visibility, ok := h.objectVisibilityForMember(r.Context(), uuidToString(issue.WorkspaceID), userID) + if !ok { + writeError(w, http.StatusInternalServerError, "failed to resolve object visibility") + return + } + visibleChildren := children[:0] + for _, child := range children { + if !h.canReadIssue(r.Context(), visibility, child) || !child.ParentIssueID.Valid { + continue + } + parent, err := h.Queries.GetIssueInWorkspace(r.Context(), db.GetIssueInWorkspaceParams{ID: child.ParentIssueID, WorkspaceID: issue.WorkspaceID}) + if err == nil && h.canReadIssue(r.Context(), visibility, parent) { + visibleChildren = append(visibleChildren, child) + } + } + children = visibleChildren prefix := h.getIssuePrefix(r.Context(), issue.WorkspaceID) ids := make([]pgtype.UUID, len(children)) for i, child := range children { @@ -2577,6 +2668,15 @@ func (h *Handler) ListChildrenByParents(w http.ResponseWriter, r *http.Request) if !ok { return } + userID, ok := requireUserID(w, r) + if !ok { + return + } + visibility, ok := h.objectVisibilityForMember(r.Context(), workspaceID, userID) + if !ok { + writeError(w, http.StatusInternalServerError, "failed to resolve object visibility") + return + } raw := r.URL.Query().Get("parent_ids") if raw == "" { @@ -2617,6 +2717,13 @@ func (h *Handler) ListChildrenByParents(w http.ResponseWriter, r *http.Request) writeError(w, http.StatusInternalServerError, "failed to list child issues") return } + visibleChildren := children[:0] + for _, child := range children { + if h.canReadIssue(r.Context(), visibility, child) { + visibleChildren = append(visibleChildren, child) + } + } + children = visibleChildren prefix := h.getIssuePrefix(r.Context(), wsUUID) ids := make([]pgtype.UUID, len(children)) for i, child := range children { @@ -2650,16 +2757,36 @@ func (h *Handler) ChildIssueProgress(w http.ResponseWriter, r *http.Request) { if !ok { return } + userID, ok := requireUserID(w, r) + if !ok { + return + } + visibility, ok := h.objectVisibilityForMember(r.Context(), wsID, userID) + if !ok { + writeError(w, http.StatusInternalServerError, "failed to resolve object visibility") + return + } terminalStatusKeys, err := h.terminalIssueStatusKeys(r.Context(), wsUUID) if err != nil { writeError(w, http.StatusInternalServerError, "failed to resolve status categories") return } - rows, err := h.Queries.ChildIssueProgress(r.Context(), db.ChildIssueProgressParams{ - WorkspaceID: wsUUID, - TerminalStatusKeys: terminalStatusKeys, - }) + args := []any{wsUUID, terminalStatusKeys} + addArg := func(value any) string { + args = append(args, value) + return fmt.Sprintf("$%d", len(args)) + } + childPredicate := visibility.issueVisibilityPredicate("child", addArg) + parentPredicate := visibility.issueVisibilityPredicate("parent", addArg) + rows, err := h.DB.Query(r.Context(), `SELECT child.parent_issue_id, + COUNT(*)::bigint AS total, + COUNT(*) FILTER (WHERE child.status = ANY($2::text[]))::bigint AS done +FROM issue child +JOIN issue parent ON parent.id = child.parent_issue_id AND parent.workspace_id = child.workspace_id +WHERE child.workspace_id = $1 AND child.parent_issue_id IS NOT NULL + AND `+childPredicate+` AND `+parentPredicate+` +GROUP BY child.parent_issue_id`, args...) if err != nil { writeError(w, http.StatusInternalServerError, "failed to get child issue progress") return @@ -2670,14 +2797,23 @@ func (h *Handler) ChildIssueProgress(w http.ResponseWriter, r *http.Request) { Total int64 `json:"total"` Done int64 `json:"done"` } - resp := make([]progressEntry, len(rows)) - for i, row := range rows { - resp[i] = progressEntry{ - ParentIssueID: uuidToString(row.ParentIssueID), - Total: row.Total, - Done: row.Done, + resp := make([]progressEntry, 0) + for rows.Next() { + var parentID pgtype.UUID + var total, done int64 + if err := rows.Scan(&parentID, &total, &done); err != nil { + rows.Close() + writeError(w, http.StatusInternalServerError, "failed to get child issue progress") + return } + resp = append(resp, progressEntry{ParentIssueID: uuidToString(parentID), Total: total, Done: done}) } + if err := rows.Err(); err != nil { + rows.Close() + writeError(w, http.StatusInternalServerError, "failed to get child issue progress") + return + } + rows.Close() writeJSON(w, http.StatusOK, map[string]any{ "progress": resp, }) @@ -2882,13 +3018,19 @@ func (h *Handler) QuickCreateIssue(w http.ResponseWriter, r *http.Request) { if !ok { return } - if _, err := h.Queries.GetProjectInWorkspace(r.Context(), db.GetProjectInWorkspaceParams{ + project, err := h.Queries.GetProjectInWorkspace(r.Context(), db.GetProjectInWorkspaceParams{ ID: pid, WorkspaceID: wsUUID, - }); err != nil { + }) + if err != nil { writeError(w, http.StatusBadRequest, "project not found") return } + visibility, visible := h.objectVisibilityForMember(r.Context(), workspaceID, requesterID) + if !visible || !h.canReadProject(r.Context(), visibility, project) { + writeError(w, http.StatusNotFound, "project not found") + return + } projectUUID = pid } @@ -2910,6 +3052,11 @@ func (h *Handler) QuickCreateIssue(w http.ResponseWriter, r *http.Request) { writeError(w, http.StatusBadRequest, "parent issue not found in this workspace") return } + visibility, visible := h.objectVisibilityForMember(r.Context(), workspaceID, requesterID) + if !visible || !h.canReadIssue(r.Context(), visibility, parent) { + writeError(w, http.StatusNotFound, "parent issue not found") + return + } parentIssueUUID = pid } @@ -3249,7 +3396,7 @@ func (h *Handler) CreateIssue(w http.ResponseWriter, r *http.Request) { // found" rather than a 403 that leaks nothing about which input was wrong. // The row itself is no longer needed: the assignee gate keys on the actor's // originator, not on a scope bound to the parent (MUL-6951). - if assigneeType.Valid && (assigneeType.String == "agent" || assigneeType.String == "squad") { + { parent, err := h.Queries.GetIssueInWorkspace(r.Context(), db.GetIssueInWorkspaceParams{ ID: parentIssueID, WorkspaceID: wsUUID, @@ -3258,6 +3405,11 @@ func (h *Handler) CreateIssue(w http.ResponseWriter, r *http.Request) { writeError(w, http.StatusBadRequest, "parent issue not found in this workspace") return } + visibility, visible := h.objectVisibilityForMember(r.Context(), workspaceID, creatorID) + if !visible || !h.canReadIssue(r.Context(), visibility, parent) { + writeError(w, http.StatusNotFound, "parent issue not found") + return + } } } @@ -3271,6 +3423,16 @@ func (h *Handler) CreateIssue(w http.ResponseWriter, r *http.Request) { if !ok { return } + project, err := h.Queries.GetProjectInWorkspace(r.Context(), db.GetProjectInWorkspaceParams{ID: id, WorkspaceID: wsUUID}) + if err != nil { + writeError(w, http.StatusNotFound, "project not found") + return + } + visibility, visible := h.objectVisibilityForMember(r.Context(), workspaceID, creatorID) + if !visible || !h.canReadProject(r.Context(), visibility, project) { + writeError(w, http.StatusNotFound, "project not found") + return + } projectID = id } // Project existence and the final parent boundary check are enforced inside @@ -3878,13 +4040,19 @@ func (h *Handler) UpdateIssue(w http.ResponseWriter, r *http.Request) { return } // Validate parent exists in the same workspace. - if _, err := h.Queries.GetIssueInWorkspace(r.Context(), db.GetIssueInWorkspaceParams{ + parent, err := h.Queries.GetIssueInWorkspace(r.Context(), db.GetIssueInWorkspaceParams{ ID: newParentID, WorkspaceID: prevIssue.WorkspaceID, - }); err != nil { + }) + if err != nil { writeError(w, http.StatusBadRequest, "parent issue not found in this workspace") return } + visibility, visible := h.objectVisibilityForMember(r.Context(), uuidToString(prevIssue.WorkspaceID), requestUserID(r)) + if !visible || !h.canReadIssue(r.Context(), visibility, parent) { + writeError(w, http.StatusNotFound, "parent issue not found") + return + } // Cycle detection: walk up from the new parent to ensure we don't reach this issue. cursor := newParentID for depth := 0; depth < 10; depth++ { @@ -3909,10 +4077,11 @@ func (h *Handler) UpdateIssue(w http.ResponseWriter, r *http.Request) { if !ok { return } - if _, err := h.Queries.GetProjectInWorkspace(r.Context(), db.GetProjectInWorkspaceParams{ + project, err := h.Queries.GetProjectInWorkspace(r.Context(), db.GetProjectInWorkspaceParams{ ID: projectUUID, WorkspaceID: prevIssue.WorkspaceID, - }); err != nil { + }) + if err != nil { if !isNotFound(err) { slog.Error("update issue: validate project scope", append(logger.RequestAttrs(r), "project_id", uuidToString(projectUUID), "error", err)...) @@ -3922,6 +4091,11 @@ func (h *Handler) UpdateIssue(w http.ResponseWriter, r *http.Request) { writeError(w, http.StatusBadRequest, "project not found in this workspace") return } + visibility, ok := h.objectVisibilityForMember(r.Context(), workspaceID, userID) + if !ok || !h.canReadProject(r.Context(), visibility, project) { + writeError(w, http.StatusNotFound, "project not found") + return + } params.ProjectID = projectUUID } else { params.ProjectID = pgtype.UUID{Valid: false} @@ -4310,6 +4484,7 @@ func (h *Handler) DeleteIssue(w http.ResponseWriter, r *http.Request) { if !ok { return } + deletedIssueRecipients := h.issueEventRecipients(r.Context(), issue) h.TaskService.CancelTasksForIssue(r.Context(), issue.ID) // Fail any linked autopilot runs before delete (ON DELETE SET NULL clears issue_id). @@ -4328,7 +4503,10 @@ func (h *Handler) DeleteIssue(w http.ResponseWriter, r *http.Request) { // identifier-style payload ("MUL-123") would leave stale entries on // other clients after an identifier-path delete. resolvedID := uuidToString(issue.ID) - h.publish(protocol.EventIssueDeleted, uuidToString(issue.WorkspaceID), actorType, actorID, map[string]any{"issue_id": resolvedID}) + h.Bus.Publish(events.Event{ + Type: protocol.EventIssueDeleted, WorkspaceID: uuidToString(issue.WorkspaceID), ActorType: actorType, ActorID: actorID, + Payload: map[string]any{"issue_id": resolvedID}, RecipientIDs: deletedIssueRecipients, + }) h.publishIssueSnapshots(r.Context(), deleteResult.DetachedChildren, actorType, actorID) h.publishClearedDuplicates(r.Context(), deleteResult.ClearedDuplicates, actorType, actorID) slog.Info("issue deleted", append(logger.RequestAttrs(r), "issue_id", resolvedID, "workspace_id", uuidToString(issue.WorkspaceID))...) @@ -4554,6 +4732,42 @@ func (h *Handler) BatchUpdateIssues(w http.ResponseWriter, r *http.Request) { if !ok { return } + visibility, ok := h.objectVisibilityForMember(r.Context(), workspaceID, userID) + if !ok { + writeError(w, http.StatusInternalServerError, "failed to resolve object visibility") + return + } + // Resolve and authorize the complete target set before the mutation loop so + // an inaccessible object can never leave a partially applied batch. + preflightIDs := make(map[pgtype.UUID]struct{}, len(req.IssueIDs)) + for _, rawID := range req.IssueIDs { + id, err := util.ParseUUID(rawID) + if err != nil { + writeError(w, http.StatusNotFound, "issue not found") + return + } + if _, duplicate := preflightIDs[id]; duplicate { + continue + } + prev, err := h.Queries.GetIssueInWorkspace(r.Context(), db.GetIssueInWorkspaceParams{ID: id, WorkspaceID: wsUUID}) + if err != nil || !h.canReadIssue(r.Context(), visibility, prev) { + writeError(w, http.StatusNotFound, "issue not found") + return + } + preflightIDs[id] = struct{}{} + } + if _, touched := rawUpdates["parent_issue_id"]; touched && req.Updates.ParentIssueID != nil { + parentID, err := util.ParseUUID(*req.Updates.ParentIssueID) + if err != nil { + writeError(w, http.StatusBadRequest, "invalid parent_issue_id") + return + } + parent, err := h.Queries.GetIssueInWorkspace(r.Context(), db.GetIssueInWorkspaceParams{ID: parentID, WorkspaceID: wsUUID}) + if err != nil || !h.canReadIssue(r.Context(), visibility, parent) { + writeError(w, http.StatusNotFound, "parent issue not found") + return + } + } // Status is validated against this workspace's catalog, so it has to wait // for wsUUID above. One check for the whole batch — every issue in it // shares the workspace — and a rejection rather than a silent skip, so a @@ -4577,10 +4791,11 @@ func (h *Handler) BatchUpdateIssues(w http.ResponseWriter, r *http.Request) { if !ok { return } - if _, err := h.Queries.GetProjectInWorkspace(r.Context(), db.GetProjectInWorkspaceParams{ + project, err := h.Queries.GetProjectInWorkspace(r.Context(), db.GetProjectInWorkspaceParams{ ID: projectUUID, WorkspaceID: wsUUID, - }); err != nil { + }) + if err != nil { if !isNotFound(err) { slog.Error("batch update issues: validate project scope", append(logger.RequestAttrs(r), "project_id", uuidToString(projectUUID), "error", err)...) @@ -4590,6 +4805,10 @@ func (h *Handler) BatchUpdateIssues(w http.ResponseWriter, r *http.Request) { writeError(w, http.StatusBadRequest, "project not found in this workspace") return } + if !h.canReadProject(r.Context(), visibility, project) { + writeError(w, http.StatusNotFound, "project not found") + return + } batchProjectID = projectUUID } @@ -4864,6 +5083,29 @@ func (h *Handler) BatchDeleteIssues(w http.ResponseWriter, r *http.Request) { if !ok { return } + visibility, ok := h.objectVisibilityForMember(r.Context(), workspaceID, userID) + if !ok { + writeError(w, http.StatusInternalServerError, "failed to resolve object visibility") + return + } + // Preflight every unique target before cancelling runs or deleting any row. + preflightIDs := make(map[pgtype.UUID]struct{}, len(req.IssueIDs)) + for _, rawID := range req.IssueIDs { + id, err := util.ParseUUID(rawID) + if err != nil { + writeError(w, http.StatusNotFound, "issue not found") + return + } + if _, duplicate := preflightIDs[id]; duplicate { + continue + } + issue, err := h.Queries.GetIssueInWorkspace(r.Context(), db.GetIssueInWorkspaceParams{ID: id, WorkspaceID: wsUUID}) + if err != nil || !h.canReadIssue(r.Context(), visibility, issue) { + writeError(w, http.StatusNotFound, "issue not found") + return + } + preflightIDs[id] = struct{}{} + } issues := make([]db.Issue, 0, len(req.IssueIDs)) excludedIDs := make([]pgtype.UUID, 0, len(req.IssueIDs)) seenIssueIDs := make(map[pgtype.UUID]struct{}, len(req.IssueIDs)) @@ -4889,6 +5131,10 @@ func (h *Handler) BatchDeleteIssues(w http.ResponseWriter, r *http.Request) { h.TaskService.CancelTasksForIssue(r.Context(), issue.ID) _ = h.AutopilotService.FailAutopilotRunsByIssue(r.Context(), issue.ID) } + issueRecipients := make(map[string][]string, len(issues)) + for _, issue := range issues { + issueRecipients[uuidToString(issue.ID)] = h.issueEventRecipients(r.Context(), issue) + } deleteResult, err := h.deleteIssuesAndCollectAttachmentURLs(r.Context(), issues, excludedIDs) if err != nil { slog.Warn("batch delete issues failed", "error", err) @@ -4898,7 +5144,10 @@ func (h *Handler) BatchDeleteIssues(w http.ResponseWriter, r *http.Request) { h.deleteS3Objects(r.Context(), deleteResult.AttachmentURLs) actorType, actorID := h.resolveActor(r, userID, workspaceID) for _, issue := range issues { - h.publish(protocol.EventIssueDeleted, workspaceID, actorType, actorID, map[string]any{"issue_id": uuidToString(issue.ID)}) + h.Bus.Publish(events.Event{ + Type: protocol.EventIssueDeleted, WorkspaceID: workspaceID, ActorType: actorType, ActorID: actorID, + Payload: map[string]any{"issue_id": uuidToString(issue.ID)}, RecipientIDs: issueRecipients[uuidToString(issue.ID)], + }) } h.publishIssueSnapshots(r.Context(), deleteResult.DetachedChildren, actorType, actorID) h.publishClearedDuplicates(r.Context(), deleteResult.ClearedDuplicates, actorType, actorID) diff --git a/server/internal/handler/issue_duplicate.go b/server/internal/handler/issue_duplicate.go index d5de58d8bac..764df12c31c 100644 --- a/server/internal/handler/issue_duplicate.go +++ b/server/internal/handler/issue_duplicate.go @@ -142,6 +142,11 @@ func (h *Handler) ListIssueDuplicates(w http.ResponseWriter, r *http.Request) { if !ok { return } + visibility, ok := h.objectVisibilityForMember(r.Context(), uuidToString(issue.WorkspaceID), requestUserID(r)) + if !ok { + writeError(w, http.StatusInternalServerError, "failed to resolve object visibility") + return + } prefix := h.getIssuePrefix(r.Context(), issue.WorkspaceID) fill := h.newStatusCategoryFiller(r.Context(), issue.WorkspaceID) @@ -152,7 +157,7 @@ func (h *Handler) ListIssueDuplicates(w http.ResponseWriter, r *http.Request) { WorkspaceID: issue.WorkspaceID, }) switch { - case err == nil: + case err == nil && h.canReadIssue(r.Context(), visibility, original): resp := issueToResponse(original, prefix) fill(&resp) duplicateOf = &resp @@ -174,6 +179,9 @@ func (h *Handler) ListIssueDuplicates(w http.ResponseWriter, r *http.Request) { } duplicates := make([]IssueResponse, 0, len(rows)) for _, row := range rows { + if !h.canReadIssue(r.Context(), visibility, row) { + continue + } resp := issueToResponse(row, prefix) fill(&resp) duplicates = append(duplicates, resp) diff --git a/server/internal/handler/issue_move.go b/server/internal/handler/issue_move.go index 7f47c3a146b..651b96b35da 100644 --- a/server/internal/handler/issue_move.go +++ b/server/internal/handler/issue_move.go @@ -12,6 +12,7 @@ import ( "github.com/jackc/pgx/v5" "github.com/jackc/pgx/v5/pgtype" "github.com/multica-ai/multica/server/internal/util" + db "github.com/multica-ai/multica/server/pkg/db/generated" ) var issueMoveFields = map[string]struct{}{ @@ -67,20 +68,22 @@ func (h *Handler) MoveIssue(w http.ResponseWriter, r *http.Request) { if !valid { return } - var exists bool - err := h.DB.QueryRow(r.Context(), ` - SELECT EXISTS ( - SELECT 1 - FROM project - WHERE workspace_id = $1 AND id = $2 - ) - `, current.WorkspaceID, *projectID).Scan(&exists) + project, err := h.Queries.GetProjectInWorkspace(r.Context(), db.GetProjectInWorkspaceParams{ID: *projectID, WorkspaceID: current.WorkspaceID}) if err != nil { - writeIssueTableQueryFailure(w, r, "failed to validate move project") + if !isNotFound(err) { + writeIssueTableQueryFailure(w, r, "failed to validate move project") + return + } + writeError(w, http.StatusBadRequest, "project not found in this workspace") return } - if !exists { - writeError(w, http.StatusBadRequest, "project not found in this workspace") + visibility, ok := h.objectVisibilityForMember(r.Context(), uuidToString(current.WorkspaceID), requestUserID(r)) + if !ok { + writeError(w, http.StatusInternalServerError, "failed to resolve object visibility") + return + } + if !h.canReadProject(r.Context(), visibility, project) { + writeError(w, http.StatusNotFound, "project not found") return } } @@ -171,12 +174,7 @@ func (h *Handler) issueMoveAnchorPosition( if id == nil { return nil, true } - var position float64 - err := h.DB.QueryRow(r.Context(), ` - SELECT position - FROM issue - WHERE workspace_id = $1 AND id = $2 - `, workspaceID, *id).Scan(&position) + anchor, err := h.Queries.GetIssueInWorkspace(r.Context(), db.GetIssueInWorkspaceParams{ID: *id, WorkspaceID: workspaceID}) if err != nil { if errors.Is(err, pgx.ErrNoRows) { writeError(w, http.StatusBadRequest, "move anchor not found in this workspace") @@ -185,6 +183,16 @@ func (h *Handler) issueMoveAnchorPosition( } return nil, false } + visibility, ok := h.objectVisibilityForMember(r.Context(), uuidToString(workspaceID), requestUserID(r)) + if !ok { + writeError(w, http.StatusInternalServerError, "failed to resolve object visibility") + return nil, false + } + if !h.canReadIssue(r.Context(), visibility, anchor) { + writeError(w, http.StatusNotFound, "issue not found") + return nil, false + } + position := anchor.Position return &position, true } diff --git a/server/internal/handler/issue_table_facets.go b/server/internal/handler/issue_table_facets.go index 0b0481dc31b..506ff4af8aa 100644 --- a/server/internal/handler/issue_table_facets.go +++ b/server/internal/handler/issue_table_facets.go @@ -364,6 +364,7 @@ func (h *Handler) ListIssueTableFacets(w http.ResponseWriter, r *http.Request) { } r, cancel := withIssueTableQueryTimeout(r) defer cancel() + auditWriter := h.DB snapshot, tx, err := h.beginIssueTableSnapshot(r.Context()) if err != nil { slog.Warn("ListIssueTableFacets snapshot failed", append(logger.RequestAttrs(r), "error", err)...) @@ -382,6 +383,9 @@ func (h *Handler) ListIssueTableFacets(w http.ResponseWriter, r *http.Request) { if !ok { return } + if !h.auditIssueTableRescue(w, r, base, auditWriter) { + return + } seen := make(map[string]struct{}, len(request.Facets)) normalizedFacets := make([]issueTableFacetSpec, len(request.Facets)) diff --git a/server/internal/handler/issue_table_group.go b/server/internal/handler/issue_table_group.go index 4a244060c6f..3ee4dc385d9 100644 --- a/server/internal/handler/issue_table_group.go +++ b/server/internal/handler/issue_table_group.go @@ -900,6 +900,7 @@ func (h *Handler) ListIssueTableGroups(w http.ResponseWriter, r *http.Request) { } r, cancel := withIssueTableQueryTimeout(r) defer cancel() + auditWriter := h.DB snapshot, tx, err := h.beginIssueTableSnapshot(r.Context()) if err != nil { slog.Warn("ListIssueTableGroups snapshot failed", append(logger.RequestAttrs(r), "error", err)...) @@ -921,6 +922,9 @@ func (h *Handler) ListIssueTableGroups(w http.ResponseWriter, r *http.Request) { if !ok { return } + if !h.auditIssueTableRescue(w, r, compiled, auditWriter) { + return + } group, ok := h.resolveIssueTableGroup(w, r, compiled.workspaceID, request.Group, false) if !ok { return diff --git a/server/internal/handler/issue_table_query.go b/server/internal/handler/issue_table_query.go index 25abc4acd51..10322ccd13c 100644 --- a/server/internal/handler/issue_table_query.go +++ b/server/internal/handler/issue_table_query.go @@ -164,6 +164,7 @@ type issueTableSQL struct { args []any fingerprint string workspaceID pgtype.UUID + visibility objectVisibility } type issueTableCursor struct { @@ -446,6 +447,16 @@ func (h *Handler) compileIssueTableQuery(w http.ResponseWriter, r *http.Request, args = append(args, value) return "$" + strconv.Itoa(len(args)) } + userID, ok := requireUserID(w, r) + if !ok { + return issueTableSQL{}, false + } + visibility, ok := h.objectVisibilityForMember(r.Context(), workspaceID, userID) + if !ok { + writeError(w, http.StatusInternalServerError, "failed to resolve object visibility") + return issueTableSQL{}, false + } + where = append(where, visibility.issueVisibilityPredicate("i", addArg)) // Any non-empty status KEY, not just the 7 built-ins. A status filter names // the exact statuses the user picked, and since MUL-6243 those can be custom @@ -691,5 +702,45 @@ func (h *Handler) compileIssueTableQuery(w http.ResponseWriter, r *http.Request, args: args, fingerprint: fingerprint, workspaceID: workspaceUUID, + visibility: visibility, }, true } + +func (h *Handler) auditIssueTableRescue(w http.ResponseWriter, r *http.Request, query issueTableSQL, auditWriter dbExecutor) bool { + if !query.visibility.rescue { + return true + } + rows, err := h.DB.Query(r.Context(), fmt.Sprintf("SELECT i.id FROM issue i WHERE %s", query.where), query.args...) + if err != nil { + writeIssueTableQueryFailure(w, r, "failed to audit table rescue access") + return false + } + ids := make([]pgtype.UUID, 0) + for rows.Next() { + var id pgtype.UUID + if err := rows.Scan(&id); err != nil { + rows.Close() + writeError(w, http.StatusInternalServerError, "failed to audit table rescue access") + return false + } + ids = append(ids, id) + } + if err := rows.Err(); err != nil { + rows.Close() + writeIssueTableQueryFailure(w, r, "failed to audit table rescue access") + return false + } + rows.Close() + // The issue IDs are read from the repeatable-read, read-only snapshot. The + // rescue audit is a write, so it must use the handler's primary executor, + // not snapshot.DB (which is the read-only transaction). + auditHandler := *h + auditHandler.DB = auditWriter + for _, id := range ids { + if !auditHandler.auditRescueObject(r.Context(), query.visibility, "issue", id, "table_query") { + writeError(w, http.StatusInternalServerError, "failed to record rescue access") + return false + } + } + return true +} diff --git a/server/internal/handler/issue_table_query_test.go b/server/internal/handler/issue_table_query_test.go index 300a77a7c76..3c2afe8bf80 100644 --- a/server/internal/handler/issue_table_query_test.go +++ b/server/internal/handler/issue_table_query_test.go @@ -628,6 +628,32 @@ func TestIssueTableRowsCommitsBeforeBestEffortEnrichment(t *testing.T) { } } +func TestIssueTableRowsAuditRescueOutsideReadOnlySnapshot(t *testing.T) { + issueID := createTestIssue(t, "table rescue audit snapshot", "todo", "none") + t.Cleanup(func() { deleteTestIssue(t, issueID) }) + + recorder := httptest.NewRecorder() + testHandler.ListIssueTableRows(recorder, newRequest("POST", "/api/issues/table/rows", issueTableRowsRequest{ + Query: issueTableQuerySpec{Scope: issueTableScope{Kind: "workspace"}}, + Group: issueTableGroupSpec{Kind: "none"}, + Page: issueTablePageRequest{Limit: 100}, + })) + if recorder.Code != http.StatusOK { + t.Fatalf("rows status = %d: %s", recorder.Code, recorder.Body.String()) + } + + var auditCount int + if err := testPool.QueryRow(context.Background(), ` + SELECT count(*) FROM object_visibility_audit + WHERE workspace_id = $1 AND actor_user_id = $2 AND object_id = $3 AND action = 'table_query' + `, testWorkspaceID, testUserID, issueID).Scan(&auditCount); err != nil { + t.Fatalf("count rescue audit rows: %v", err) + } + if auditCount != 1 { + t.Fatalf("rescue audit rows = %d, want 1", auditCount) + } +} + func TestIssueTableStatusGroupingOverOneThousandRows(t *testing.T) { ctx := context.Background() suffix := time.Now().UnixNano() diff --git a/server/internal/handler/issue_table_rows.go b/server/internal/handler/issue_table_rows.go index 5ec8fef955f..72545f662eb 100644 --- a/server/internal/handler/issue_table_rows.go +++ b/server/internal/handler/issue_table_rows.go @@ -271,6 +271,9 @@ func (h *Handler) ListIssueTableRows(w http.ResponseWriter, r *http.Request) { if !ok { return } + if !h.auditIssueTableRescue(w, r, compiled, baseHandler.DB) { + return + } if !issueTableCursorMatches(w, cursor, compiled.fingerprint, groupKey, request.ParentID) { return } diff --git a/server/internal/handler/issue_trigger.go b/server/internal/handler/issue_trigger.go index 8ac03ad612d..75c74cb970f 100644 --- a/server/internal/handler/issue_trigger.go +++ b/server/internal/handler/issue_trigger.go @@ -153,6 +153,11 @@ func (h *Handler) PreviewIssueTrigger(w http.ResponseWriter, r *http.Request) { writeError(w, http.StatusBadRequest, "workspace is required") return } + visibility, ok := h.objectVisibilityForMember(r.Context(), workspaceID, userID) + if !ok { + writeError(w, http.StatusInternalServerError, "failed to resolve object visibility") + return + } var req IssueTriggerPreviewRequest if err := json.NewDecoder(r.Body).Decode(&req); err != nil { @@ -229,6 +234,9 @@ func (h *Handler) PreviewIssueTrigger(w http.ResponseWriter, r *http.Request) { if err != nil { continue // cross-workspace / unknown id contributes no trigger } + if !h.canReadIssue(r.Context(), visibility, loaded) { + continue + } post := loaded in := service.IssueTriggerInput{PrevStatus: loaded.Status} diff --git a/server/internal/handler/issue_validation_test.go b/server/internal/handler/issue_validation_test.go index 5cc2ec15fda..8d5b8045f85 100644 --- a/server/internal/handler/issue_validation_test.go +++ b/server/internal/handler/issue_validation_test.go @@ -64,9 +64,11 @@ func TestUpdateIssueInvalidPriorityReturns400(t *testing.T) { } func TestBatchUpdateIssuesInvalidStatusReturns400(t *testing.T) { + issueID := createTestIssue(t, "batch invalid status issue", "todo", "none") + t.Cleanup(func() { deleteTestIssue(t, issueID) }) w := httptest.NewRecorder() req := newRequest("POST", "/api/issues/batch-update", map[string]any{ - "issue_ids": []string{"not-needed"}, + "issue_ids": []string{issueID}, "updates": map[string]any{ "status": "active", }, diff --git a/server/internal/handler/object_visibility.go b/server/internal/handler/object_visibility.go new file mode 100644 index 00000000000..1ea876100fb --- /dev/null +++ b/server/internal/handler/object_visibility.go @@ -0,0 +1,247 @@ +package handler + +import ( + "context" + "fmt" + "log/slog" + + "github.com/jackc/pgx/v5/pgtype" + "github.com/multica-ai/multica/server/internal/util" + db "github.com/multica-ai/multica/server/pkg/db/generated" +) + +// objectVisibility is the requester's effective business-object scope. Agent +// invocation permission is an input to this scope, not the visibility model +// itself. Keeping the derived IDs together makes the same policy usable by +// single-object checks and SQL collection filters. +type objectVisibility struct { + userID pgtype.UUID + workspaceID pgtype.UUID + rescue bool +} + +func (h *Handler) canReadIssue(ctx context.Context, s objectVisibility, issue db.Issue) bool { + if s.rescue { + return h.auditRescueObject(ctx, s, "issue", issue.ID, "read_or_write") + } + var allowed bool + err := h.DB.QueryRow(ctx, "SELECT can_member_read_issue($1, $2, $3)", s.workspaceID, issue.ID, s.userID).Scan(&allowed) + return err == nil && allowed +} + +func (h *Handler) canReadProject(ctx context.Context, s objectVisibility, project db.Project) bool { + if s.rescue { + return h.auditRescueObject(ctx, s, "project", project.ID, "read_or_write") + } + var visible bool + err := h.DB.QueryRow(ctx, "SELECT can_member_read_project($1, $2, $3)", s.workspaceID, project.ID, s.userID).Scan(&visible) + return err == nil && visible +} + +func (s objectVisibility) projectVisibilityPredicate(alias string, addArg func(any) string) string { + if s.rescue { + return "TRUE" + } + u := addArg(s.userID) + return "can_member_read_project(" + alias + ".workspace_id, " + alias + ".id, " + u + "::uuid)" +} + +func (h *Handler) canReadSquad(ctx context.Context, s objectVisibility, squad db.Squad) bool { + if s.rescue { + return h.auditRescueObject(ctx, s, "squad", squad.ID, "read_or_write") + } + var allowed bool + err := h.DB.QueryRow(ctx, "SELECT can_member_read_squad($1, $2, $3)", s.workspaceID, squad.ID, s.userID).Scan(&allowed) + return err == nil && allowed +} + +// MemberCanReadBusinessObject exposes the unified visibility rule to event +// fanout, which runs outside the HTTP handler package boundary. +func (h *Handler) MemberCanReadBusinessObject(ctx context.Context, workspaceID, userID, objectType, objectID string) bool { + scope, ok := h.objectVisibilityForMember(ctx, workspaceID, userID) + if !ok { + return false + } + id, err := util.ParseUUID(objectID) + if err != nil { + return false + } + ws, err := util.ParseUUID(workspaceID) + if err != nil { + return false + } + switch objectType { + case "issue": + issue, err := h.Queries.GetIssueInWorkspace(ctx, db.GetIssueInWorkspaceParams{ID: id, WorkspaceID: ws}) + return err == nil && h.canReadIssue(ctx, scope, issue) + case "project": + project, err := h.Queries.GetProjectInWorkspace(ctx, db.GetProjectInWorkspaceParams{ID: id, WorkspaceID: ws}) + return err == nil && h.canReadProject(ctx, scope, project) + case "squad": + squad, err := h.Queries.GetSquadInWorkspace(ctx, db.GetSquadInWorkspaceParams{ID: id, WorkspaceID: ws}) + return err == nil && h.canReadSquad(ctx, scope, squad) + default: + return false + } +} + +// BusinessObjectRecipients returns the current member audience for one +// business object. SQL and HTTP checks share the same database predicates. +func (h *Handler) BusinessObjectRecipients(ctx context.Context, workspaceID, objectType, objectID string) ([]string, error) { + ws, err := util.ParseUUID(workspaceID) + if err != nil { + return nil, err + } + id, err := util.ParseUUID(objectID) + if err != nil { + return nil, err + } + function := "" + switch objectType { + case "issue": + function = "can_member_read_issue" + case "project": + function = "can_member_read_project" + case "squad": + function = "can_member_read_squad" + default: + return nil, fmt.Errorf("unsupported business object type") + } + rows, err := h.DB.Query(ctx, fmt.Sprintf(`SELECT m.user_id, m.role +FROM member m +WHERE m.workspace_id = $1 AND %s($1, $2, m.user_id)`, function), ws, id) + if err != nil { + return nil, err + } + type audienceMember struct { + userID pgtype.UUID + role string + } + members := make([]audienceMember, 0) + for rows.Next() { + var member audienceMember + if err := rows.Scan(&member.userID, &member.role); err != nil { + rows.Close() + return nil, err + } + members = append(members, member) + } + if err := rows.Err(); err != nil { + rows.Close() + return nil, err + } + rows.Close() + recipients := make([]string, 0, len(members)) + for _, member := range members { + recipients = append(recipients, uuidToString(member.userID)) + if roleAllowed(member.role, "owner", "admin") { + scope := objectVisibility{workspaceID: ws, userID: member.userID, rescue: true} + if !h.auditRescueObject(ctx, scope, objectType, id, "realtime_delivery") { + return nil, fmt.Errorf("record realtime rescue access") + } + } + } + return recipients, nil +} + +func (h *Handler) issueEventRecipients(ctx context.Context, issue db.Issue) []string { + members, err := h.Queries.ListMembers(ctx, issue.WorkspaceID) + if err != nil { + return nil + } + recipients := make([]string, 0, len(members)) + for _, member := range members { + var allowed bool + if err := h.DB.QueryRow(ctx, "SELECT can_member_read_issue($1, $2, $3)", issue.WorkspaceID, issue.ID, member.UserID).Scan(&allowed); err == nil && allowed { + recipients = append(recipients, uuidToString(member.UserID)) + } + } + return recipients +} + +func (h *Handler) auditRescueObject(ctx context.Context, scope objectVisibility, objectType string, objectID pgtype.UUID, action string) bool { + err := h.recordRescueObject(ctx, scope, objectType, objectID, action) + if err != nil { + slog.Error("record object visibility rescue access failed", "workspace_id", uuidToString(scope.workspaceID), "actor_user_id", uuidToString(scope.userID), "object_type", objectType, "object_id", uuidToString(objectID), "action", action, "error", err) + } + return err == nil +} + +func (h *Handler) recordRescueObject(ctx context.Context, scope objectVisibility, objectType string, objectID pgtype.UUID, action string) error { + if !scope.rescue { + return nil + } + _, err := h.DB.Exec(ctx, `INSERT INTO object_visibility_audit + (workspace_id, actor_user_id, object_type, object_id, action) +VALUES ($1, $2, $3, $4, $5)`, scope.workspaceID, scope.userID, objectType, objectID, action) + return err +} + +func (h *Handler) visibleProjectIssueStats(ctx context.Context, workspaceID pgtype.UUID, projectIDs []pgtype.UUID, scope objectVisibility) (map[string]db.GetProjectIssueStatsRow, error) { + result := make(map[string]db.GetProjectIssueStatsRow, len(projectIDs)) + if len(projectIDs) == 0 { + return result, nil + } + terminalKeys := h.projectTerminalIssueStatusKeys(ctx, workspaceID) + args := []any{workspaceID, projectIDs, terminalKeys} + addArg := func(value any) string { + args = append(args, value) + return fmt.Sprintf("$%d", len(args)) + } + predicate := scope.issueVisibilityPredicate("i", addArg) + rows, err := h.DB.Query(ctx, `SELECT i.project_id, + COUNT(*)::bigint AS total_count, + COUNT(*) FILTER (WHERE i.status = ANY($3::text[]))::bigint AS done_count +FROM issue i +WHERE i.workspace_id = $1 AND i.project_id = ANY($2::uuid[]) + AND `+predicate+` +GROUP BY i.project_id`, args...) + if err != nil { + return nil, err + } + defer rows.Close() + for rows.Next() { + var stats db.GetProjectIssueStatsRow + if err := rows.Scan(&stats.ProjectID, &stats.TotalCount, &stats.DoneCount); err != nil { + return nil, err + } + result[uuidToString(stats.ProjectID)] = stats + } + if err := rows.Err(); err != nil { + return nil, err + } + return result, nil +} + +func (h *Handler) objectVisibilityForMember(ctx context.Context, workspaceID, userID string) (objectVisibility, bool) { + ws, err := util.ParseUUID(workspaceID) + if err != nil { + return objectVisibility{}, false + } + user, err := util.ParseUUID(userID) + if err != nil { + return objectVisibility{}, false + } + member, err := h.Queries.GetMemberByUserAndWorkspace(ctx, db.GetMemberByUserAndWorkspaceParams{UserID: user, WorkspaceID: ws}) + if err != nil { + return objectVisibility{}, false + } + scope := objectVisibility{userID: user, workspaceID: ws, rescue: roleAllowed(member.Role, "owner", "admin")} + if scope.rescue { + return scope, true + } + + return scope, true +} + +// issueVisibilityPredicate returns a SQL predicate with arguments allocated by +// addArg. It deliberately keeps unrestricted workspace tasks visible, while +// restricting agent/squad assignments to the accepted subject mapping. The +// creator, explicit subscriber, and rescue rules are additive. +func (s objectVisibility) issueVisibilityPredicate(alias string, addArg func(any) string) string { + if s.rescue { + return "TRUE" + } + u := addArg(s.userID) + return "can_member_read_issue(" + alias + ".workspace_id, " + alias + ".id, " + u + "::uuid)" +} diff --git a/server/internal/handler/object_visibility_audit_test.go b/server/internal/handler/object_visibility_audit_test.go new file mode 100644 index 00000000000..b07db97ec47 --- /dev/null +++ b/server/internal/handler/object_visibility_audit_test.go @@ -0,0 +1,25 @@ +package handler + +import ( + "context" + "testing" +) + +func TestRecordRescueObjectAudit(t *testing.T) { + if testHandler == nil { + t.Skip("database not available") + } + ctx := context.Background() + scope, ok := testHandler.objectVisibilityForMember(ctx, testWorkspaceID, testUserID) + if !ok || !scope.rescue { + t.Fatal("integration fixture must be a workspace owner") + } + objectID := scope.userID + const action = "audit_insert_regression" + if err := testHandler.recordRescueObject(ctx, scope, "issue", objectID, action); err != nil { + t.Fatalf("insert visibility audit row: %v", err) + } + t.Cleanup(func() { + _, _ = testHandler.DB.Exec(ctx, `DELETE FROM object_visibility_audit WHERE workspace_id = $1 AND actor_user_id = $2 AND object_id = $3 AND action = $4`, scope.workspaceID, scope.userID, objectID, action) + }) +} diff --git a/server/internal/handler/pin.go b/server/internal/handler/pin.go index 578efab3756..5762864f594 100644 --- a/server/internal/handler/pin.go +++ b/server/internal/handler/pin.go @@ -1,11 +1,14 @@ package handler import ( + "context" "encoding/json" "net/http" "strings" "github.com/go-chi/chi/v5" + "github.com/jackc/pgx/v5/pgtype" + "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" ) @@ -50,6 +53,22 @@ type ReorderItem struct { Position float64 `json:"position"` } +func (h *Handler) canReadPinnedObject(ctx context.Context, visibility objectVisibility, itemType string, itemID, workspaceID, userID pgtype.UUID) bool { + switch itemType { + case "issue": + issue, err := h.Queries.GetIssueInWorkspace(ctx, db.GetIssueInWorkspaceParams{ID: itemID, WorkspaceID: workspaceID}) + return err == nil && h.canReadIssue(ctx, visibility, issue) + case "project": + project, err := h.Queries.GetProjectInWorkspace(ctx, db.GetProjectInWorkspaceParams{ID: itemID, WorkspaceID: workspaceID}) + return err == nil && h.canReadProject(ctx, visibility, project) + case "view": + view, err := h.Queries.GetIssueView(ctx, db.GetIssueViewParams{ID: itemID, WorkspaceID: workspaceID}) + return err == nil && canReadIssueView(view, userID) + default: + return false + } +} + func (h *Handler) ListPins(w http.ResponseWriter, r *http.Request) { userID, ok := requireUserID(w, r) if !ok { @@ -65,6 +84,11 @@ func (h *Handler) ListPins(w http.ResponseWriter, r *http.Request) { writeError(w, http.StatusInternalServerError, "failed to list pins") return } + visibility, ok := h.objectVisibilityForMember(r.Context(), workspaceID, userID) + if !ok { + writeError(w, http.StatusInternalServerError, "failed to resolve object visibility") + return + } // Capability opt-in: clients built before saved views classified every // non-issue pin as a project pin, fetched its detail, got 404, and @@ -79,6 +103,9 @@ func (h *Handler) ListPins(w http.ResponseWriter, r *http.Request) { if p.ItemType == "view" && !includeViews { continue } + if !h.canReadPinnedObject(r.Context(), visibility, p.ItemType, p.ItemID, parseUUID(workspaceID), parseUUID(userID)) { + continue + } resp = append(resp, pinnedItemToResponse(p)) } writeJSON(w, http.StatusOK, resp) @@ -117,16 +144,20 @@ func (h *Handler) CreatePin(w http.ResponseWriter, r *http.Request) { // Verify the item exists in this workspace switch req.ItemType { case "issue": - if _, err := h.Queries.GetIssueInWorkspace(r.Context(), db.GetIssueInWorkspaceParams{ + issue, err := h.Queries.GetIssueInWorkspace(r.Context(), db.GetIssueInWorkspaceParams{ ID: itemUUID, WorkspaceID: wsUUID, - }); err != nil { + }) + visibility, scopeOK := h.objectVisibilityForMember(r.Context(), workspaceID, userID) + if err != nil || !scopeOK || !h.canReadIssue(r.Context(), visibility, issue) { writeError(w, http.StatusNotFound, "issue not found") return } case "project": - if _, err := h.Queries.GetProjectInWorkspace(r.Context(), db.GetProjectInWorkspaceParams{ + project, err := h.Queries.GetProjectInWorkspace(r.Context(), db.GetProjectInWorkspaceParams{ ID: itemUUID, WorkspaceID: wsUUID, - }); err != nil { + }) + visibility, scopeOK := h.objectVisibilityForMember(r.Context(), workspaceID, userID) + if err != nil || !scopeOK || !h.canReadProject(r.Context(), visibility, project) { writeError(w, http.StatusNotFound, "project not found") return } @@ -191,6 +222,11 @@ func (h *Handler) DeletePin(w http.ResponseWriter, r *http.Request) { if !ok { return } + visibility, ok := h.objectVisibilityForMember(r.Context(), workspaceID, userID) + if !ok || !h.canReadPinnedObject(r.Context(), visibility, itemType, itemUUID, wsUUID, parseUUID(userID)) { + writeError(w, http.StatusNotFound, "pinned object not found") + return + } err := h.Queries.DeletePinnedItem(r.Context(), db.DeletePinnedItemParams{ WorkspaceID: wsUUID, @@ -227,6 +263,28 @@ func (h *Handler) ReorderPins(w http.ResponseWriter, r *http.Request) { if !ok { return } + visibility, ok := h.objectVisibilityForMember(r.Context(), workspaceID, userID) + if !ok { + writeError(w, http.StatusInternalServerError, "failed to resolve object visibility") + return + } + pins, err := h.Queries.ListPinnedItems(r.Context(), db.ListPinnedItemsParams{WorkspaceID: wsUUID, UserID: parseUUID(userID)}) + if err != nil { + writeError(w, http.StatusInternalServerError, "failed to list pins") + return + } + pinByID := make(map[pgtype.UUID]db.PinnedItem, len(pins)) + for _, pin := range pins { + pinByID[pin.ID] = pin + } + for _, item := range req.Items { + pinID, err := util.ParseUUID(item.ID) + pin, exists := pinByID[pinID] + if err != nil || !exists || !h.canReadPinnedObject(r.Context(), visibility, pin.ItemType, pin.ItemID, wsUUID, parseUUID(userID)) { + writeError(w, http.StatusNotFound, "pinned object not found") + return + } + } for _, item := range req.Items { itemUUID, ok := parseUUIDOrBadRequest(w, item.ID, "items[].id") diff --git a/server/internal/handler/plugin_action.go b/server/internal/handler/plugin_action.go index 6e55bf0ec06..7a734d1d7e4 100644 --- a/server/internal/handler/plugin_action.go +++ b/server/internal/handler/plugin_action.go @@ -240,6 +240,11 @@ func (h *Handler) pluginIssueForUser(w http.ResponseWriter, r *http.Request, cal publicapiv1.WriteProblem(w, r, http.StatusNotFound, "not_found", "issue not found") return db.Issue{}, false } + visibility, ok := h.objectVisibilityForMember(r.Context(), uuidToString(caller.WorkspaceID), uuidToString(caller.UserID)) + if !ok || !h.canReadIssue(r.Context(), visibility, issue) { + publicapiv1.WriteProblem(w, r, http.StatusNotFound, "not_found", "issue not found") + return db.Issue{}, false + } return issue, true } diff --git a/server/internal/handler/plugin_action_test.go b/server/internal/handler/plugin_action_test.go index c437e009e7d..cf98040507c 100644 --- a/server/internal/handler/plugin_action_test.go +++ b/server/internal/handler/plugin_action_test.go @@ -111,38 +111,22 @@ func TestPluginInstallTokenRunsIssueCommentWorkflow(t *testing.T) { get := httptest.NewRecorder() testHandler.GetPluginIssue(get, pluginInstallTokenRequest(http.MethodGet, "/v1/issues/"+issueID, token, nil, map[string]string{"issue_ref": issueID})) - if get.Code != http.StatusOK { - t.Fatalf("install-token issue read status=%d body=%s", get.Code, get.Body.String()) - } - - patchRequest := pluginInstallTokenRequest(http.MethodPatch, "/v1/issues/"+issueID, token, - map[string]any{"title": "Updated by install token"}, map[string]string{"issue_ref": issueID}) - patchRequest.Header.Set("If-Match", get.Header().Get("ETag")) - patch := httptest.NewRecorder() - testHandler.PatchPluginIssue(patch, patchRequest) - if patch.Code != http.StatusOK { - t.Fatalf("install-token issue patch status=%d body=%s", patch.Code, patch.Body.String()) - } - - create := httptest.NewRecorder() - testHandler.CreatePluginComment(create, pluginInstallTokenRequest(http.MethodPost, "/v1/issues/"+issueID+"/comments", token, - map[string]any{"content": "created with a real mpi token"}, map[string]string{"issue_ref": issueID})) - if create.Code != http.StatusCreated { - t.Fatalf("install-token comment create status=%d body=%s", create.Code, create.Body.String()) + if get.Code != http.StatusNotFound { + t.Fatalf("install-token issue read without a member visibility subject status=%d body=%s", get.Code, get.Body.String()) } - var comment publicapiv1.Comment - if err := json.Unmarshal(create.Body.Bytes(), &comment); err != nil { - t.Fatalf("decode install-token comment: %v", err) + var commentCount int + if err := testPool.QueryRow(context.Background(), `SELECT count(*) FROM comment WHERE issue_id = $1`, issueID).Scan(&commentCount); err != nil { + t.Fatal(err) } - if comment.AuthorType != "plugin" || comment.AuthorID != installationID { - t.Fatalf("install-token comment attribution = %+v", comment) + if commentCount != 0 { + t.Fatalf("inaccessible install-token issue produced %d comments", commentCount) } list := httptest.NewRecorder() testHandler.ListPluginComments(list, pluginInstallTokenRequest(http.MethodGet, "/v1/issues/"+issueID+"/comments", token, nil, map[string]string{"issue_ref": issueID})) - if list.Code != http.StatusOK || !strings.Contains(list.Body.String(), comment.ID) { - t.Fatalf("install-token comment list status=%d body=%s", list.Code, list.Body.String()) + if list.Code != http.StatusNotFound { + t.Fatalf("install-token comment list without a member visibility subject status=%d body=%s", list.Code, list.Body.String()) } } diff --git a/server/internal/handler/plugin_callback_test.go b/server/internal/handler/plugin_callback_test.go index a8590559339..66b100847ed 100644 --- a/server/internal/handler/plugin_callback_test.go +++ b/server/internal/handler/plugin_callback_test.go @@ -111,16 +111,15 @@ func TestCallbackFromAnEventHookWritesAsThePlugin(t *testing.T) { "/v1/issues/"+issueID+"/comments", map[string]any{"content": "posted by an event hook"}, map[string]string{"issue_ref": issueID})) - if recorder.Code != http.StatusCreated { - t.Fatalf("status=%d body=%s", recorder.Code, recorder.Body.String()) + if recorder.Code != http.StatusNotFound { + t.Fatalf("event-hook callback without a member visibility subject: status=%d body=%s", recorder.Code, recorder.Body.String()) } - - comment := latestComment(t, issueID) - if comment.AuthorType != "plugin" { - t.Fatalf("author_type = %q, want plugin: an event hook must not borrow a person's identity", comment.AuthorType) + var commentCount int + if err := testPool.QueryRow(context.Background(), `SELECT count(*) FROM comment WHERE issue_id = $1`, issueID).Scan(&commentCount); err != nil { + t.Fatal(err) } - if uuidToString(comment.AuthorID) != installationID { - t.Fatalf("author_id = %s, want the installation %s", uuidToString(comment.AuthorID), installationID) + if commentCount != 0 { + t.Fatalf("inaccessible event-hook issue produced %d comments", commentCount) } } diff --git a/server/internal/handler/project.go b/server/internal/handler/project.go index 5f8690a8588..96f7ae3bc91 100644 --- a/server/internal/handler/project.go +++ b/server/internal/handler/project.go @@ -139,6 +139,15 @@ func (h *Handler) ListProjects(w http.ResponseWriter, r *http.Request) { if !ok { return } + userID, ok := requireUserID(w, r) + if !ok { + return + } + visibility, ok := h.objectVisibilityForMember(r.Context(), workspaceID, userID) + if !ok { + writeError(w, http.StatusInternalServerError, "failed to resolve object visibility") + return + } var statusFilter pgtype.Text if s := r.URL.Query().Get("status"); s != "" { statusFilter = pgtype.Text{String: s, Valid: true} @@ -156,6 +165,13 @@ func (h *Handler) ListProjects(w http.ResponseWriter, r *http.Request) { writeError(w, http.StatusInternalServerError, "failed to list projects") return } + visibleProjects := projects[:0] + for _, project := range projects { + if h.canReadProject(r.Context(), visibility, project) { + visibleProjects = append(visibleProjects, project) + } + } + projects = visibleProjects // Batch-fetch issue stats and resource counts for all projects statsMap := make(map[string]db.GetProjectIssueStatsRow) @@ -165,16 +181,11 @@ func (h *Handler) ListProjects(w http.ResponseWriter, r *http.Request) { for i, p := range projects { projectIDs[i] = p.ID } - terminalStatusKeys := h.projectTerminalIssueStatusKeys(r.Context(), wsUUID) - stats, statsErr := h.Queries.GetProjectIssueStats(r.Context(), db.GetProjectIssueStatsParams{ - WorkspaceID: wsUUID, - ProjectIds: projectIDs, - TerminalStatusKeys: terminalStatusKeys, - }) - if statsErr == nil { - for _, s := range stats { - statsMap[uuidToString(s.ProjectID)] = s - } + var statsErr error + statsMap, statsErr = h.visibleProjectIssueStats(r.Context(), wsUUID, projectIDs, visibility) + if statsErr != nil { + writeError(w, http.StatusInternalServerError, "failed to resolve visible project stats") + return } counts, err := h.Queries.GetProjectResourceCounts(r.Context(), projectIDs) if err == nil { @@ -214,8 +225,28 @@ func (h *Handler) GetProject(w http.ResponseWriter, r *http.Request) { writeError(w, http.StatusNotFound, "project not found") return } + userID, ok := requireUserID(w, r) + if !ok { + return + } + visibility, ok := h.objectVisibilityForMember(r.Context(), workspaceID, userID) + if !ok { + writeError(w, http.StatusInternalServerError, "failed to resolve object visibility") + return + } + if !h.canReadProject(r.Context(), visibility, project) { + writeError(w, http.StatusNotFound, "project not found") + return + } resp := projectToResponse(project) - resp.IssueCount, resp.DoneCount = h.loadProjectIssueStats(r.Context(), wsUUID, project.ID) + visibleStats, statsErr := h.visibleProjectIssueStats(r.Context(), wsUUID, []pgtype.UUID{project.ID}, visibility) + if statsErr != nil { + writeError(w, http.StatusInternalServerError, "failed to resolve visible project stats") + return + } + if stats, exists := visibleStats[resp.ID]; exists { + resp.IssueCount, resp.DoneCount = stats.TotalCount, stats.DoneCount + } resp.ResourceCount = h.loadProjectResourceCount(r.Context(), project.ID) writeJSON(w, http.StatusOK, resp) } @@ -490,6 +521,15 @@ func (h *Handler) UpdateProject(w http.ResponseWriter, r *http.Request) { if !ok { return } + visibility, ok := h.objectVisibilityForMember(r.Context(), workspaceID, userID) + if !ok { + writeError(w, http.StatusInternalServerError, "failed to resolve object visibility") + return + } + if !h.canReadProject(r.Context(), visibility, prevProject) { + writeError(w, http.StatusNotFound, "project not found") + return + } bodyBytes, err := io.ReadAll(r.Body) if err != nil { writeError(w, http.StatusBadRequest, "failed to read request body") @@ -591,7 +631,14 @@ func (h *Handler) UpdateProject(w http.ResponseWriter, r *http.Request) { return } resp := projectToResponse(project) - resp.IssueCount, resp.DoneCount = h.loadProjectIssueStats(r.Context(), wsUUID, project.ID) + visibleStats, statsErr := h.visibleProjectIssueStats(r.Context(), wsUUID, []pgtype.UUID{project.ID}, visibility) + if statsErr != nil { + writeError(w, http.StatusInternalServerError, "failed to resolve visible project stats") + return + } + if stats, exists := visibleStats[resp.ID]; exists { + resp.IssueCount, resp.DoneCount = stats.TotalCount, stats.DoneCount + } resp.ResourceCount = h.loadProjectResourceCount(r.Context(), project.ID) h.publish(protocol.EventProjectUpdated, workspaceID, "member", userID, map[string]any{"project": resp}) writeJSON(w, http.StatusOK, resp) @@ -619,6 +666,20 @@ func (h *Handler) DeleteProject(w http.ResponseWriter, r *http.Request) { if !ok { return } + visibility, ok := h.objectVisibilityForMember(r.Context(), workspaceID, uuidToString(requester.UserID)) + if !ok { + writeError(w, http.StatusInternalServerError, "failed to resolve object visibility") + return + } + if !h.canReadProject(r.Context(), visibility, project) { + writeError(w, http.StatusNotFound, "project not found") + return + } + deleteRecipients, err := h.BusinessObjectRecipients(r.Context(), workspaceID, "project", uuidToString(project.ID)) + if err != nil { + writeError(w, http.StatusInternalServerError, "failed to resolve project event recipients") + return + } userID := uuidToString(requester.UserID) tx, err := h.TxStarter.Begin(r.Context()) if err != nil { @@ -666,7 +727,7 @@ func (h *Handler) DeleteProject(w http.ResponseWriter, r *http.Request) { writeError(w, http.StatusInternalServerError, "failed to commit project delete") return } - h.publish(protocol.EventProjectDeleted, workspaceID, "member", userID, map[string]any{"project_id": uuidToString(project.ID)}) + h.publishWithRecipients(protocol.EventProjectDeleted, workspaceID, "member", userID, map[string]any{"project_id": uuidToString(project.ID)}, deleteRecipients) w.WriteHeader(http.StatusNoContent) } @@ -678,7 +739,7 @@ type SearchProjectResponse struct { } // buildProjectSearchQuery builds a dynamic SQL query for project search. -func buildProjectSearchQuery(phrase string, terms []string, includeClosed bool) (string, []any) { +func buildProjectSearchQuery(phrase string, terms []string, includeClosed bool, visibilityScopes ...objectVisibility) (string, []any) { phrase = strings.ToLower(phrase) for i, t := range terms { terms[i] = strings.ToLower(t) @@ -736,6 +797,9 @@ func buildProjectSearchQuery(phrase string, terms []string, includeClosed bool) if !includeClosed { whereClause += " AND p.status NOT IN ('completed', 'cancelled')" } + if len(visibilityScopes) > 0 { + whereClause += " AND " + visibilityScopes[0].projectVisibilityPredicate("p", nextArg) + } // --- ORDER BY ranking --- var rankCases []string @@ -850,9 +914,18 @@ func (h *Handler) SearchProjects(w http.ResponseWriter, r *http.Request) { if !ok { return } + userID, ok := requireUserID(w, r) + if !ok { + return + } + visibility, ok := h.objectVisibilityForMember(ctx, workspaceID, userID) + if !ok { + writeError(w, http.StatusInternalServerError, "failed to resolve object visibility") + return + } terms := splitSearchTerms(q) - sqlQuery, args := buildProjectSearchQuery(q, terms, includeClosed) + sqlQuery, args := buildProjectSearchQuery(q, terms, includeClosed, visibility) args[1] = wsUUID args[len(args)-2] = limit args[len(args)-1] = offset @@ -903,6 +976,14 @@ func (h *Handler) SearchProjects(w http.ResponseWriter, r *http.Request) { writeError(w, http.StatusInternalServerError, "failed to search projects") return } + if visibility.rescue { + for _, row := range results { + if !h.auditRescueObject(ctx, visibility, "project", row.project.ID, "search") { + writeError(w, http.StatusInternalServerError, "failed to record rescue access") + return + } + } + } // Batch-fetch issue stats and resource counts statsMap := make(map[string]db.GetProjectIssueStatsRow) @@ -912,16 +993,11 @@ func (h *Handler) SearchProjects(w http.ResponseWriter, r *http.Request) { for i, r := range results { projectIDs[i] = r.project.ID } - terminalStatusKeys := h.projectTerminalIssueStatusKeys(ctx, wsUUID) - stats, statsErr := h.Queries.GetProjectIssueStats(ctx, db.GetProjectIssueStatsParams{ - WorkspaceID: wsUUID, - ProjectIds: projectIDs, - TerminalStatusKeys: terminalStatusKeys, - }) - if statsErr == nil { - for _, s := range stats { - statsMap[uuidToString(s.ProjectID)] = s - } + var statsErr error + statsMap, statsErr = h.visibleProjectIssueStats(ctx, wsUUID, projectIDs, visibility) + if statsErr != nil { + writeError(w, http.StatusInternalServerError, "failed to resolve visible project stats") + return } counts, err := h.Queries.GetProjectResourceCounts(ctx, projectIDs) if err == nil { diff --git a/server/internal/handler/project_resource.go b/server/internal/handler/project_resource.go index 525a6fb7e0e..91bb980fa08 100644 --- a/server/internal/handler/project_resource.go +++ b/server/internal/handler/project_resource.go @@ -522,6 +522,19 @@ func (h *Handler) loadProjectForResource(w http.ResponseWriter, r *http.Request, writeError(w, http.StatusNotFound, "project not found") return db.Project{}, false } + userID, ok := requireUserID(w, r) + if !ok { + return db.Project{}, false + } + visibility, ok := h.objectVisibilityForMember(r.Context(), uuidToString(wsUUID), userID) + if !ok { + writeError(w, http.StatusInternalServerError, "failed to resolve object visibility") + return db.Project{}, false + } + if !h.canReadProject(r.Context(), visibility, project) { + writeError(w, http.StatusNotFound, "project not found") + return db.Project{}, false + } return project, true } diff --git a/server/internal/handler/search.go b/server/internal/handler/search.go index c2c75350f17..70b13a6ef10 100644 --- a/server/internal/handler/search.go +++ b/server/internal/handler/search.go @@ -115,7 +115,7 @@ func runSearchQuery( args []any, rowsFn func(pgx.Rows) error, ) error { - tx, err := txStarter.Begin(ctx) + tx, err := beginSearchReadOnlyTx(ctx, txStarter) if err != nil { return fmt.Errorf("begin search tx: %w", err) } @@ -142,13 +142,6 @@ func runSearchQuery( return fmt.Errorf("set search work_mem: %w", err) } } - // The read-only mode is applied here rather than via TxOptions so we - // keep the txStarter interface signature (Begin only) intact. It's - // belt-and-suspenders — the search queries only SELECT anyway. - if _, err := tx.Exec(ctx, "SET LOCAL transaction_read_only = on"); err != nil { - return fmt.Errorf("set search transaction_read_only: %w", err) - } - rows, err := tx.Query(ctx, sql, args...) if err != nil { return err @@ -168,6 +161,25 @@ func runSearchQuery( return nil } +// beginSearchReadOnlyTx uses PostgreSQL's transaction option when the starter +// supports it so read-only behavior is attached to the transaction itself. +func beginSearchReadOnlyTx(ctx context.Context, starter txStarter) (pgx.Tx, error) { + if readOnlyStarter, ok := starter.(interface { + BeginTx(context.Context, pgx.TxOptions) (pgx.Tx, error) + }); ok { + return readOnlyStarter.BeginTx(ctx, pgx.TxOptions{AccessMode: pgx.ReadOnly}) + } + tx, err := starter.Begin(ctx) + if err != nil { + return nil, err + } + if _, err := tx.Exec(ctx, "SET LOCAL transaction_read_only = on"); err != nil { + _ = tx.Rollback(context.Background()) + return nil, fmt.Errorf("set search transaction_read_only: %w", err) + } + return tx, nil +} + // isSearchStatementTimeout reports whether err is the canonical Postgres // query_canceled error (SQLSTATE 57014) produced when the transaction-local // statement timeout fires. A client-side context cancellation surfaces from diff --git a/server/internal/handler/search_timeout_test.go b/server/internal/handler/search_timeout_test.go index a76fbb6483d..53657e1a49a 100644 --- a/server/internal/handler/search_timeout_test.go +++ b/server/internal/handler/search_timeout_test.go @@ -118,6 +118,10 @@ func TestRunSearchQuery_WorkMemIsTransactionLocal(t *testing.T) { if err := conn.QueryRow(ctx, "SHOW work_mem").Scan(&before); err != nil { t.Fatalf("read baseline work_mem: %v", err) } + var readOnlyBefore string + if err := conn.QueryRow(ctx, "SHOW transaction_read_only").Scan(&readOnlyBefore); err != nil { + t.Fatalf("read baseline transaction_read_only: %v", err) + } var during string err = runSearchQuery(ctx, conn, "SELECT current_setting('work_mem')", nil, func(rows pgx.Rows) error { @@ -144,6 +148,13 @@ func TestRunSearchQuery_WorkMemIsTransactionLocal(t *testing.T) { if after != before { t.Fatalf("transaction-local work_mem leaked: before=%q after=%q", before, after) } + var readOnlyAfter string + if err := conn.QueryRow(ctx, "SHOW transaction_read_only").Scan(&readOnlyAfter); err != nil { + t.Fatalf("read transaction_read_only after search: %v", err) + } + if readOnlyAfter != readOnlyBefore { + t.Fatalf("transaction_read_only leaked: before=%q after=%q", readOnlyBefore, readOnlyAfter) + } } // setSearchStatementTimeoutForTest is a package-private hook used only diff --git a/server/internal/handler/squad.go b/server/internal/handler/squad.go index aa2f21acf64..6da767e95f8 100644 --- a/server/internal/handler/squad.go +++ b/server/internal/handler/squad.go @@ -161,6 +161,19 @@ func (h *Handler) loadSquadInWorkspace(w http.ResponseWriter, r *http.Request) ( writeError(w, http.StatusNotFound, "squad not found") return db.Squad{}, "", false } + userID, ok := requireUserID(w, r) + if !ok { + return db.Squad{}, "", false + } + visibility, ok := h.objectVisibilityForMember(r.Context(), workspaceID, userID) + if !ok { + writeError(w, http.StatusInternalServerError, "failed to resolve object visibility") + return db.Squad{}, "", false + } + if !h.canReadSquad(r.Context(), visibility, squad) { + writeError(w, http.StatusNotFound, "squad not found") + return db.Squad{}, "", false + } return squad, workspaceID, true } @@ -194,11 +207,27 @@ func (h *Handler) ListSquads(w http.ResponseWriter, r *http.Request) { if !ok { return } + userID, ok := requireUserID(w, r) + if !ok { + return + } + visibility, ok := h.objectVisibilityForMember(r.Context(), workspaceID, userID) + if !ok { + writeError(w, http.StatusInternalServerError, "failed to resolve object visibility") + return + } squads, err := h.Queries.ListSquads(r.Context(), wsUUID) if err != nil { writeError(w, http.StatusInternalServerError, "failed to list squads") return } + visibleSquads := squads[:0] + for _, squad := range squads { + if h.canReadSquad(r.Context(), visibility, squad) { + visibleSquads = append(visibleSquads, squad) + } + } + squads = visibleSquads previewRows, err := h.Queries.ListSquadMemberPreviewRows(r.Context(), wsUUID) if err != nil { @@ -500,6 +529,11 @@ func (h *Handler) DeleteSquad(w http.ResponseWriter, r *http.Request) { writeError(w, http.StatusBadRequest, "squad is already archived") return } + deleteRecipients, err := h.BusinessObjectRecipients(r.Context(), workspaceID, "squad", uuidToString(squad.ID)) + if err != nil { + writeError(w, http.StatusInternalServerError, "failed to resolve squad event recipients") + return + } // Transfer issues assigned to this squad to the leader agent. if err := h.Queries.TransferSquadAssignees(r.Context(), db.TransferSquadAssigneesParams{ @@ -533,10 +567,10 @@ func (h *Handler) DeleteSquad(w http.ResponseWriter, r *http.Request) { return } - h.publish(protocol.EventSquadDeleted, workspaceID, "member", userID, map[string]any{ + h.publishWithRecipients(protocol.EventSquadDeleted, workspaceID, "member", userID, map[string]any{ "squad_id": uuidToString(squad.ID), "leader_id": uuidToString(squad.LeaderID), - }) + }, deleteRecipients) w.WriteHeader(http.StatusNoContent) } diff --git a/server/internal/handler/squad_private_leader_test.go b/server/internal/handler/squad_private_leader_test.go index 31db6070d81..ff809a35a9b 100644 --- a/server/internal/handler/squad_private_leader_test.go +++ b/server/internal/handler/squad_private_leader_test.go @@ -183,8 +183,8 @@ func TestComment_SquadPrivateLeader_PlainMemberNoEnqueue(t *testing.T) { }) r = withURLParam(r, "id", issueID) testHandler.CreateComment(w, r) - if w.Code != http.StatusCreated { - t.Fatalf("CreateComment: expected 201, got %d: %s", w.Code, w.Body.String()) + if w.Code != http.StatusNotFound { + t.Fatalf("CreateComment for an issue outside the member's visible scope: expected 404, got %d: %s", w.Code, w.Body.String()) } // The private leader must NOT have a queued task. diff --git a/server/internal/handler/task_message_call_id_test.go b/server/internal/handler/task_message_call_id_test.go index e70d18a62bd..078020f0c79 100644 --- a/server/internal/handler/task_message_call_id_test.go +++ b/server/internal/handler/task_message_call_id_test.go @@ -76,8 +76,16 @@ func TestTaskMessageCallIDLiveAndHistory(t *testing.T) { t.Run(reader.name+suffix, func(t *testing.T) { req := testutil.JSONRequest(http.MethodGet, "/api/tasks/"+taskID+"/messages"+suffix, nil) req = testutil.WithURLParams(req, "taskId", taskID) + req.Header.Set("X-User-ID", testUserID) + req.Header.Set("X-Workspace-ID", testWorkspaceID) ctx := middleware.WithDaemonContext(req.Context(), testWorkspaceID, "call-id-daemon") - ctx = middleware.SetMemberContext(ctx, testWorkspaceID, db.Member{}) + member, err := h.Queries.GetMemberByUserAndWorkspace(context.Background(), db.GetMemberByUserAndWorkspaceParams{ + UserID: util.MustParseUUID(testUserID), WorkspaceID: util.MustParseUUID(testWorkspaceID), + }) + if err != nil { + t.Fatalf("load authenticated member: %v", err) + } + ctx = middleware.SetMemberContext(ctx, testWorkspaceID, member) var history []map[string]any testutil.Call(t, reader.handler, req.WithContext(ctx)).Want(http.StatusOK).JSON(&history) want := live diff --git a/server/internal/handler/workspace_delete_manifest_test.go b/server/internal/handler/workspace_delete_manifest_test.go index 584c7d0887a..0e9865a9d4e 100644 --- a/server/internal/handler/workspace_delete_manifest_test.go +++ b/server/internal/handler/workspace_delete_manifest_test.go @@ -101,6 +101,7 @@ var workspaceDeletionManifest = map[string]workspaceDeleteAction{ "agent_mcp_server": workspaceDelete, "workspace_mcp_server": workspaceDelete, "notification_preference": workspaceDelete, + "object_visibility_audit": workspaceDelete, "personal_access_token": workspaceDeleteKeep, "pinned_item": workspaceDelete, "plugin_installation": workspaceDelete, diff --git a/server/migrations/565_object_visibility_audit.down.sql b/server/migrations/565_object_visibility_audit.down.sql new file mode 100644 index 00000000000..84259e5076b --- /dev/null +++ b/server/migrations/565_object_visibility_audit.down.sql @@ -0,0 +1 @@ +DROP TABLE IF EXISTS object_visibility_audit; diff --git a/server/migrations/565_object_visibility_audit.up.sql b/server/migrations/565_object_visibility_audit.up.sql new file mode 100644 index 00000000000..88b08f5df63 --- /dev/null +++ b/server/migrations/565_object_visibility_audit.up.sql @@ -0,0 +1,12 @@ +-- Records owner/admin rescue access to restricted business objects. This is +-- intentionally an audit log, not an authorization grant table: visibility is +-- derived from the current issue/project/squad and agent-access relationships. +CREATE TABLE object_visibility_audit ( + id UUID NOT NULL DEFAULT gen_random_uuid(), + workspace_id UUID NOT NULL, + actor_user_id UUID NOT NULL, + object_type TEXT NOT NULL CHECK (object_type IN ('issue', 'project', 'squad')), + object_id UUID NOT NULL, + action TEXT NOT NULL, + occurred_at TIMESTAMPTZ NOT NULL DEFAULT now() +); diff --git a/server/migrations/566_business_visibility_functions.down.sql b/server/migrations/566_business_visibility_functions.down.sql new file mode 100644 index 00000000000..1844b17eeea --- /dev/null +++ b/server/migrations/566_business_visibility_functions.down.sql @@ -0,0 +1,3 @@ +DROP FUNCTION IF EXISTS can_member_read_squad(UUID, UUID, UUID); +DROP FUNCTION IF EXISTS can_member_read_project(UUID, UUID, UUID); +DROP FUNCTION IF EXISTS can_member_read_issue(UUID, UUID, UUID); diff --git a/server/migrations/566_business_visibility_functions.up.sql b/server/migrations/566_business_visibility_functions.up.sql new file mode 100644 index 00000000000..ba9ac8b7d0a --- /dev/null +++ b/server/migrations/566_business_visibility_functions.up.sql @@ -0,0 +1,103 @@ +CREATE OR REPLACE FUNCTION can_member_read_issue(p_workspace_id UUID, p_issue_id UUID, p_user_id UUID) +RETURNS BOOLEAN +LANGUAGE SQL +STABLE +AS $$ +SELECT EXISTS ( + SELECT 1 + FROM issue i + JOIN member m ON m.workspace_id = i.workspace_id AND m.user_id = p_user_id + WHERE i.workspace_id = p_workspace_id AND i.id = p_issue_id + AND ( + m.role IN ('owner', 'admin') + OR (i.creator_type = 'member' AND i.creator_id = p_user_id) + OR EXISTS ( + SELECT 1 FROM issue_subscriber sub + WHERE sub.issue_id = i.id AND sub.user_type = 'member' AND sub.user_id = p_user_id + ) + OR (i.assignee_type IS DISTINCT FROM 'agent' AND i.assignee_type IS DISTINCT FROM 'squad') + OR (i.assignee_type = 'agent' AND EXISTS ( + SELECT 1 FROM agent a + WHERE a.id = i.assignee_id AND a.workspace_id = i.workspace_id + AND (a.owner_id = p_user_id OR (a.permission_mode = 'public_to' AND EXISTS ( + SELECT 1 FROM agent_invocation_target t + WHERE t.agent_id = a.id + AND (t.target_type = 'workspace' OR (t.target_type = 'member' AND t.target_id = p_user_id)) + ))) + )) + OR (i.assignee_type = 'squad' AND EXISTS ( + SELECT 1 FROM squad s + WHERE s.id = i.assignee_id AND s.workspace_id = i.workspace_id + AND ( + EXISTS (SELECT 1 FROM squad_member sm WHERE sm.squad_id = s.id AND sm.member_type = 'member' AND sm.member_id = p_user_id) + OR EXISTS ( + SELECT 1 FROM agent a + WHERE a.id = s.leader_id AND a.workspace_id = s.workspace_id + AND (a.owner_id = p_user_id OR (a.permission_mode = 'public_to' AND EXISTS ( + SELECT 1 FROM agent_invocation_target t + WHERE t.agent_id = a.id + AND (t.target_type = 'workspace' OR (t.target_type = 'member' AND t.target_id = p_user_id)) + ))) + ) + ) + )) + ) +); +$$; + +CREATE OR REPLACE FUNCTION can_member_read_project(p_workspace_id UUID, p_project_id UUID, p_user_id UUID) +RETURNS BOOLEAN +LANGUAGE SQL +STABLE +AS $$ +SELECT EXISTS ( + SELECT 1 + FROM project p + JOIN member m ON m.workspace_id = p.workspace_id AND m.user_id = p_user_id + WHERE p.workspace_id = p_workspace_id AND p.id = p_project_id + AND ( + m.role IN ('owner', 'admin') + OR (p.lead_type = 'member' AND p.lead_id = p_user_id) + OR (p.lead_type = 'agent' AND EXISTS ( + SELECT 1 FROM agent a + WHERE a.id = p.lead_id AND a.workspace_id = p.workspace_id + AND (a.owner_id = p_user_id OR (a.permission_mode = 'public_to' AND EXISTS ( + SELECT 1 FROM agent_invocation_target t + WHERE t.agent_id = a.id + AND (t.target_type = 'workspace' OR (t.target_type = 'member' AND t.target_id = p_user_id)) + ))) + )) + OR EXISTS ( + SELECT 1 FROM issue i + WHERE i.workspace_id = p.workspace_id AND i.project_id = p.id + AND can_member_read_issue(i.workspace_id, i.id, p_user_id) + ) + ) +); +$$; + +CREATE OR REPLACE FUNCTION can_member_read_squad(p_workspace_id UUID, p_squad_id UUID, p_user_id UUID) +RETURNS BOOLEAN +LANGUAGE SQL +STABLE +AS $$ +SELECT EXISTS ( + SELECT 1 + FROM squad s + JOIN member m ON m.workspace_id = s.workspace_id AND m.user_id = p_user_id + WHERE s.workspace_id = p_workspace_id AND s.id = p_squad_id + AND ( + m.role IN ('owner', 'admin') + OR EXISTS (SELECT 1 FROM squad_member sm WHERE sm.squad_id = s.id AND sm.member_type = 'member' AND sm.member_id = p_user_id) + OR EXISTS ( + SELECT 1 FROM agent a + WHERE a.id = s.leader_id AND a.workspace_id = s.workspace_id + AND (a.owner_id = p_user_id OR (a.permission_mode = 'public_to' AND EXISTS ( + SELECT 1 FROM agent_invocation_target t + WHERE t.agent_id = a.id + AND (t.target_type = 'workspace' OR (t.target_type = 'member' AND t.target_id = p_user_id)) + ))) + ) + ) +); +$$; diff --git a/server/pkg/db/generated/agent.sql.go b/server/pkg/db/generated/agent.sql.go index 56289d1ef73..5beb32703f0 100644 --- a/server/pkg/db/generated/agent.sql.go +++ b/server/pkg/db/generated/agent.sql.go @@ -5348,6 +5348,7 @@ JOIN issue i ON i.id = atq.issue_id JOIN workspace w ON w.id = i.workspace_id WHERE i.workspace_id = $1 AND (i.id = $2::uuid OR i.parent_issue_id = $2::uuid) + AND can_member_read_issue(i.workspace_id, i.id, $3::uuid) AND atq.status IN ('queued', 'dispatched', 'running', 'waiting_local_directory') ORDER BY CASE atq.status @@ -5357,12 +5358,13 @@ ORDER BY ELSE 3 END, atq.created_at DESC -LIMIT $3 +LIMIT $4 ` type ListActiveTasksByIssueFamilyParams struct { WorkspaceID pgtype.UUID `json:"workspace_id"` RootIssueID pgtype.UUID `json:"root_issue_id"` + UserID pgtype.UUID `json:"user_id"` RowLimit int32 `json:"row_limit"` } @@ -5405,7 +5407,12 @@ type ListActiveTasksByIssueFamilyRow struct { // interesting rows, and bounded because a parent with hundreds of children must // not turn one coordination read into an unbounded scan. func (q *Queries) ListActiveTasksByIssueFamily(ctx context.Context, arg ListActiveTasksByIssueFamilyParams) ([]ListActiveTasksByIssueFamilyRow, error) { - rows, err := q.db.Query(ctx, listActiveTasksByIssueFamily, arg.WorkspaceID, arg.RootIssueID, arg.RowLimit) + rows, err := q.db.Query(ctx, listActiveTasksByIssueFamily, + arg.WorkspaceID, + arg.RootIssueID, + arg.UserID, + arg.RowLimit, + ) if err != nil { return nil, err } diff --git a/server/pkg/db/generated/inbox.sql.go b/server/pkg/db/generated/inbox.sql.go index 789b048dcca..8bee5d9d059 100644 --- a/server/pkg/db/generated/inbox.sql.go +++ b/server/pkg/db/generated/inbox.sql.go @@ -14,6 +14,7 @@ import ( const archiveAllInbox = `-- name: ArchiveAllInbox :execrows UPDATE inbox_item SET archived = true WHERE workspace_id = $1 AND recipient_type = 'member' AND recipient_id = $2 AND archived = false + AND (issue_id IS NULL OR can_member_read_issue(workspace_id, issue_id, recipient_id)) ` type ArchiveAllInboxParams struct { @@ -39,6 +40,7 @@ WITH newest_groups AS ( AND i.recipient_type = 'member' AND i.recipient_id = $2 AND i.archived = false + AND (i.issue_id IS NULL OR can_member_read_issue(i.workspace_id, i.issue_id, i.recipient_id)) ORDER BY COALESCE(i.issue_id, i.id), i.created_at DESC, i.id DESC ), read_groups AS ( SELECT group_id @@ -75,9 +77,11 @@ func (q *Queries) ArchiveAllReadInbox(ctx context.Context, arg ArchiveAllReadInb const archiveCompletedInbox = `-- name: ArchiveCompletedInbox :execrows UPDATE inbox_item i SET archived = true WHERE i.workspace_id = $1 AND i.recipient_type = 'member' AND i.recipient_id = $2 AND i.archived = false + AND (i.issue_id IS NULL OR can_member_read_issue(i.workspace_id, i.issue_id, i.recipient_id)) AND i.issue_id IN ( SELECT id FROM issue WHERE workspace_id = $1 + AND can_member_read_issue(workspace_id, id, $2) AND status = ANY($3::text[]) ) ` @@ -188,8 +192,9 @@ func (q *Queries) ArchiveInboxItem(ctx context.Context, id pgtype.UUID) (InboxIt } const countUnreadInbox = `-- name: CountUnreadInbox :one -SELECT count(*) FROM inbox_item -WHERE workspace_id = $1 AND recipient_type = $2 AND recipient_id = $3 AND read = false AND archived = false +SELECT count(*) FROM inbox_item i +WHERE i.workspace_id = $1 AND i.recipient_type = $2 AND i.recipient_id = $3 AND i.read = false AND i.archived = false + AND (i.issue_id IS NULL OR can_member_read_issue(i.workspace_id, i.issue_id, i.recipient_id)) ` type CountUnreadInboxParams struct { @@ -215,6 +220,7 @@ FROM ( WHERE i.recipient_type = 'member' AND i.recipient_id = $1 AND i.archived = false + AND (i.issue_id IS NULL OR can_member_read_issue(i.workspace_id, i.issue_id, i.recipient_id)) ORDER BY i.workspace_id, COALESCE(i.issue_id, i.id), i.created_at DESC ) newest WHERE newest.read = false @@ -388,6 +394,7 @@ WITH eligible_archived AS MATERIALIZED ( AND i.recipient_type = $2 AND i.recipient_id = $3 AND i.archived = true + AND (i.issue_id IS NULL OR can_member_read_issue(i.workspace_id, i.issue_id, i.recipient_id)) AND (i.issue_id IS NULL OR NOT EXISTS ( SELECT 1 FROM inbox_item active @@ -523,6 +530,7 @@ SELECT i.id, i.workspace_id, i.recipient_type, i.recipient_id, i.type, i.severit FROM inbox_item i LEFT JOIN issue iss ON iss.id = i.issue_id WHERE i.workspace_id = $1 AND i.recipient_type = $2 AND i.recipient_id = $3 AND i.archived = false + AND (i.issue_id IS NULL OR can_member_read_issue(i.workspace_id, i.issue_id, i.recipient_id)) ORDER BY i.created_at DESC ` @@ -593,6 +601,7 @@ func (q *Queries) ListInboxItems(ctx context.Context, arg ListInboxItemsParams) const markAllInboxRead = `-- name: MarkAllInboxRead :execrows UPDATE inbox_item SET read = true WHERE workspace_id = $1 AND recipient_type = 'member' AND recipient_id = $2 AND archived = false AND read = false + AND (issue_id IS NULL OR can_member_read_issue(workspace_id, issue_id, recipient_id)) ` type MarkAllInboxReadParams struct { diff --git a/server/pkg/db/generated/inbox_archive.sql.go b/server/pkg/db/generated/inbox_archive.sql.go index c4e6438ccf1..22245b7ed83 100644 --- a/server/pkg/db/generated/inbox_archive.sql.go +++ b/server/pkg/db/generated/inbox_archive.sql.go @@ -22,6 +22,7 @@ WITH newest AS MATERIALIZED ( AND i.recipient_type = 'member' AND i.recipient_id = $2::uuid AND i.archived = true + AND (i.issue_id IS NULL OR can_member_read_issue(i.workspace_id, i.issue_id, i.recipient_id)) AND (i.issue_id IS NULL OR NOT EXISTS ( SELECT 1 FROM inbox_item active WHERE active.workspace_id = i.workspace_id @@ -114,6 +115,7 @@ WITH newest AS MATERIALIZED ( AND i.recipient_type = 'member' AND i.recipient_id = $2::uuid AND i.archived = true + AND (i.issue_id IS NULL OR can_member_read_issue(i.workspace_id, i.issue_id, i.recipient_id)) AND ($3::uuid IS NULL OR i.issue_id = $3::uuid OR (i.issue_id IS NULL AND i.id = $3::uuid)) AND (i.issue_id IS NULL OR NOT EXISTS ( diff --git a/server/pkg/db/generated/models.go b/server/pkg/db/generated/models.go index 2cd4893996e..cf23858d8de 100644 --- a/server/pkg/db/generated/models.go +++ b/server/pkg/db/generated/models.go @@ -1176,6 +1176,16 @@ type NotificationPreference struct { UpdatedAt pgtype.Timestamptz `json:"updated_at"` } +type ObjectVisibilityAudit struct { + ID pgtype.UUID `json:"id"` + WorkspaceID pgtype.UUID `json:"workspace_id"` + ActorUserID pgtype.UUID `json:"actor_user_id"` + ObjectType string `json:"object_type"` + ObjectID pgtype.UUID `json:"object_id"` + Action string `json:"action"` + OccurredAt pgtype.Timestamptz `json:"occurred_at"` +} + type PersonalAccessToken struct { ID pgtype.UUID `json:"id"` UserID pgtype.UUID `json:"user_id"` diff --git a/server/pkg/db/generated/workspace_delete.sql.go b/server/pkg/db/generated/workspace_delete.sql.go index 2d067d0f470..88f011a4cbc 100644 --- a/server/pkg/db/generated/workspace_delete.sql.go +++ b/server/pkg/db/generated/workspace_delete.sql.go @@ -346,6 +346,9 @@ deleted_issue_reactions AS ( deleted_activity AS ( DELETE FROM activity_log WHERE workspace_id = $1 ), +deleted_visibility_audit AS ( + DELETE FROM object_visibility_audit WHERE workspace_id = $1 +), deleted_inbox AS ( DELETE FROM inbox_item WHERE workspace_id = $1 ), diff --git a/server/pkg/db/queries/agent.sql b/server/pkg/db/queries/agent.sql index 0af074b3647..ad79d40c234 100644 --- a/server/pkg/db/queries/agent.sql +++ b/server/pkg/db/queries/agent.sql @@ -2562,6 +2562,7 @@ JOIN issue i ON i.id = atq.issue_id JOIN workspace w ON w.id = i.workspace_id WHERE i.workspace_id = @workspace_id AND (i.id = @root_issue_id::uuid OR i.parent_issue_id = @root_issue_id::uuid) + AND can_member_read_issue(i.workspace_id, i.id, @user_id::uuid) AND atq.status IN ('queued', 'dispatched', 'running', 'waiting_local_directory') ORDER BY CASE atq.status diff --git a/server/pkg/db/queries/inbox.sql b/server/pkg/db/queries/inbox.sql index aad0404fb80..d1a4400d36e 100644 --- a/server/pkg/db/queries/inbox.sql +++ b/server/pkg/db/queries/inbox.sql @@ -5,6 +5,7 @@ SELECT i.*, FROM inbox_item i LEFT JOIN issue iss ON iss.id = i.issue_id WHERE i.workspace_id = $1 AND i.recipient_type = $2 AND i.recipient_id = $3 AND i.archived = false + AND (i.issue_id IS NULL OR can_member_read_issue(i.workspace_id, i.issue_id, i.recipient_id)) ORDER BY i.created_at DESC; -- name: ListArchivedInboxItems :many @@ -39,6 +40,7 @@ WITH eligible_archived AS MATERIALIZED ( AND i.recipient_type = $2 AND i.recipient_id = $3 AND i.archived = true + AND (i.issue_id IS NULL OR can_member_read_issue(i.workspace_id, i.issue_id, i.recipient_id)) AND (i.issue_id IS NULL OR NOT EXISTS ( SELECT 1 FROM inbox_item active @@ -143,8 +145,9 @@ WHERE workspace_id = $1 AND issue_id = $2 AND type = $3 AND archived = false RETURNING recipient_type, recipient_id; -- name: CountUnreadInbox :one -SELECT count(*) FROM inbox_item -WHERE workspace_id = $1 AND recipient_type = $2 AND recipient_id = $3 AND read = false AND archived = false; +SELECT count(*) FROM inbox_item i +WHERE i.workspace_id = $1 AND i.recipient_type = $2 AND i.recipient_id = $3 AND i.read = false AND i.archived = false + AND (i.issue_id IS NULL OR can_member_read_issue(i.workspace_id, i.issue_id, i.recipient_id)); -- name: CountUnreadInboxByWorkspace :many -- Per-workspace unread inbox counts for a recipient member, matching the @@ -166,6 +169,7 @@ FROM ( WHERE i.recipient_type = 'member' AND i.recipient_id = $1 AND i.archived = false + AND (i.issue_id IS NULL OR can_member_read_issue(i.workspace_id, i.issue_id, i.recipient_id)) ORDER BY i.workspace_id, COALESCE(i.issue_id, i.id), i.created_at DESC ) newest WHERE newest.read = false @@ -173,11 +177,13 @@ GROUP BY newest.workspace_id; -- name: MarkAllInboxRead :execrows UPDATE inbox_item SET read = true -WHERE workspace_id = $1 AND recipient_type = 'member' AND recipient_id = $2 AND archived = false AND read = false; +WHERE workspace_id = $1 AND recipient_type = 'member' AND recipient_id = $2 AND archived = false AND read = false + AND (issue_id IS NULL OR can_member_read_issue(workspace_id, issue_id, recipient_id)); -- name: ArchiveAllInbox :execrows UPDATE inbox_item SET archived = true -WHERE workspace_id = $1 AND recipient_type = 'member' AND recipient_id = $2 AND archived = false; +WHERE workspace_id = $1 AND recipient_type = 'member' AND recipient_id = $2 AND archived = false + AND (issue_id IS NULL OR can_member_read_issue(workspace_id, issue_id, recipient_id)); -- name: ArchiveAllReadInbox :execrows -- "Read" is the state of the one issue row the inbox renders: the newest @@ -194,6 +200,7 @@ WITH newest_groups AS ( AND i.recipient_type = 'member' AND i.recipient_id = $2 AND i.archived = false + AND (i.issue_id IS NULL OR can_member_read_issue(i.workspace_id, i.issue_id, i.recipient_id)) ORDER BY COALESCE(i.issue_id, i.id), i.created_at DESC, i.id DESC ), read_groups AS ( SELECT group_id @@ -211,8 +218,10 @@ WHERE i.workspace_id = $1 -- name: ArchiveCompletedInbox :execrows UPDATE inbox_item i SET archived = true WHERE i.workspace_id = $1 AND i.recipient_type = 'member' AND i.recipient_id = $2 AND i.archived = false + AND (i.issue_id IS NULL OR can_member_read_issue(i.workspace_id, i.issue_id, i.recipient_id)) AND i.issue_id IN ( SELECT id FROM issue WHERE workspace_id = $1 + AND can_member_read_issue(workspace_id, id, $2) AND status = ANY(sqlc.arg('terminal_status_keys')::text[]) ); diff --git a/server/pkg/db/queries/inbox_archive.sql b/server/pkg/db/queries/inbox_archive.sql index ac564695580..15ee04b5fac 100644 --- a/server/pkg/db/queries/inbox_archive.sql +++ b/server/pkg/db/queries/inbox_archive.sql @@ -10,6 +10,7 @@ WITH newest AS MATERIALIZED ( AND i.recipient_type = 'member' AND i.recipient_id = sqlc.arg(recipient_id)::uuid AND i.archived = true + AND (i.issue_id IS NULL OR can_member_read_issue(i.workspace_id, i.issue_id, i.recipient_id)) AND (sqlc.narg(group_id)::uuid IS NULL OR i.issue_id = sqlc.narg(group_id)::uuid OR (i.issue_id IS NULL AND i.id = sqlc.narg(group_id)::uuid)) AND (i.issue_id IS NULL OR NOT EXISTS ( @@ -68,6 +69,7 @@ WITH newest AS MATERIALIZED ( AND i.recipient_type = 'member' AND i.recipient_id = sqlc.arg(recipient_id)::uuid AND i.archived = true + AND (i.issue_id IS NULL OR can_member_read_issue(i.workspace_id, i.issue_id, i.recipient_id)) AND (i.issue_id IS NULL OR NOT EXISTS ( SELECT 1 FROM inbox_item active WHERE active.workspace_id = i.workspace_id diff --git a/server/pkg/db/queries/workspace_delete.sql b/server/pkg/db/queries/workspace_delete.sql index 9f6972cfb1a..33669210915 100644 --- a/server/pkg/db/queries/workspace_delete.sql +++ b/server/pkg/db/queries/workspace_delete.sql @@ -359,6 +359,9 @@ deleted_issue_reactions AS ( deleted_activity AS ( DELETE FROM activity_log WHERE workspace_id = $1 ), +deleted_visibility_audit AS ( + DELETE FROM object_visibility_audit WHERE workspace_id = $1 +), deleted_inbox AS ( DELETE FROM inbox_item WHERE workspace_id = $1 ),