Files
multica/server/internal/handler/slack.go
Bohan Jiang ccacce60a1 fix(channels): auto-reclaim orphaned IM-bot installations + accurate rebind conflict copy (#4810) MUL-3937 (#5103)
* fix(channels): auto-reclaim orphaned bot installations + accurate rebind conflict copy

channel_installation has no FK to workspace/agent (MUL-3515 §4), so deleting a
workspace or hard-deleting an agent left the row behind, occupying the
(channel_type, app_id) routing slot forever — the bot could never be rebound and
the UI had no way to clear it (#4810). The 409 also always blamed "a different
Multica workspace" even when the real owner sat in the same workspace.

Auto-reclaim on delete:
- DeleteWorkspace and the runtime-teardown paths now sweep the workspace's /
  archived agents' channel installations and every dependent row in-tx.
- The shared install path (Feishu + Slack) reclaims a DEAD prior owner — a
  revoked placeholder or an orphan whose workspace/agent is gone — before the
  upsert, healing installations stranded before this fix. A live owner (active
  agent, including an archived one) is left in place, not stolen.

Accurate conflict copy:
- A rebind refused by a LIVE owner now distinguishes same-workspace / another
  agent, an archived agent, and a genuinely different workspace, for both Slack
  (typed sentinels) and Feishu (registration message).

MUL-3937

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

* fix(channels): reclaim cross-workspace revoked bots + sweep card/dedup/audit (#4810)

Address the #5103 review (yyclaw + Steve):

- Reclaim: a REVOKED installation in ANY workspace is now dead (except the
  caller's own row), not just same-workspace. Disconnect never hard-deletes the
  row and there is no release UI, so a cross-workspace revoked row would pin a
  bot's app_id slot forever, with the misleading "connected to another
  workspace" copy resurfacing. A new binder proves control by holding the app
  credentials, so reclaiming is safe. Live ACTIVE owners (incl. archived) are
  still refused.
- Sweep the two dependent tables the cleanups missed, in all three paths
  (reclaim / DeleteWorkspace / runtime teardown): channel_outbound_card_message
  (no reaper, so a permanent orphan otherwise) and channel_inbound_message_dedup
  (PurgeChannelInboundDedup has no caller).
- Audit rows: PURGE on the hard-delete paths instead of detaching them into
  permanently unattributable NULL rows; keep DETACH on reclaim, where the
  workspace survives and the row stays useful for triage.
- Tests: flip cross-ws revoked to reclaimed + add cross-ws active preserved;
  extend the reclaim and both delete-path cleanup tests for card/dedup and the
  audit purge/detach split; assert the channel sweep on the DeleteRuntimeProfile
  entry point.

MUL-3937

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

---------

Co-authored-by: J <j@multica.ai>
Co-authored-by: multica-agent <github@multica.ai>
2026-07-09 15:07:11 +08:00

283 lines
11 KiB
Go

package handler
import (
"encoding/json"
"errors"
"net/http"
"strings"
"time"
"github.com/go-chi/chi/v5"
"github.com/multica-ai/multica/server/internal/integrations/slack"
db "github.com/multica-ai/multica/server/pkg/db/generated"
"github.com/multica-ai/multica/server/pkg/protocol"
)
// SlackInstallationResponse is the wire shape for a Slack installation row. The
// encrypted bot token in config is INTENTIONALLY absent — it is server-internal
// (only the outbound sender decrypts it). WS lease columns are runtime state,
// not API surface, so they are omitted too.
type SlackInstallationResponse struct {
ID string `json:"id"`
WorkspaceID string `json:"workspace_id"`
AgentID string `json:"agent_id"`
TeamID string `json:"team_id"`
BotUserID string `json:"bot_user_id"`
InstallerUserID string `json:"installer_user_id"`
Status string `json:"status"`
InstalledAt string `json:"installed_at"`
CreatedAt string `json:"created_at"`
UpdatedAt string `json:"updated_at"`
}
func slackInstallationToResponse(row db.ChannelInstallation) SlackInstallationResponse {
info := slack.DecodePublicConfig(row.Config)
return SlackInstallationResponse{
ID: uuidToString(row.ID),
WorkspaceID: uuidToString(row.WorkspaceID),
AgentID: uuidToString(row.AgentID),
TeamID: info.TeamID,
BotUserID: info.BotUserID,
InstallerUserID: uuidToString(row.InstallerUserID),
Status: row.Status,
InstalledAt: row.InstalledAt.Time.UTC().Format(time.RFC3339),
CreatedAt: row.CreatedAt.Time.UTC().Format(time.RFC3339),
UpdatedAt: row.UpdatedAt.Time.UTC().Format(time.RFC3339),
}
}
// ListSlackInstallations (GET /api/workspaces/{id}/slack/installations) is
// member-visible so the Integrations tab renders for non-admins. Response
// flags mirror Lark:
// - configured: at-rest encryption key is set (SlackInstall != nil).
// - install_supported: kept for the management UI; true whenever configured,
// since a BYO install needs only the at-rest key (no hosted OAuth creds).
func (h *Handler) ListSlackInstallations(w http.ResponseWriter, r *http.Request) {
if h.SlackInstall == nil {
writeJSON(w, http.StatusOK, map[string]any{
"installations": []SlackInstallationResponse{},
"configured": false,
"install_supported": false,
})
return
}
wsUUID, ok := parseUUIDOrBadRequest(w, chi.URLParam(r, "id"), "workspace id")
if !ok {
return
}
rows, err := h.SlackInstall.ListByWorkspace(r.Context(), wsUUID)
if err != nil {
writeError(w, http.StatusInternalServerError, "failed to list slack installations")
return
}
out := make([]SlackInstallationResponse, 0, len(rows))
for _, row := range rows {
out = append(out, slackInstallationToResponse(row))
}
writeJSON(w, http.StatusOK, map[string]any{
"installations": out,
"configured": true,
"install_supported": true,
})
}
// RegisterSlackBYORequest is the body for a bring-your-own-app install: the two
// tokens the user pasted from their own Slack app.
type RegisterSlackBYORequest struct {
BotToken string `json:"bot_token"`
AppToken string `json:"app_token"`
}
// RegisterSlackBYO (POST /api/workspaces/{id}/slack/install/byo?agent_id=…)
// installs a user-supplied ("bring your own") Slack app for an agent, so several
// agents can each have their own bot identity in the SAME Slack workspace.
// Admin-only at the router. Unlike the hosted OAuth path this needs only the
// at-rest key configured (SlackInstall != nil), NOT the hosted OAuth client
// credentials — BYO is exactly the path for deployments without a hosted app.
func (h *Handler) RegisterSlackBYO(w http.ResponseWriter, r *http.Request) {
if h.SlackInstall == nil {
writeError(w, http.StatusServiceUnavailable, "slack integration not enabled")
return
}
userID, ok := requireUserID(w, r)
if !ok {
return
}
wsUUID, ok := parseUUIDOrBadRequest(w, chi.URLParam(r, "id"), "workspace id")
if !ok {
return
}
agentIDStr := strings.TrimSpace(r.URL.Query().Get("agent_id"))
if agentIDStr == "" {
writeError(w, http.StatusBadRequest, "agent_id is required")
return
}
agentUUID, ok := parseUUIDOrBadRequest(w, agentIDStr, "agent_id")
if !ok {
return
}
// Ownership pre-check at the boundary so a wrong agent_id is a clear 404.
if _, err := h.Queries.GetAgentInWorkspace(r.Context(), db.GetAgentInWorkspaceParams{
ID: agentUUID,
WorkspaceID: wsUUID,
}); err != nil {
writeError(w, http.StatusNotFound, "agent not found in this workspace")
return
}
initiatorUUID, ok := parseUUIDOrBadRequest(w, userID, "user id")
if !ok {
return
}
var body RegisterSlackBYORequest
if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
writeError(w, http.StatusBadRequest, "invalid request body")
return
}
row, err := h.SlackInstall.RegisterBYO(r.Context(), slack.RegisterBYOParams{
WorkspaceID: wsUUID,
AgentID: agentUUID,
InitiatorID: initiatorUUID,
BotToken: body.BotToken,
AppToken: body.AppToken,
})
if err != nil {
switch {
case errors.Is(err, slack.ErrInvalidBotToken), errors.Is(err, slack.ErrInvalidAppToken), errors.Is(err, slack.ErrTokenAppMismatch):
writeError(w, http.StatusBadRequest, err.Error())
case errors.Is(err, slack.ErrTeamOwnedBySameWorkspace):
writeError(w, http.StatusConflict, "this Slack app is already connected to another agent in this workspace — disconnect it there first, then connect it here")
case errors.Is(err, slack.ErrTeamOwnedByArchivedAgent):
writeError(w, http.StatusConflict, "this Slack app is connected to an archived agent in this workspace — restore that agent, or disconnect its bot, before connecting it here")
case errors.Is(err, slack.ErrTeamOwnedByAnotherWorkspace):
writeError(w, http.StatusConflict, "this Slack app is already connected to a different Multica workspace — disconnect it there before connecting it here")
default:
// The dominant non-sentinel failure here is auth.test rejecting the
// pasted bot token (a user error), so guide the user to recheck the
// tokens rather than surfacing an opaque 500.
writeError(w, http.StatusBadRequest, "could not verify the Slack tokens — check the bot token and app-level token, that the app is installed to your workspace, and that it has the users:read scope")
}
return
}
// Broadcast so every open client (Settings, Agent Integrations, other tabs)
// invalidates its installations query and shows the new bot — matching the
// revoke event and Lark's install semantics. The installer's own tab also
// invalidates locally, but other clients rely on this event.
h.publishSlackInstallationCreated(row, userID)
writeJSON(w, http.StatusOK, slackInstallationToResponse(row))
}
// publishSlackInstallationCreated emits slack_installation:created for a newly
// connected bot. The realtime layer fans it out to the workspace; the web app
// listens on slack_installation:* to invalidate the installations query.
func (h *Handler) publishSlackInstallationCreated(row db.ChannelInstallation, actorID string) {
h.publish(protocol.EventSlackInstallationCreated, uuidToString(row.WorkspaceID), "user", actorID, map[string]any{
"id": uuidToString(row.ID),
})
}
// RevokeSlackInstallation (DELETE /api/workspaces/{id}/slack/installations/{installationId})
// flips status to 'revoked'. Admin-only at the router. The row is preserved for
// audit; a re-install (re-pasting the app's tokens) flips status back to 'active'.
func (h *Handler) RevokeSlackInstallation(w http.ResponseWriter, r *http.Request) {
if h.SlackInstall == nil {
writeError(w, http.StatusServiceUnavailable, "slack integration not configured")
return
}
userID, ok := requireUserID(w, r)
if !ok {
return
}
wsUUID, ok := parseUUIDOrBadRequest(w, chi.URLParam(r, "id"), "workspace id")
if !ok {
return
}
instUUID, ok := parseUUIDOrBadRequest(w, chi.URLParam(r, "installationId"), "installation id")
if !ok {
return
}
// Workspace-scoped lookup so one workspace cannot revoke another's
// installation by guessing the UUID.
if _, err := h.SlackInstall.GetInWorkspace(r.Context(), instUUID, wsUUID); err != nil {
if errors.Is(err, slack.ErrInstallationNotFound) {
writeError(w, http.StatusNotFound, "slack installation not found")
return
}
writeError(w, http.StatusInternalServerError, "failed to load installation")
return
}
if err := h.SlackInstall.Revoke(r.Context(), instUUID); err != nil {
writeError(w, http.StatusInternalServerError, "failed to revoke installation")
return
}
h.publish(protocol.EventSlackInstallationRevoked, uuidToString(wsUUID), "user", userID, map[string]any{
"id": uuidToString(instUUID),
})
w.WriteHeader(http.StatusNoContent)
}
// RedeemSlackBindingTokenRequest carries the raw token the user clicked through
// from the bot's "link your account" prompt.
type RedeemSlackBindingTokenRequest struct {
Token string `json:"token"`
}
// RedeemSlackBindingTokenResponse echoes the bound workspace/installation/user
// so the frontend can confirm without a second fetch.
type RedeemSlackBindingTokenResponse struct {
WorkspaceID string `json:"workspace_id"`
InstallationID string `json:"installation_id"`
SlackUserID string `json:"slack_user_id"`
}
// RedeemSlackBindingToken (POST /api/slack/binding/redeem) binds the Slack user
// id carried by the token to the logged-in Multica user. The redeemer's identity
// comes from the session, not the token, so a stolen token cannot bind a Slack
// id to an attacker's account. Failure modes map to distinct status codes:
// - 410 Gone: token unknown / consumed / expired
// - 409 Conflict: this Slack id is already bound to a different user
// - 403 Forbidden: redeemer is not a workspace member
func (h *Handler) RedeemSlackBindingToken(w http.ResponseWriter, r *http.Request) {
if h.SlackBindingTokens == nil {
writeError(w, http.StatusServiceUnavailable, "slack integration not configured")
return
}
userID, ok := requireUserID(w, r)
if !ok {
return
}
var req RedeemSlackBindingTokenRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
writeError(w, http.StatusBadRequest, "invalid request body")
return
}
if req.Token == "" {
writeError(w, http.StatusBadRequest, "token is required")
return
}
userUUID, ok := parseUUIDOrBadRequest(w, userID, "user id")
if !ok {
return
}
redeemed, err := h.SlackBindingTokens.RedeemAndBind(r.Context(), req.Token, userUUID)
if err != nil {
switch {
case errors.Is(err, slack.ErrBindingTokenInvalid):
writeError(w, http.StatusGone, "binding token invalid or expired")
case errors.Is(err, slack.ErrBindingAlreadyAssigned):
writeError(w, http.StatusConflict, "this Slack account is already bound to a different Multica user")
case errors.Is(err, slack.ErrBindingNotWorkspaceMember):
writeError(w, http.StatusForbidden, "binding refused (are you a workspace member?)")
default:
writeError(w, http.StatusInternalServerError, "failed to redeem token")
}
return
}
writeJSON(w, http.StatusOK, RedeemSlackBindingTokenResponse{
WorkspaceID: uuidToString(redeemed.WorkspaceID),
InstallationID: uuidToString(redeemed.InstallationID),
SlackUserID: redeemed.SlackUserID,
})
}