mirror of
https://gh-proxy.org/https://github.com/klinsmaya/multica.git
synced 2026-11-08 01:16:45 +08:00
feat(integrations/lark): installation HTTP surface + secretbox-gated wiring (MUL-2671)
Lands the HTTP boundary on top of the inbound services from the
previous commit. What ships:
- InstallationService.Upsert: the only path that writes
lark_installation. Encrypts app_secret with the secretbox passed in
at construction time; refuses to fall back to plaintext storage
(returns an error from the constructor if no Box is supplied), so a
misconfigured dev environment cannot accidentally land a row with
cleartext credentials. Revoke flips status without DELETE so audit
trail survives.
- HTTP handlers under /api/workspaces/{id}/lark/:
* GET /installations — member-visible (Integrations tab
renders for non-admins). Soft 200
with empty list + configured:false
when MULTICA_LARK_SECRET_KEY is
unset, so the tab does not error
on self-host that has not opted in.
* POST /installations — admin-only; 503 when not configured.
Re-validates agent_id ∈ workspace
before accepting credentials so a
cross-workspace agent UUID is
rejected.
* DELETE /installations/{id} — admin-only; workspace-scoped lookup
so one workspace cannot revoke
another's installation by UUID
guess.
- POST /api/lark/binding/redeem (user-scoped, no workspace context):
the only path that mints a lark_user_binding row from user action.
Redeemer identity comes from the session, not the token, so a stolen
link cannot bind an open_id to an attacker's Multica user. The
composite FK on lark_user_binding cascades the binding away if the
user is not (or no longer) a workspace member, so a non-member who
steals the link gets 403 at the DB layer.
- Two new event-bus types in protocol.events:
EventLarkInstallationCreated, EventLarkInstallationRevoked.
- Router wiring: MULTICA_LARK_SECRET_KEY drives a conditional
initialization of h.LarkInstallations + h.LarkBindingTokens. When
unset, the integration disables itself with an INFO log and the
rest of the server boots normally.
- Handler tests cover all four not-configured short-circuits.
Happy-path integration tests (real DB, full create→list→revoke
cycle and token mint→redeem) ship alongside the WS hub PR.
Co-authored-by: multica-agent <[email protected]>
This commit is contained in:
co-authored by
multica-agent
parent
79dc637002
commit
87ad15e167
@@ -21,12 +21,14 @@ import (
|
||||
"github.com/multica-ai/multica/server/internal/daemonws"
|
||||
"github.com/multica-ai/multica/server/internal/events"
|
||||
"github.com/multica-ai/multica/server/internal/handler"
|
||||
"github.com/multica-ai/multica/server/internal/integrations/lark"
|
||||
obsmetrics "github.com/multica-ai/multica/server/internal/metrics"
|
||||
"github.com/multica-ai/multica/server/internal/middleware"
|
||||
"github.com/multica-ai/multica/server/internal/realtime"
|
||||
"github.com/multica-ai/multica/server/internal/service"
|
||||
"github.com/multica-ai/multica/server/internal/storage"
|
||||
"github.com/multica-ai/multica/server/internal/util"
|
||||
"github.com/multica-ai/multica/server/internal/util/secretbox"
|
||||
db "github.com/multica-ai/multica/server/pkg/db/generated"
|
||||
)
|
||||
|
||||
@@ -152,6 +154,31 @@ func NewRouterWithOptions(pool *pgxpool.Pool, hub *realtime.Hub, bus *events.Bus
|
||||
h.WebhookRateLimiter = handler.NewRedisWebhookRateLimiter(rdb, handler.DefaultWebhookRateLimit())
|
||||
h.WebhookIPRateLimiter = handler.NewRedisWebhookIPRateLimiter(rdb, handler.DefaultWebhookIPRateLimit())
|
||||
}
|
||||
|
||||
// Lark integration. Only wired when MULTICA_LARK_SECRET_KEY is set:
|
||||
// the InstallationService refuses to fall back to plaintext storage
|
||||
// for app_secret, and the BindingTokenService cannot mint usable
|
||||
// tokens without it either. When the key is absent the Lark
|
||||
// handlers return 503 with a clear message; the rest of the server
|
||||
// continues to start so self-host deployments that have not opted
|
||||
// in to Lark are unaffected.
|
||||
if larkKey, err := secretbox.LoadKey("MULTICA_LARK_SECRET_KEY"); err == nil {
|
||||
box, err := secretbox.New(larkKey)
|
||||
if err != nil {
|
||||
slog.Error("lark: secretbox.New failed; lark integration disabled", "error", err)
|
||||
} else {
|
||||
installSvc, err := lark.NewInstallationService(queries, box)
|
||||
if err != nil {
|
||||
slog.Error("lark: InstallationService init failed; lark integration disabled", "error", err)
|
||||
} else {
|
||||
h.LarkInstallations = installSvc
|
||||
h.LarkBindingTokens = lark.NewBindingTokenService(queries)
|
||||
slog.Info("lark integration enabled")
|
||||
}
|
||||
}
|
||||
} else {
|
||||
slog.Info("lark integration disabled (MULTICA_LARK_SECRET_KEY not set)")
|
||||
}
|
||||
if opts.HeartbeatScheduler != nil {
|
||||
h.HeartbeatScheduler = opts.HeartbeatScheduler
|
||||
}
|
||||
@@ -363,9 +390,33 @@ func NewRouterWithOptions(pool *pgxpool.Pool, hub *realtime.Hub, bus *events.Bus
|
||||
r.Get("/github/connect", h.GitHubConnect)
|
||||
r.Delete("/github/installations/{installationId}", h.DeleteGitHubInstallation)
|
||||
})
|
||||
|
||||
// Lark integration. Listing is member-visible (same
|
||||
// rationale as GitHub: the Integrations tab must
|
||||
// render for non-admins so they see "wired up by whom").
|
||||
// Create / revoke require admin to prevent a non-admin
|
||||
// from binding a Bot to a workspace agent or yanking
|
||||
// an installation out from under one.
|
||||
r.Group(func(r chi.Router) {
|
||||
r.Use(middleware.RequireWorkspaceMemberFromURL(queries, "id"))
|
||||
r.Get("/lark/installations", h.ListLarkInstallations)
|
||||
})
|
||||
r.Group(func(r chi.Router) {
|
||||
r.Use(middleware.RequireWorkspaceRoleFromURL(queries, "id", "owner", "admin"))
|
||||
r.Post("/lark/installations", h.CreateLarkInstallation)
|
||||
r.Delete("/lark/installations/{installationId}", h.RevokeLarkInstallation)
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
// Lark binding-token redemption. NOT workspace-scoped because
|
||||
// the redeemer hits this BEFORE they have any workspace
|
||||
// context — the redemption itself is what mints their
|
||||
// lark_user_binding row. Identity comes from the session;
|
||||
// the token only proves "this open_id requested binding," and
|
||||
// is combined with the logged-in user to create the mapping.
|
||||
r.Post("/api/lark/binding/redeem", h.RedeemLarkBindingToken)
|
||||
|
||||
// User-scoped invitation routes (no workspace context required)
|
||||
r.Get("/api/invitations", h.ListMyInvitations)
|
||||
r.Get("/api/invitations/{id}", h.GetMyInvitation)
|
||||
|
||||
@@ -20,6 +20,7 @@ import (
|
||||
"github.com/multica-ai/multica/server/internal/cloudruntime"
|
||||
"github.com/multica-ai/multica/server/internal/daemonws"
|
||||
"github.com/multica-ai/multica/server/internal/events"
|
||||
"github.com/multica-ai/multica/server/internal/integrations/lark"
|
||||
"github.com/multica-ai/multica/server/internal/middleware"
|
||||
"github.com/multica-ai/multica/server/internal/realtime"
|
||||
"github.com/multica-ai/multica/server/internal/service"
|
||||
@@ -106,7 +107,14 @@ type Handler struct {
|
||||
WebhookRateLimiter WebhookRateLimiter
|
||||
WebhookIPRateLimiter WebhookRateLimiter
|
||||
CloudRuntime cloudRuntimeProxy
|
||||
cfg Config
|
||||
// Lark integration. Both are nil when the Lark master key
|
||||
// (MULTICA_LARK_SECRET_KEY) is unset; the corresponding HTTP
|
||||
// handlers return 503 in that case so a misconfigured self-host
|
||||
// deployment surfaces a clear error instead of silently using a
|
||||
// zero key. Wired in cmd/server/router.go after handler.New.
|
||||
LarkInstallations *lark.InstallationService
|
||||
LarkBindingTokens *lark.BindingTokenService
|
||||
cfg Config
|
||||
}
|
||||
|
||||
func New(queries *db.Queries, txStarter txStarter, hub *realtime.Hub, bus *events.Bus, emailService *service.EmailService, store storage.Storage, cfSigner *auth.CloudFrontSigner, analyticsClient analytics.Client, cfg Config, daemonHubs ...*daemonws.Hub) *Handler {
|
||||
|
||||
@@ -0,0 +1,278 @@
|
||||
package handler
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"net/http"
|
||||
"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"`
|
||||
InstalledAt string `json:"installed_at"`
|
||||
CreatedAt string `json:"created_at"`
|
||||
UpdatedAt string `json:"updated_at"`
|
||||
}
|
||||
|
||||
func larkInstallationToResponse(row db.LarkInstallation) 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,
|
||||
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
|
||||
}
|
||||
|
||||
// CreateLarkInstallationRequest is the manual-install payload (admin
|
||||
// pastes credentials from the Lark developer console). The OAuth
|
||||
// callback path lands credentials via the same InstallationService
|
||||
// pipeline, so adding it later does not change this contract.
|
||||
type CreateLarkInstallationRequest struct {
|
||||
AgentID string `json:"agent_id"`
|
||||
AppID string `json:"app_id"`
|
||||
AppSecret string `json:"app_secret"`
|
||||
TenantKey string `json:"tenant_key,omitempty"`
|
||||
BotOpenID string `json:"bot_open_id"`
|
||||
}
|
||||
|
||||
// CreateLarkInstallation (POST /api/workspaces/{id}/lark/installations)
|
||||
// is admin-only at the router level. It performs the at-rest
|
||||
// encryption of `app_secret` via InstallationService and refuses to
|
||||
// fall back to plaintext storage when the master key is unset (503).
|
||||
func (h *Handler) CreateLarkInstallation(w http.ResponseWriter, r *http.Request) {
|
||||
if h.LarkInstallations == nil {
|
||||
writeError(w, http.StatusServiceUnavailable, "lark integration not configured (MULTICA_LARK_SECRET_KEY)")
|
||||
return
|
||||
}
|
||||
userID, ok := requireUserID(w, r)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
wsUUID, ok := parseUUIDOrBadRequest(w, chi.URLParam(r, "id"), "workspace id")
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
|
||||
var req CreateLarkInstallationRequest
|
||||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||
writeError(w, http.StatusBadRequest, "invalid request body")
|
||||
return
|
||||
}
|
||||
agentUUID, ok := parseUUIDOrBadRequest(w, req.AgentID, "agent_id")
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
// Validate the agent really belongs to this workspace before we
|
||||
// accept its credentials. Without this guard a workspace admin
|
||||
// could install Lark on an agent in a different workspace by
|
||||
// supplying that workspace's agent_id.
|
||||
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
|
||||
}
|
||||
installerUUID, ok := parseUUIDOrBadRequest(w, userID, "installer user id")
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
|
||||
inst, err := h.LarkInstallations.Upsert(r.Context(), lark.InstallationParams{
|
||||
WorkspaceID: wsUUID,
|
||||
AgentID: agentUUID,
|
||||
AppID: req.AppID,
|
||||
AppSecret: req.AppSecret,
|
||||
TenantKey: req.TenantKey,
|
||||
BotOpenID: req.BotOpenID,
|
||||
InstallerUserID: installerUUID,
|
||||
})
|
||||
if err != nil {
|
||||
writeError(w, http.StatusBadRequest, err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
resp := larkInstallationToResponse(inst)
|
||||
h.publish(protocol.EventLarkInstallationCreated, uuidToString(wsUUID), "user", userID, resp)
|
||||
writeJSON(w, http.StatusCreated, 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.
|
||||
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,
|
||||
})
|
||||
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,
|
||||
})
|
||||
}
|
||||
|
||||
// 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
|
||||
// CreateLarkInstallation flips status back to 'active' atomically.
|
||||
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.
|
||||
if _, err := h.LarkInstallations.GetInWorkspace(r.Context(), instUUID, wsUUID); 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
|
||||
}
|
||||
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.
|
||||
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.Redeem(r.Context(), req.Token)
|
||||
if err != nil {
|
||||
if errors.Is(err, lark.ErrBindingTokenInvalid) {
|
||||
writeError(w, http.StatusGone, "binding token invalid or expired")
|
||||
return
|
||||
}
|
||||
writeError(w, http.StatusInternalServerError, "failed to redeem token")
|
||||
return
|
||||
}
|
||||
|
||||
// CreateLarkUserBinding's composite FK to member(workspace_id, user_id)
|
||||
// will fail with a foreign-key violation if the redeemer is NOT a
|
||||
// member of the token's workspace. We surface that as 403 so a
|
||||
// non-member who stole the link cannot silently complete binding.
|
||||
if _, err := h.Queries.CreateLarkUserBinding(r.Context(), db.CreateLarkUserBindingParams{
|
||||
WorkspaceID: redeemed.WorkspaceID,
|
||||
MulticaUserID: userUUID,
|
||||
InstallationID: redeemed.InstallationID,
|
||||
LarkOpenID: string(redeemed.LarkOpenID),
|
||||
}); err != nil {
|
||||
writeError(w, http.StatusForbidden, "binding refused (are you a workspace member?)")
|
||||
return
|
||||
}
|
||||
|
||||
writeJSON(w, http.StatusOK, RedeemLarkBindingTokenResponse{
|
||||
WorkspaceID: uuidToString(redeemed.WorkspaceID),
|
||||
InstallationID: uuidToString(redeemed.InstallationID),
|
||||
LarkOpenID: string(redeemed.LarkOpenID),
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,74 @@
|
||||
package handler
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// Lark-handler unit tests focus on the no-config short-circuits —
|
||||
// verifying that a self-host deployment without MULTICA_LARK_SECRET_KEY
|
||||
// does NOT serve create / revoke / redeem, and that list degrades
|
||||
// gracefully to an empty response so the Integrations tab still
|
||||
// renders. Happy-path flows (create + list + revoke; token mint +
|
||||
// redeem) need a real DB and land alongside the WS hub integration
|
||||
// tests in a follow-up commit.
|
||||
|
||||
func TestCreateLarkInstallation_NotConfigured(t *testing.T) {
|
||||
h := &Handler{} // LarkInstallations intentionally nil
|
||||
req := httptest.NewRequest(http.MethodPost, "/api/workspaces/x/lark/installations", strings.NewReader(`{}`))
|
||||
w := httptest.NewRecorder()
|
||||
h.CreateLarkInstallation(w, req)
|
||||
if w.Code != http.StatusServiceUnavailable {
|
||||
t.Fatalf("expected 503 when lark not configured, got %d body=%s", w.Code, w.Body.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestRevokeLarkInstallation_NotConfigured(t *testing.T) {
|
||||
h := &Handler{}
|
||||
req := httptest.NewRequest(http.MethodDelete, "/api/workspaces/x/lark/installations/y", nil)
|
||||
w := httptest.NewRecorder()
|
||||
h.RevokeLarkInstallation(w, req)
|
||||
if w.Code != http.StatusServiceUnavailable {
|
||||
t.Fatalf("expected 503, got %d", w.Code)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRedeemLarkBindingToken_NotConfigured(t *testing.T) {
|
||||
h := &Handler{}
|
||||
req := httptest.NewRequest(http.MethodPost, "/api/lark/binding/redeem", strings.NewReader(`{"token":"x"}`))
|
||||
w := httptest.NewRecorder()
|
||||
h.RedeemLarkBindingToken(w, req)
|
||||
if w.Code != http.StatusServiceUnavailable {
|
||||
t.Fatalf("expected 503, got %d", w.Code)
|
||||
}
|
||||
}
|
||||
|
||||
func TestListLarkInstallations_NotConfiguredReturnsEmpty(t *testing.T) {
|
||||
// Listing is intentionally a "soft" endpoint: when lark is not
|
||||
// configured we return an empty list + configured:false rather
|
||||
// than a 503, so the Integrations tab renders normally with a
|
||||
// "not connected" empty state instead of an error banner.
|
||||
h := &Handler{}
|
||||
req := httptest.NewRequest(http.MethodGet, "/api/workspaces/x/lark/installations", nil)
|
||||
w := httptest.NewRecorder()
|
||||
h.ListLarkInstallations(w, req)
|
||||
if w.Code != http.StatusOK {
|
||||
t.Fatalf("expected 200, got %d body=%s", w.Code, w.Body.String())
|
||||
}
|
||||
var resp struct {
|
||||
Installations []any `json:"installations"`
|
||||
Configured bool `json:"configured"`
|
||||
}
|
||||
if err := json.Unmarshal(w.Body.Bytes(), &resp); err != nil {
|
||||
t.Fatalf("decode: %v", err)
|
||||
}
|
||||
if resp.Configured {
|
||||
t.Fatalf("configured should be false when LarkInstallations is nil")
|
||||
}
|
||||
if len(resp.Installations) != 0 {
|
||||
t.Fatalf("expected empty installations list, got %d", len(resp.Installations))
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,157 @@
|
||||
package lark
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
|
||||
"github.com/jackc/pgx/v5"
|
||||
"github.com/jackc/pgx/v5/pgtype"
|
||||
"github.com/multica-ai/multica/server/internal/util/secretbox"
|
||||
db "github.com/multica-ai/multica/server/pkg/db/generated"
|
||||
)
|
||||
|
||||
// InstallationParams is the input shape both the manual-install HTTP
|
||||
// handler and the OAuth callback assemble into. The credentials are
|
||||
// supplied here as plaintext — encryption happens inside
|
||||
// InstallationService.Upsert via the supplied *secretbox.Box, so
|
||||
// callers never see (and therefore cannot leak) the ciphertext that
|
||||
// lands in the DB.
|
||||
type InstallationParams struct {
|
||||
WorkspaceID pgtype.UUID
|
||||
AgentID pgtype.UUID
|
||||
AppID string
|
||||
AppSecret string // plaintext; encrypted at the service boundary
|
||||
TenantKey string // optional, "" treated as NULL
|
||||
BotOpenID string
|
||||
InstallerUserID pgtype.UUID
|
||||
}
|
||||
|
||||
// InstallationService creates, refreshes and revokes per-agent Lark
|
||||
// installations. It owns the at-rest encryption of `app_secret` so
|
||||
// that no caller (and no test fixture) can accidentally insert a row
|
||||
// with plaintext credentials — the only path to writing
|
||||
// lark_installation goes through here.
|
||||
type InstallationService struct {
|
||||
queries *db.Queries
|
||||
box *secretbox.Box
|
||||
}
|
||||
|
||||
// NewInstallationService binds the service to a queries handle and a
|
||||
// secretbox keyed for at-rest encryption. The box MUST be non-nil; we
|
||||
// refuse to fall back to plaintext storage even in test or dev
|
||||
// configurations because that is exactly the regression the §4.4
|
||||
// requirement guards against.
|
||||
func NewInstallationService(queries *db.Queries, box *secretbox.Box) (*InstallationService, error) {
|
||||
if box == nil {
|
||||
return nil, errors.New("lark: InstallationService requires a non-nil secretbox.Box")
|
||||
}
|
||||
return &InstallationService{queries: queries, box: box}, nil
|
||||
}
|
||||
|
||||
// Upsert creates a new installation or refreshes an existing one in
|
||||
// place (matching on the (workspace_id, agent_id) UNIQUE). Re-install
|
||||
// resets status to 'active' but does NOT touch the WS lease — that is
|
||||
// the hub's concern, not ours. The returned row is the post-write
|
||||
// state; the encrypted secret column is included for completeness but
|
||||
// callers SHOULD NOT log or persist it elsewhere.
|
||||
func (s *InstallationService) Upsert(ctx context.Context, p InstallationParams) (db.LarkInstallation, error) {
|
||||
if err := validateInstallationParams(p); err != nil {
|
||||
return db.LarkInstallation{}, err
|
||||
}
|
||||
sealed, err := s.box.Seal([]byte(p.AppSecret))
|
||||
if err != nil {
|
||||
return db.LarkInstallation{}, fmt.Errorf("encrypt app_secret: %w", err)
|
||||
}
|
||||
return s.queries.UpsertLarkInstallation(ctx, db.UpsertLarkInstallationParams{
|
||||
WorkspaceID: p.WorkspaceID,
|
||||
AgentID: p.AgentID,
|
||||
AppID: p.AppID,
|
||||
AppSecretEncrypted: sealed,
|
||||
TenantKey: textOrNull(p.TenantKey),
|
||||
BotOpenID: p.BotOpenID,
|
||||
InstallerUserID: p.InstallerUserID,
|
||||
})
|
||||
}
|
||||
|
||||
// Revoke flips status to 'revoked' so the WS hub tears the connection
|
||||
// down on its next sweep and the dispatcher drops any in-flight
|
||||
// events. The row is preserved (no DELETE) so audit history remains
|
||||
// queryable; a subsequent re-install via Upsert flips status back to
|
||||
// 'active' atomically.
|
||||
func (s *InstallationService) Revoke(ctx context.Context, id pgtype.UUID) error {
|
||||
return s.queries.SetLarkInstallationStatus(ctx, db.SetLarkInstallationStatusParams{
|
||||
ID: id,
|
||||
Status: string(InstallationRevoked),
|
||||
})
|
||||
}
|
||||
|
||||
// DecryptAppSecret returns the plaintext app_secret for the supplied
|
||||
// installation row. Used by the WebSocket hub when it needs to
|
||||
// authenticate against the Lark API on behalf of an installation; do
|
||||
// NOT use this for read-only display surfaces. The plaintext value
|
||||
// must never round-trip through an HTTP response.
|
||||
func (s *InstallationService) DecryptAppSecret(inst db.LarkInstallation) (string, error) {
|
||||
plain, err := s.box.Open(inst.AppSecretEncrypted)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("decrypt app_secret: %w", err)
|
||||
}
|
||||
return string(plain), nil
|
||||
}
|
||||
|
||||
// GetInWorkspace is the workspace-scoped lookup helper. Internal
|
||||
// callers (Dispatcher) use GetLarkInstallationByAppID directly because
|
||||
// the event payload only carries app_id; HTTP-side callers always
|
||||
// know the workspace and should use this so a forged installation_id
|
||||
// from a different workspace returns NotFound instead of leaking
|
||||
// existence.
|
||||
func (s *InstallationService) GetInWorkspace(ctx context.Context, id, workspaceID pgtype.UUID) (db.LarkInstallation, error) {
|
||||
row, err := s.queries.GetLarkInstallationInWorkspace(ctx, db.GetLarkInstallationInWorkspaceParams{
|
||||
ID: id,
|
||||
WorkspaceID: workspaceID,
|
||||
})
|
||||
if err != nil {
|
||||
if errors.Is(err, pgx.ErrNoRows) {
|
||||
return db.LarkInstallation{}, ErrInstallationNotFound
|
||||
}
|
||||
return db.LarkInstallation{}, err
|
||||
}
|
||||
return row, nil
|
||||
}
|
||||
|
||||
// ListByWorkspace returns every installation rooted at the workspace,
|
||||
// active and revoked, oldest first. The status column lets the UI
|
||||
// distinguish "wired up" from "torn down but kept for audit".
|
||||
func (s *InstallationService) ListByWorkspace(ctx context.Context, workspaceID pgtype.UUID) ([]db.LarkInstallation, error) {
|
||||
return s.queries.ListLarkInstallationsByWorkspace(ctx, workspaceID)
|
||||
}
|
||||
|
||||
// ErrInstallationNotFound surfaces "no row matches in this workspace"
|
||||
// — used by the HTTP layer to return 404. Distinct from a plain
|
||||
// pgx.ErrNoRows so handlers do not need to import pgx.
|
||||
var ErrInstallationNotFound = errors.New("lark installation not found")
|
||||
|
||||
func validateInstallationParams(p InstallationParams) error {
|
||||
switch {
|
||||
case !p.WorkspaceID.Valid:
|
||||
return errors.New("workspace_id is required")
|
||||
case !p.AgentID.Valid:
|
||||
return errors.New("agent_id is required")
|
||||
case !p.InstallerUserID.Valid:
|
||||
return errors.New("installer_user_id is required")
|
||||
case p.AppID == "":
|
||||
return errors.New("app_id is required")
|
||||
case p.AppSecret == "":
|
||||
return errors.New("app_secret is required")
|
||||
case p.BotOpenID == "":
|
||||
return errors.New("bot_open_id is required")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func textOrNull(s string) pgtype.Text {
|
||||
if s == "" {
|
||||
return pgtype.Text{}
|
||||
}
|
||||
return pgtype.Text{String: s, Valid: true}
|
||||
}
|
||||
@@ -122,4 +122,13 @@ const (
|
||||
EventPullRequestLinked = "pull_request:linked"
|
||||
EventPullRequestUpdated = "pull_request:updated"
|
||||
EventPullRequestUnlinked = "pull_request:unlinked"
|
||||
|
||||
// Lark integration events. `created` covers both first-install
|
||||
// (UNIQUE on (workspace_id, agent_id) means at most one row per
|
||||
// agent) and re-install via UpsertLarkInstallation — front-ends
|
||||
// treat both as a single "installation appeared / refreshed"
|
||||
// notification. `revoked` flips status to 'revoked' without
|
||||
// deleting the row; the audit trail is preserved.
|
||||
EventLarkInstallationCreated = "lark_installation:created"
|
||||
EventLarkInstallationRevoked = "lark_installation:revoked"
|
||||
)
|
||||
|
||||
Reference in New Issue
Block a user