mirror of
https://github.com/multica-ai/multica.git
synced 2026-07-16 14:49:09 +02:00
* feat(lark): let an agent's owner bind/manage its Lark bot (MUL-4213) Scan-to-bind was authorized by workspace role only, so a non-admin member could not bind a Lark bot even to an agent they own. Authorize the device-flow install, status poll, and revoke by the same rule that governs every other agent-management op — canManageAgent: the agent's owner OR a workspace owner/admin. Backend: - router: begin/status/revoke drop to workspace-member level; the per-agent check moves into the handlers (agent_id is a query param / installation id, which the role middleware can't see). - BeginLarkInstall + RevokeLarkInstallation load the target agent and run canManageAgent. - GetLarkInstallStatus scopes the read to the session initiator or a workspace owner/admin; others get 404 (no existence leak). Session state now carries InitiatorID for this. Frontend: - LarkAgentBindButton takes agentOwnerId and lets the agent owner through (mirrors canEditAgent). - Agent Integrations tab gates Lark per-agent (owner or admin) while Slack stays workspace-admin-only, since its routes are unchanged. Tests: begin/status/revoke authorization (owner, agent owner, unrelated member) on the backend; agent-owner bind visibility on the frontend. Co-authored-by: multica-agent <github@multica.ai> * fix(lark): keep orphan installation revoke available to workspace admins (MUL-4213) RevokeLarkInstallation loaded the bound agent and ran canManageAgent unconditionally, so once the agent was hard-deleted the load 404'd and a workspace owner/admin could no longer disconnect the orphan Lark installation — a documented cleanup path (ListByWorkspace lists orphans; the active-connection query filters them; Settings surfaces "Unknown Agent" Disconnect). Fall back to workspace owner/admin-only revoke when GetAgentInWorkspace finds no agent; agents that still exist keep the owner-OR-admin canManageAgent check. A plain member gains no orphan-row cleanup rights. No FK/cascade — resolved in the application layer. Adds a backend regression test: orphan installation is revocable by a workspace owner but not a plain member. Co-authored-by: multica-agent <github@multica.ai> --------- Co-authored-by: J <j@multica.ai> Co-authored-by: multica-agent <github@multica.ai>
439 lines
17 KiB
Go
439 lines
17 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/lark"
|
|
db "github.com/multica-ai/multica/server/pkg/db/generated"
|
|
"github.com/multica-ai/multica/server/pkg/protocol"
|
|
)
|
|
|
|
// LarkInstallationResponse is the wire shape for an installation row.
|
|
// `app_secret_encrypted` is INTENTIONALLY absent — the encrypted blob
|
|
// is server-internal and there is no product reason to expose it (the
|
|
// only consumer that needs the plaintext is the WS hub, which calls
|
|
// InstallationService.DecryptAppSecret server-side). Likewise, the WS
|
|
// lease columns are omitted; they are runtime state, not API surface.
|
|
type LarkInstallationResponse struct {
|
|
ID string `json:"id"`
|
|
WorkspaceID string `json:"workspace_id"`
|
|
AgentID string `json:"agent_id"`
|
|
AppID string `json:"app_id"`
|
|
TenantKey *string `json:"tenant_key,omitempty"`
|
|
BotOpenID string `json:"bot_open_id"`
|
|
InstallerUserID string `json:"installer_user_id"`
|
|
Status string `json:"status"`
|
|
// Region is the Lark cloud this installation lives on: "feishu"
|
|
// (mainland) or "lark" (international). The UI uses it to render a
|
|
// badge and to build the correct "Manage in Lark" dev-console host.
|
|
Region string `json:"region"`
|
|
InstalledAt string `json:"installed_at"`
|
|
CreatedAt string `json:"created_at"`
|
|
UpdatedAt string `json:"updated_at"`
|
|
}
|
|
|
|
func larkInstallationToResponse(row lark.Installation) LarkInstallationResponse {
|
|
resp := LarkInstallationResponse{
|
|
ID: uuidToString(row.ID),
|
|
WorkspaceID: uuidToString(row.WorkspaceID),
|
|
AgentID: uuidToString(row.AgentID),
|
|
AppID: row.AppID,
|
|
BotOpenID: row.BotOpenID,
|
|
InstallerUserID: uuidToString(row.InstallerUserID),
|
|
Status: row.Status,
|
|
Region: row.Region,
|
|
InstalledAt: row.InstalledAt.Time.UTC().Format(time.RFC3339),
|
|
CreatedAt: row.CreatedAt.Time.UTC().Format(time.RFC3339),
|
|
UpdatedAt: row.UpdatedAt.Time.UTC().Format(time.RFC3339),
|
|
}
|
|
if row.TenantKey.Valid {
|
|
tk := row.TenantKey.String
|
|
resp.TenantKey = &tk
|
|
}
|
|
return resp
|
|
}
|
|
|
|
// ListLarkInstallations (GET /api/workspaces/{id}/lark/installations)
|
|
// is member-visible — the Integrations tab should not render blank
|
|
// for non-admins. Unlike the GitHub list, we do not strip any field
|
|
// here because no API surface column doubles as a management handle:
|
|
// revocation goes by the UUID id, which is meaningless without the
|
|
// admin route's authorization, so exposing it is harmless.
|
|
//
|
|
// Response fields:
|
|
// - configured: at-rest encryption key is set (`LarkInstallations
|
|
// != nil`). When false, no install flow can succeed at all; the
|
|
// UI hides the tab.
|
|
// - install_supported: the device-flow install path is wired
|
|
// end-to-end: a RegistrationService exists (deployment supplied
|
|
// MULTICA_LARK_SECRET_KEY) AND the APIClient.IsConfigured signal
|
|
// is true (the real Lark HTTP client is in place — the stub
|
|
// cannot complete the post-poll GetBotInfo call). When false,
|
|
// the agent-detail "Bind" button stays hidden and the Settings
|
|
// tab surfaces a "coming soon" notice; already-installed bots
|
|
// still appear and remain manageable.
|
|
func (h *Handler) ListLarkInstallations(w http.ResponseWriter, r *http.Request) {
|
|
if h.LarkInstallations == nil {
|
|
writeJSON(w, http.StatusOK, map[string]any{
|
|
"installations": []LarkInstallationResponse{},
|
|
"configured": false,
|
|
"install_supported": false,
|
|
})
|
|
return
|
|
}
|
|
wsUUID, ok := parseUUIDOrBadRequest(w, chi.URLParam(r, "id"), "workspace id")
|
|
if !ok {
|
|
return
|
|
}
|
|
rows, err := h.LarkInstallations.ListByWorkspace(r.Context(), wsUUID)
|
|
if err != nil {
|
|
writeError(w, http.StatusInternalServerError, "failed to list lark installations")
|
|
return
|
|
}
|
|
out := make([]LarkInstallationResponse, 0, len(rows))
|
|
for _, row := range rows {
|
|
out = append(out, larkInstallationToResponse(row))
|
|
}
|
|
writeJSON(w, http.StatusOK, map[string]any{
|
|
"installations": out,
|
|
"configured": true,
|
|
"install_supported": h.LarkRegistration != nil && h.LarkAPIClient != nil && h.LarkAPIClient.IsConfigured(),
|
|
})
|
|
}
|
|
|
|
// RevokeLarkInstallation (DELETE /api/workspaces/{id}/lark/installations/{installationId})
|
|
// flips status to 'revoked' so the WS hub drops the connection on its
|
|
// next sweep. The row itself is preserved for audit; a re-install via
|
|
// the device-flow path flips status back to 'active' atomically.
|
|
//
|
|
// Membership is checked at the router; the per-agent authorization
|
|
// (canManageAgent: the bound agent's owner OR a workspace owner/admin)
|
|
// is enforced here, symmetric with BeginLarkInstall so an agent owner
|
|
// can unbind the bot they bound (MUL-4213). When the bound agent has
|
|
// been hard-deleted the installation is an orphan (the active-connection
|
|
// query skips it and disconnecting it is the documented cleanup path);
|
|
// revoke then falls back to workspace owner/admin only, so the cleanup
|
|
// entry point keeps working without handing a plain member orphan-row
|
|
// rights.
|
|
func (h *Handler) RevokeLarkInstallation(w http.ResponseWriter, r *http.Request) {
|
|
if h.LarkInstallations == nil {
|
|
writeError(w, http.StatusServiceUnavailable, "lark 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 ensures one workspace cannot revoke
|
|
// another's installation by guessing the UUID.
|
|
inst, err := h.LarkInstallations.GetInWorkspace(r.Context(), instUUID, wsUUID)
|
|
if err != nil {
|
|
if errors.Is(err, lark.ErrInstallationNotFound) {
|
|
writeError(w, http.StatusNotFound, "lark installation not found")
|
|
return
|
|
}
|
|
writeError(w, http.StatusInternalServerError, "failed to load installation")
|
|
return
|
|
}
|
|
// Authorize against the bound agent. Normally its owner or a workspace
|
|
// owner/admin may revoke (canManageAgent writes the 403/404 itself).
|
|
// If the agent has been hard-deleted the installation is an orphan, so
|
|
// fall back to workspace owner/admin-only cleanup instead of 404-ing
|
|
// the disconnect entry point (see ListByWorkspace vs the orphan-
|
|
// filtered active list). No FK/cascade: the missing agent is handled
|
|
// in the application layer.
|
|
agent, agentErr := h.Queries.GetAgentInWorkspace(r.Context(), db.GetAgentInWorkspaceParams{
|
|
ID: inst.AgentID,
|
|
WorkspaceID: wsUUID,
|
|
})
|
|
if agentErr != nil {
|
|
if _, ok := h.requireWorkspaceRole(w, r, uuidToString(wsUUID), "lark installation not found", "owner", "admin"); !ok {
|
|
return
|
|
}
|
|
} else if !h.canManageAgent(w, r, agent) {
|
|
return
|
|
}
|
|
if err := h.LarkInstallations.Revoke(r.Context(), instUUID); err != nil {
|
|
writeError(w, http.StatusInternalServerError, "failed to revoke installation")
|
|
return
|
|
}
|
|
h.publish(protocol.EventLarkInstallationRevoked, uuidToString(wsUUID), "user", userID, map[string]any{
|
|
"id": uuidToString(instUUID),
|
|
})
|
|
w.WriteHeader(http.StatusNoContent)
|
|
}
|
|
|
|
// RedeemLarkBindingTokenRequest carries the raw token the user
|
|
// clicked through from the Bot's "you need to bind" reply card.
|
|
type RedeemLarkBindingTokenRequest struct {
|
|
Token string `json:"token"`
|
|
}
|
|
|
|
// RedeemLarkBindingTokenResponse is the post-redemption shape. We
|
|
// echo the workspace/installation/open_id so the frontend can render
|
|
// "you are now bound to <workspace> via <agent>" without a second
|
|
// fetch.
|
|
type RedeemLarkBindingTokenResponse struct {
|
|
WorkspaceID string `json:"workspace_id"`
|
|
InstallationID string `json:"installation_id"`
|
|
LarkOpenID string `json:"lark_open_id"`
|
|
}
|
|
|
|
// RedeemLarkBindingToken (POST /api/lark/binding/redeem) is the only
|
|
// path that writes a lark_user_binding row from user-driven action.
|
|
// The redeemer's identity is taken from the session, not the token,
|
|
// so a stolen token cannot bind a Lark open_id to an attacker's
|
|
// Multica account. The token only proves "this open_id requested
|
|
// binding" — combining it with the logged-in user is what creates
|
|
// the (open_id ↔ user) mapping.
|
|
//
|
|
// Consume + bind happen inside a single DB transaction (see
|
|
// lark.BindingTokenService.RedeemAndBind). The three failure modes
|
|
// each map to a distinct status code so the frontend can render the
|
|
// appropriate copy without a separate probe:
|
|
// - 410 Gone: token unknown / consumed / expired
|
|
// - 409 Conflict: open_id is already bound to a different user
|
|
// - 403 Forbidden: redeemer is not a workspace member
|
|
func (h *Handler) RedeemLarkBindingToken(w http.ResponseWriter, r *http.Request) {
|
|
if h.LarkBindingTokens == nil {
|
|
writeError(w, http.StatusServiceUnavailable, "lark integration not configured")
|
|
return
|
|
}
|
|
userID, ok := requireUserID(w, r)
|
|
if !ok {
|
|
return
|
|
}
|
|
var req RedeemLarkBindingTokenRequest
|
|
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.LarkBindingTokens.RedeemAndBind(r.Context(), req.Token, userUUID)
|
|
if err != nil {
|
|
switch {
|
|
case errors.Is(err, lark.ErrBindingTokenInvalid):
|
|
writeError(w, http.StatusGone, "binding token invalid or expired")
|
|
case errors.Is(err, lark.ErrBindingAlreadyAssigned):
|
|
writeError(w, http.StatusConflict, "this Lark account is already bound to a different Multica user")
|
|
case errors.Is(err, lark.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, RedeemLarkBindingTokenResponse{
|
|
WorkspaceID: uuidToString(redeemed.WorkspaceID),
|
|
InstallationID: uuidToString(redeemed.InstallationID),
|
|
LarkOpenID: string(redeemed.LarkOpenID),
|
|
})
|
|
}
|
|
|
|
// BeginLarkInstallResponse is the payload the QR-code dialog consumes.
|
|
// The frontend renders `qr_code_url` as a QR image (and as a tap-to-
|
|
// open link fallback) and starts polling
|
|
// /lark/install/{session_id}/status at the supplied cadence.
|
|
type BeginLarkInstallResponse struct {
|
|
SessionID string `json:"session_id"`
|
|
QRCodeURL string `json:"qr_code_url"`
|
|
ExpiresInSeconds int `json:"expires_in_seconds"`
|
|
PollIntervalSeconds int `json:"poll_interval_seconds"`
|
|
}
|
|
|
|
// BeginLarkInstall (POST /api/workspaces/{id}/lark/install/begin)
|
|
// opens a new device-flow registration session against Lark. The router
|
|
// only requires workspace membership; this handler authorizes per-agent
|
|
// via canManageAgent (the agent's owner OR a workspace owner/admin), so
|
|
// an agent owner can bind their own agent's Bot without being a
|
|
// workspace admin (MUL-4213). The agent_id query param picks which
|
|
// Multica Agent the new Bot will be bound to; the agent must belong to
|
|
// this workspace (RegistrationService re-checks that defense-in-depth).
|
|
//
|
|
// Returns 503 when the integration is not wired (no at-rest key, no
|
|
// HTTP client, no RegistrationService); the UI hides the bind button
|
|
// in that case so this should not be reached through the normal flow.
|
|
func (h *Handler) BeginLarkInstall(w http.ResponseWriter, r *http.Request) {
|
|
if h.LarkRegistration == nil {
|
|
writeError(w, http.StatusServiceUnavailable, "lark install not configured")
|
|
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
|
|
}
|
|
// region is the cloud the user explicitly chose to bind against —
|
|
// "feishu" (mainland, accounts.feishu.cn) or "lark" (international,
|
|
// accounts.larksuite.com). The frontend now exposes two CTAs ("Bind
|
|
// to Feishu" / "Bind to Lark") so the QR is rendered against the
|
|
// right cloud up front rather than relying on the mid-poll
|
|
// tenant-brand auto-switch from a Feishu-first begin. We accept
|
|
// "feishu", "lark", and the empty string (for back-compat with
|
|
// callers that pre-date the split CTA, which RegionOrDefault inside
|
|
// the service maps to Feishu); any other value is a 400 — the
|
|
// service would normalize an unknown value to Feishu silently and
|
|
// that would mask a frontend regression where a typo'd region
|
|
// landed users on the wrong cloud without telling them.
|
|
regionParam := strings.ToLower(strings.TrimSpace(r.URL.Query().Get("region")))
|
|
switch regionParam {
|
|
case "", "feishu", "lark":
|
|
// ok — empty defaults to feishu downstream.
|
|
default:
|
|
writeError(w, http.StatusBadRequest, "region must be 'feishu' or 'lark'")
|
|
return
|
|
}
|
|
// Ownership pre-check at the HTTP boundary so a malformed
|
|
// agent_id surfaces 404 here (not an opaque service error from
|
|
// inside the service's own re-check).
|
|
agent, err := h.Queries.GetAgentInWorkspace(r.Context(), db.GetAgentInWorkspaceParams{
|
|
ID: agentUUID,
|
|
WorkspaceID: wsUUID,
|
|
})
|
|
if err != nil {
|
|
writeError(w, http.StatusNotFound, "agent not found in this workspace")
|
|
return
|
|
}
|
|
// Authorize the initiator against the target agent: its owner or a
|
|
// workspace owner/admin may bind. canManageAgent writes the 403/404
|
|
// itself, so a member who is neither is stopped here rather than at
|
|
// the (now member-level) router.
|
|
if !h.canManageAgent(w, r, agent) {
|
|
return
|
|
}
|
|
initiatorUUID, ok := parseUUIDOrBadRequest(w, userID, "user id")
|
|
if !ok {
|
|
return
|
|
}
|
|
|
|
res, err := h.LarkRegistration.BeginInstall(r.Context(), lark.BeginInstallParams{
|
|
WorkspaceID: wsUUID,
|
|
AgentID: agentUUID,
|
|
InitiatorID: initiatorUUID,
|
|
Region: lark.Region(regionParam),
|
|
})
|
|
if err != nil {
|
|
writeError(w, http.StatusBadGateway, "failed to start install: "+err.Error())
|
|
return
|
|
}
|
|
writeJSON(w, http.StatusOK, BeginLarkInstallResponse{
|
|
SessionID: res.SessionID,
|
|
QRCodeURL: res.QRCodeURL,
|
|
ExpiresInSeconds: res.ExpiresInSeconds,
|
|
PollIntervalSeconds: res.PollIntervalSeconds,
|
|
})
|
|
}
|
|
|
|
// LarkInstallStatusResponse is the polling payload. `status` is one
|
|
// of "pending" | "success" | "error"; on success `installation_id`
|
|
// is populated, on error `error_reason` is a stable code (see
|
|
// lark.RegistrationReason*).
|
|
type LarkInstallStatusResponse struct {
|
|
Status string `json:"status"`
|
|
InstallationID string `json:"installation_id,omitempty"`
|
|
ErrorReason string `json:"error_reason,omitempty"`
|
|
ErrorMessage string `json:"error_message,omitempty"`
|
|
}
|
|
|
|
// GetLarkInstallStatus (GET /api/workspaces/{id}/lark/install/{sessionId}/status)
|
|
// returns the current state of an in-flight install session. The router
|
|
// only requires workspace membership; this handler scopes the read to
|
|
// the session's initiator OR a workspace owner/admin, so a member who
|
|
// began an install (as its agent's owner) can poll their own session
|
|
// without exposing it to unrelated members (MUL-4213). Unknown /
|
|
// cross-workspace / GC'd sessions — and sessions the caller may not read
|
|
// — return 404, which the frontend treats as "session lost, please
|
|
// restart".
|
|
//
|
|
// On success this handler does NOT clean up the session — the
|
|
// frontend may poll once more after the dialog closes to confirm
|
|
// before the in-process GC sweep retires the entry; reading is
|
|
// idempotent.
|
|
func (h *Handler) GetLarkInstallStatus(w http.ResponseWriter, r *http.Request) {
|
|
if h.LarkRegistration == nil {
|
|
writeError(w, http.StatusServiceUnavailable, "lark install not configured")
|
|
return
|
|
}
|
|
userID, ok := requireUserID(w, r)
|
|
if !ok {
|
|
return
|
|
}
|
|
wsUUID, ok := parseUUIDOrBadRequest(w, chi.URLParam(r, "id"), "workspace id")
|
|
if !ok {
|
|
return
|
|
}
|
|
sessionID := strings.TrimSpace(chi.URLParam(r, "sessionId"))
|
|
if sessionID == "" {
|
|
writeError(w, http.StatusBadRequest, "session id is required")
|
|
return
|
|
}
|
|
state, err := h.LarkRegistration.GetSession(wsUUID, sessionID)
|
|
if err != nil {
|
|
if errors.Is(err, lark.ErrRegistrationSessionNotFound) {
|
|
writeError(w, http.StatusNotFound, "install session not found")
|
|
return
|
|
}
|
|
writeError(w, http.StatusInternalServerError, "failed to load install session")
|
|
return
|
|
}
|
|
// Only the initiator or a workspace owner/admin may read the
|
|
// session. The session id is handed back only to the initiator, so
|
|
// treating anyone else as "session lost" (404, no existence leak) is
|
|
// consistent with the cross-workspace case above.
|
|
if uuidToString(state.InitiatorID) != userID {
|
|
member, mErr := h.getWorkspaceMember(r.Context(), userID, uuidToString(wsUUID))
|
|
if mErr != nil || !roleAllowed(member.Role, "owner", "admin") {
|
|
writeError(w, http.StatusNotFound, "install session not found")
|
|
return
|
|
}
|
|
}
|
|
resp := LarkInstallStatusResponse{
|
|
Status: string(state.Status),
|
|
ErrorReason: state.ErrorReason,
|
|
ErrorMessage: state.ErrorMessage,
|
|
}
|
|
if state.InstallationID.Valid {
|
|
resp.InstallationID = uuidToString(state.InstallationID)
|
|
// The lark_installation:created event is published by the
|
|
// RegistrationService at the row-commit point (see
|
|
// registration_service.go finishSuccess), not here — that keeps
|
|
// the connection-badge refresh independent of whether any browser
|
|
// polls this status endpoint to success.
|
|
}
|
|
writeJSON(w, http.StatusOK, resp)
|
|
}
|