Files
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

256 lines
12 KiB
Go

package slack
import (
"context"
"errors"
"fmt"
"log/slog"
"net/http"
"github.com/jackc/pgx/v5"
"github.com/jackc/pgx/v5/pgconn"
"github.com/jackc/pgx/v5/pgtype"
"github.com/multica-ai/multica/server/internal/integrations/channel/engine"
"github.com/multica-ai/multica/server/internal/util/secretbox"
db "github.com/multica-ai/multica/server/pkg/db/generated"
)
// This file is the Slack install backend (MUL-3666). Slack uses the
// bring-your-own-app (BYO) model: the workspace admin creates their own Slack
// app, installs it to their Slack workspace, and pastes its bot token (xoxb-) +
// app-level token (xapp-) into Multica (the paste path lives in byo_install.go).
// The InstallService owns the at-rest encryption of those tokens — so no caller
// can write a channel_installation with a plaintext token — plus the shared
// persistInstall transaction and the list / get / revoke management surface.
var (
// ErrInstallationNotFound surfaces "no row matches in this workspace".
ErrInstallationNotFound = errors.New("slack installation not found")
// ErrTeamOwnedByAnotherWorkspace is returned when the pasted Slack app is
// already connected to a live owner in a DIFFERENT Multica workspace — it
// would collide with the (channel_type, app_id) routing index. A Slack app is
// one bot identity and maps to one agent; reusing it here requires
// disconnecting it in the other workspace first.
ErrTeamOwnedByAnotherWorkspace = errors.New("slack: this Slack app is already connected to a different Multica workspace")
// ErrTeamOwnedBySameWorkspace is returned when the app is already connected to
// a DIFFERENT (live, non-archived) agent in the SAME workspace. The old
// catch-all wrongly blamed "another workspace"; naming the same-workspace case
// points the user at the Disconnect they can actually reach (#4810).
ErrTeamOwnedBySameWorkspace = errors.New("slack: this Slack app is already connected to another agent in this workspace")
// ErrTeamOwnedByArchivedAgent is returned when the app's owning agent is
// archived (and so still holds the bot, since archiving is reversible). The
// user recovers by restoring that agent or disconnecting its bot.
ErrTeamOwnedByArchivedAgent = errors.New("slack: this Slack app is connected to an archived agent in this workspace")
)
// installQueries is the slice of generated queries InstallService needs. WithTx
// returns the same interface bound to a transaction so persistInstall runs its
// upsert atomically (and so tests can inject a fake without a real DB).
type installQueries interface {
WithTx(tx pgx.Tx) installQueries
UpsertChannelInstallation(ctx context.Context, arg db.UpsertChannelInstallationParams) (db.ChannelInstallation, error)
ReclaimDeadChannelInstallationByAppID(ctx context.Context, arg db.ReclaimDeadChannelInstallationByAppIDParams) (pgtype.UUID, error)
GetChannelInstallationOwnerByAppID(ctx context.Context, arg db.GetChannelInstallationOwnerByAppIDParams) (db.GetChannelInstallationOwnerByAppIDRow, error)
ListChannelInstallationsByWorkspace(ctx context.Context, arg db.ListChannelInstallationsByWorkspaceParams) ([]db.ChannelInstallation, error)
GetChannelInstallationInWorkspace(ctx context.Context, arg db.GetChannelInstallationInWorkspaceParams) (db.ChannelInstallation, error)
SetChannelInstallationStatus(ctx context.Context, arg db.SetChannelInstallationStatusParams) error
}
// dbInstallQueries adapts *db.Queries to installQueries — the generated WithTx
// returns *db.Queries, so we wrap it to return the interface (the same adapter
// pattern engine.ChatSession uses).
type dbInstallQueries struct{ *db.Queries }
func (q dbInstallQueries) WithTx(tx pgx.Tx) installQueries {
return dbInstallQueries{q.Queries.WithTx(tx)}
}
// InstallService owns the at-rest encryption of the bot + app tokens (so no
// caller can write a channel_installation with a plaintext token) and the shared
// install transaction. The box MUST be non-nil (we refuse plaintext storage even
// in dev).
type InstallService struct {
box *secretbox.Box
q installQueries
tx engine.TxStarter
httpClient *http.Client
logger *slog.Logger
// apiURL overrides the Slack API base for the BYO auth.test call (tests point
// it at an httptest server). Empty uses the real Slack API.
apiURL string
}
// NewInstallService binds the service to queries, a tx starter (*pgxpool.Pool),
// and an encryption box. Listing / revoking and BYO register all require only
// the box (the at-rest key); there is no hosted OAuth credential.
func NewInstallService(q *db.Queries, tx engine.TxStarter, box *secretbox.Box, logger *slog.Logger) (*InstallService, error) {
if q == nil {
return nil, errors.New("slack: InstallService requires queries")
}
return newInstallService(dbInstallQueries{q}, tx, box, logger)
}
// newInstallService is the testable core: it takes the installQueries interface
// so tests can inject a fake (with a fake TxStarter) without a real DB.
func newInstallService(q installQueries, tx engine.TxStarter, box *secretbox.Box, logger *slog.Logger) (*InstallService, error) {
if box == nil {
return nil, errors.New("slack: InstallService requires a non-nil secretbox.Box")
}
if q == nil {
return nil, errors.New("slack: InstallService requires queries")
}
if tx == nil {
return nil, errors.New("slack: InstallService requires a tx starter")
}
if logger == nil {
logger = slog.Default()
}
return &InstallService{
box: box,
q: q,
tx: tx,
httpClient: http.DefaultClient,
logger: logger,
}, nil
}
// installPersist carries the resolved fields persistInstall writes. appIDKey is
// the value stored at config->>'app_id' — the real Slack app id — and MUST equal
// the app_id inside configJSON; it is the lookup / ON CONFLICT key. installerSlackID
// is the installer's Slack user id to auto-bind, or "" to skip (a BYO paste
// carries no authed_user, so the installer binds via the normal token flow on
// first message).
type installPersist struct {
wsID pgtype.UUID
agentID pgtype.UUID
installerID pgtype.UUID
// appIDKey is the Slack app id stored at config->>'app_id'; it MUST equal the
// app_id inside configJSON. It keys the dead-owner reclaim and the live-owner
// lookup that drives the accurate conflict message.
appIDKey string
// configJSON holds the Slack app id (config->>'app_id') used for inbound
// routing; the ROW itself is keyed by (workspace, agent) — one bot per agent.
configJSON []byte
}
// pgUniqueViolation is the Postgres SQLSTATE for a unique-constraint violation.
const pgUniqueViolation = "23505"
// persistInstall upserts the installation keyed by (workspace_id, agent_id,
// channel_type): ONE Slack bot per agent. Re-connecting an agent — including
// swapping it to a NEW Slack app after a disconnect — UPDATES that agent's row
// in place instead of colliding with the (workspace, agent, channel) unique.
//
// The (channel_type, app_id) routing index is the only OTHER unique constraint,
// and it is NOT this upsert's conflict target, so a unique violation here means
// the pasted Slack app is already connected to a DIFFERENT agent or Multica
// workspace — refuse it (ErrTeamOwnedByAnotherWorkspace) rather than steal it.
// No chat-session retire is needed: a row's agent_id never changes (it is part
// of the key), so existing sessions stay valid for the same agent.
func (s *InstallService) persistInstall(ctx context.Context, p installPersist) (db.ChannelInstallation, error) {
tx, err := s.tx.Begin(ctx)
if err != nil {
return db.ChannelInstallation{}, fmt.Errorf("begin install tx: %w", err)
}
defer func() { _ = tx.Rollback(ctx) }()
qtx := s.q.WithTx(tx)
// Free the (slack, app_id) routing slot from any DEAD prior owner — a revoked
// placeholder, or an orphan whose owning workspace/agent was deleted (#4810) —
// before the upsert, so a bot whose old owner is gone can be rebound. A live
// owner (active agent, including an archived one) is left in place and trips
// the unique index below, which we turn into an accurate conflict.
if _, err := qtx.ReclaimDeadChannelInstallationByAppID(ctx, db.ReclaimDeadChannelInstallationByAppIDParams{
ChannelType: string(TypeSlack),
AppID: p.appIDKey,
WorkspaceID: p.wsID,
AgentID: p.agentID,
}); err != nil && !errors.Is(err, pgx.ErrNoRows) {
// pgx.ErrNoRows just means nothing was dead — a no-op, not a failure.
return db.ChannelInstallation{}, fmt.Errorf("reclaim dead slack installation: %w", err)
}
inst, err := qtx.UpsertChannelInstallation(ctx, db.UpsertChannelInstallationParams{
WorkspaceID: p.wsID,
AgentID: p.agentID,
ChannelType: string(TypeSlack),
Config: p.configJSON,
InstallerUserID: p.installerID,
})
if err != nil {
var pgErr *pgconn.PgError
if errors.As(err, &pgErr) && pgErr.Code == pgUniqueViolation {
return db.ChannelInstallation{}, s.liveOwnerConflictErr(ctx, p.wsID, p.appIDKey)
}
return db.ChannelInstallation{}, fmt.Errorf("upsert slack installation: %w", err)
}
if err := tx.Commit(ctx); err != nil {
return db.ChannelInstallation{}, fmt.Errorf("commit slack install: %w", err)
}
return inst, nil
}
// liveOwnerConflictErr classifies who holds the (slack, app_id) routing slot
// after the dead-owner reclaim ran, so persistInstall returns a sentinel the
// handler renders as an accurate message rather than the old catch-all that
// always blamed "another workspace" (#4810). Read on the base pool (s.q), since
// the failed upsert has aborted the tx. A now-free slot (concurrent disconnect)
// or lookup error falls back to the generic cross-workspace sentinel — a retry
// then succeeds.
func (s *InstallService) liveOwnerConflictErr(ctx context.Context, requestingWorkspaceID pgtype.UUID, appID string) error {
owner, err := s.q.GetChannelInstallationOwnerByAppID(ctx, db.GetChannelInstallationOwnerByAppIDParams{
ChannelType: string(TypeSlack),
AppID: appID,
})
if err != nil {
return ErrTeamOwnedByAnotherWorkspace
}
switch {
case owner.WorkspaceID != requestingWorkspaceID:
return ErrTeamOwnedByAnotherWorkspace
case owner.AgentArchivedAt.Valid:
return ErrTeamOwnedByArchivedAgent
default:
return ErrTeamOwnedBySameWorkspace
}
}
// ListByWorkspace returns every Slack installation in the workspace (active and
// revoked), for the management surface.
func (s *InstallService) ListByWorkspace(ctx context.Context, wsID pgtype.UUID) ([]db.ChannelInstallation, error) {
return s.q.ListChannelInstallationsByWorkspace(ctx, db.ListChannelInstallationsByWorkspaceParams{
WorkspaceID: wsID,
ChannelType: string(TypeSlack),
})
}
// GetInWorkspace is the workspace-scoped lookup so a forged installation id from
// another workspace returns NotFound instead of leaking existence.
func (s *InstallService) GetInWorkspace(ctx context.Context, id, wsID pgtype.UUID) (db.ChannelInstallation, error) {
inst, err := s.q.GetChannelInstallationInWorkspace(ctx, db.GetChannelInstallationInWorkspaceParams{
ID: id,
WorkspaceID: wsID,
ChannelType: string(TypeSlack),
})
if err != nil {
if errors.Is(err, pgx.ErrNoRows) {
return db.ChannelInstallation{}, ErrInstallationNotFound
}
return db.ChannelInstallation{}, err
}
return inst, nil
}
// Revoke flips status to 'revoked'. The row is preserved for audit; a re-install
// flips it back to 'active'. The Supervisor stops supervising the installation
// (ListActiveInstallations filters to active), so its Socket Mode connection
// winds down, and outbound drops too.
func (s *InstallService) Revoke(ctx context.Context, id pgtype.UUID) error {
return s.q.SetChannelInstallationStatus(ctx, db.SetChannelInstallationStatusParams{
ID: id,
Status: "revoked",
})
}