mirror of
https://github.com/multica-ai/multica.git
synced 2026-07-14 21:59:30 +02:00
* 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>
256 lines
12 KiB
Go
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",
|
|
})
|
|
}
|