mirror of
https://github.com/tiennm99/goclaw.git
synced 2026-10-04 16:13:47 +00:00
Add total-count pagination to the webhook admin endpoints and the web UI.
Store:
- WebhookStore/WebhookCallStore gain Count; WebhookListFilter gains
IncludeRevoked + Query (PG + SQLite, parameterized, tenant-scoped)
API:
- GET /v1/webhooks and GET /v1/webhooks/{id}/calls return
{items, total, limit, offset} with server-side search + revoked filtering
Web UI:
- server-driven list pager + search/revoked filter; call-history dialog uses
the real total (fixes the full-page "has more" boundary bug)
- i18n pager labels (en/vi/zh)
Tests: store pagination integration test; mock stores implement Count.
No schema migration (read-only COUNT).
969 lines
34 KiB
Go
969 lines
34 KiB
Go
package http
|
|
|
|
import (
|
|
"context"
|
|
"crypto/rand"
|
|
"crypto/sha256"
|
|
"encoding/base32"
|
|
"encoding/hex"
|
|
"log/slog"
|
|
"net/http"
|
|
"strconv"
|
|
"time"
|
|
|
|
"github.com/google/uuid"
|
|
|
|
"github.com/nextlevelbuilder/goclaw/internal/bus"
|
|
"github.com/nextlevelbuilder/goclaw/internal/crypto"
|
|
"github.com/nextlevelbuilder/goclaw/internal/edition"
|
|
"github.com/nextlevelbuilder/goclaw/internal/i18n"
|
|
"github.com/nextlevelbuilder/goclaw/internal/permissions"
|
|
"github.com/nextlevelbuilder/goclaw/internal/store"
|
|
"github.com/nextlevelbuilder/goclaw/pkg/protocol"
|
|
)
|
|
|
|
// Compile-time assertion: WebhooksAdminHandler must implement routeRegistrar
|
|
// (the interface defined in internal/gateway/server.go).
|
|
var _ interface{ RegisterRoutes(mux *http.ServeMux) } = (*WebhooksAdminHandler)(nil)
|
|
|
|
// webhookKinds is the set of valid webhook kinds.
|
|
var webhookKinds = map[string]bool{
|
|
"llm": true,
|
|
"message": true,
|
|
}
|
|
|
|
// webhookLLMTester runs a server-side test invocation of an llm-kind webhook using the
|
|
// admin's already-authorized session (no webhook secret required). Implemented by *WebhookLLMHandler.
|
|
type webhookLLMTester interface {
|
|
RunTest(ctx context.Context, wh *store.WebhookData, input, model string) (*webhookLLMSyncResp, error)
|
|
}
|
|
|
|
// webhookMsgTester runs a server-side test invocation of a message-kind webhook. Implemented by
|
|
// *WebhookMessageHandler. nil on Lite edition (channels unavailable) — handleTest guards on nil.
|
|
type webhookMsgTester interface {
|
|
RunTest(ctx context.Context, wh *store.WebhookData, req webhookMessageReq) (*webhookMessageResp, error)
|
|
}
|
|
|
|
// WebhooksAdminHandler implements CRUD for webhook registry entries.
|
|
// All endpoints are tenant-admin-gated (requireTenantAdmin).
|
|
// encKey is the AES-256-GCM encryption key (GOCLAW_ENCRYPTION_KEY); if empty, encrypted_secret
|
|
// is stored as "" and HMAC auth requires rotation before it can be used.
|
|
type WebhooksAdminHandler struct {
|
|
webhooks store.WebhookStore
|
|
calls store.WebhookCallStore
|
|
tenants store.TenantStore
|
|
msgBus *bus.MessageBus
|
|
encKey string // AES-256-GCM key for encrypting raw webhook secrets at rest
|
|
llmTester webhookLLMTester
|
|
msgTester webhookMsgTester
|
|
}
|
|
|
|
// NewWebhooksAdminHandler creates a handler for webhook admin endpoints.
|
|
// calls may be nil — the delivery-history endpoint returns 503 when unset.
|
|
func NewWebhooksAdminHandler(webhooks store.WebhookStore, calls store.WebhookCallStore, tenants store.TenantStore, msgBus *bus.MessageBus) *WebhooksAdminHandler {
|
|
return &WebhooksAdminHandler{
|
|
webhooks: webhooks,
|
|
calls: calls,
|
|
tenants: tenants,
|
|
msgBus: msgBus,
|
|
}
|
|
}
|
|
|
|
// SetEncKey sets the AES-256-GCM encryption key used to encrypt raw webhook secrets at rest.
|
|
// Must be called before the first Create/Rotate request; safe to call at startup only.
|
|
func (h *WebhooksAdminHandler) SetEncKey(encKey string) {
|
|
h.encKey = encKey
|
|
}
|
|
|
|
// SetTesters wires the runtime invocation handlers used by POST /v1/webhooks/{id}/test.
|
|
// Concrete pointer params (not interfaces) so callers can pass typed-nil safely: a nil
|
|
// *WebhookMessageHandler on Lite leaves msgTester as a true nil interface. Safe to call at startup only.
|
|
func (h *WebhooksAdminHandler) SetTesters(llm *WebhookLLMHandler, msg *WebhookMessageHandler) {
|
|
if llm != nil {
|
|
h.llmTester = llm
|
|
}
|
|
if msg != nil {
|
|
h.msgTester = msg
|
|
}
|
|
}
|
|
|
|
// RegisterRoutes registers all webhook admin routes on mux.
|
|
// Admin CRUD routes mount for both editions.
|
|
// Runtime routes (/v1/webhooks/message, /v1/webhooks/llm) are mounted by phases 05/06
|
|
// conditionally: message-kind only if edition.Current().AllowsChannels().
|
|
func (h *WebhooksAdminHandler) RegisterRoutes(mux *http.ServeMux) {
|
|
mux.HandleFunc("POST /v1/webhooks", h.requireAdmin(h.handleCreate))
|
|
mux.HandleFunc("GET /v1/webhooks", h.requireAdmin(h.handleList))
|
|
mux.HandleFunc("GET /v1/webhooks/{id}", h.requireAdmin(h.handleGet))
|
|
mux.HandleFunc("PATCH /v1/webhooks/{id}", h.requireAdmin(h.handleUpdate))
|
|
mux.HandleFunc("POST /v1/webhooks/{id}/rotate", h.requireAdmin(h.handleRotate))
|
|
mux.HandleFunc("DELETE /v1/webhooks/{id}", h.requireAdmin(h.handleRevoke))
|
|
mux.HandleFunc("GET /v1/webhooks/{id}/calls", h.requireAdmin(h.handleListCalls))
|
|
mux.HandleFunc("GET /v1/webhooks/{id}/calls/{callId}", h.requireAdmin(h.handleGetCall))
|
|
mux.HandleFunc("POST /v1/webhooks/{id}/test", h.requireAdmin(h.handleTest))
|
|
}
|
|
|
|
func (h *WebhooksAdminHandler) requireAdmin(next http.HandlerFunc) http.HandlerFunc {
|
|
return func(w http.ResponseWriter, r *http.Request) {
|
|
if role := permissions.Role(store.RoleFromContext(r.Context())); role != "" {
|
|
if !permissions.HasMinRole(role, permissions.RoleAdmin) {
|
|
writeJSON(w, http.StatusForbidden, map[string]string{
|
|
"error": i18n.T(store.LocaleFromContext(r.Context()), i18n.MsgPermissionDenied, r.URL.Path+" requires "+string(permissions.RoleAdmin)+" role"),
|
|
})
|
|
return
|
|
}
|
|
next(w, r)
|
|
return
|
|
}
|
|
requireAuth(permissions.RoleAdmin, next)(w, r)
|
|
}
|
|
}
|
|
|
|
// --- Create ---
|
|
|
|
// createWebhookReq is the request body for POST /v1/webhooks.
|
|
type createWebhookReq struct {
|
|
Name string `json:"name"`
|
|
Kind string `json:"kind"` // "llm" | "message"
|
|
AgentID *uuid.UUID `json:"agent_id,omitempty"`
|
|
Scopes []string `json:"scopes,omitempty"`
|
|
ChannelID *uuid.UUID `json:"channel_id,omitempty"`
|
|
RateLimitPerMin int `json:"rate_limit_per_min,omitempty"`
|
|
IPAllowlist []string `json:"ip_allowlist,omitempty"`
|
|
RequireHMAC bool `json:"require_hmac,omitempty"`
|
|
LocalhostOnly bool `json:"localhost_only,omitempty"`
|
|
}
|
|
|
|
// webhookCreateResp is the response for create and rotate — includes raw secret once.
|
|
// hmac_signing_key = raw secret itself — callers sign HMAC requests using raw secret bytes.
|
|
// The raw secret is encrypted at rest; secret_hash is kept only for bearer-token lookup.
|
|
type webhookCreateResp struct {
|
|
ID uuid.UUID `json:"id"`
|
|
TenantID uuid.UUID `json:"tenant_id"`
|
|
AgentID *uuid.UUID `json:"agent_id,omitempty"`
|
|
Name string `json:"name"`
|
|
Kind string `json:"kind"`
|
|
SecretPrefix string `json:"secret_prefix"`
|
|
Secret string `json:"secret"` // raw secret — shown ONCE; use this as HMAC key
|
|
HMACSigningKey string `json:"hmac_signing_key"` // same as Secret — raw bytes for X-GoClaw-Signature
|
|
Scopes []string `json:"scopes"`
|
|
ChannelID *uuid.UUID `json:"channel_id,omitempty"`
|
|
RateLimitPerMin int `json:"rate_limit_per_min"`
|
|
IPAllowlist []string `json:"ip_allowlist"`
|
|
RequireHMAC bool `json:"require_hmac"`
|
|
LocalhostOnly bool `json:"localhost_only"`
|
|
CreatedAt time.Time `json:"created_at"`
|
|
}
|
|
|
|
func (h *WebhooksAdminHandler) handleCreate(w http.ResponseWriter, r *http.Request) {
|
|
locale := extractLocale(r)
|
|
|
|
// Auth first — don't leak config state (encKey presence) to unauthenticated callers.
|
|
if !requireTenantAdmin(w, r, h.tenants) {
|
|
slog.Warn("security.webhook.admin_denied", "action", "create", "path", r.URL.Path,
|
|
"user_id", store.UserIDFromContext(r.Context()))
|
|
return
|
|
}
|
|
|
|
// Defense-in-depth: primary guard is skip-mount in gateway_http_wiring.go.
|
|
// This secondary guard protects if the handler is ever wired without an encKey
|
|
// (e.g. test harness or future refactor that bypasses the wiring guard).
|
|
if h.encKey == "" {
|
|
slog.Error("security.webhook.admin_no_enc_key", "action", "create")
|
|
writeError(w, http.StatusServiceUnavailable, protocol.ErrInternal, i18n.T(locale, i18n.MsgWebhookEncryptionUnavailable))
|
|
return
|
|
}
|
|
|
|
var req createWebhookReq
|
|
if !bindJSON(w, r, locale, &req) {
|
|
return
|
|
}
|
|
|
|
// Validate required fields.
|
|
if req.Name == "" {
|
|
writeError(w, http.StatusBadRequest, protocol.ErrInvalidRequest, i18n.T(locale, i18n.MsgRequired, "name"))
|
|
return
|
|
}
|
|
if len(req.Name) > 100 {
|
|
writeError(w, http.StatusBadRequest, protocol.ErrInvalidRequest, i18n.T(locale, i18n.MsgInvalidRequest, "name must be 100 characters or less"))
|
|
return
|
|
}
|
|
if !webhookKinds[req.Kind] {
|
|
writeError(w, http.StatusBadRequest, protocol.ErrInvalidRequest, i18n.T(locale, i18n.MsgInvalidRequest, "kind must be 'llm' or 'message'"))
|
|
return
|
|
}
|
|
|
|
// Edition gate: message kind requires channels edition.
|
|
if req.Kind == "message" && !edition.Current().AllowsChannels() {
|
|
writeError(w, http.StatusForbidden, protocol.ErrUnauthorized, i18n.T(locale, i18n.MsgInvalidRequest, "message webhooks require Standard edition"))
|
|
return
|
|
}
|
|
|
|
// Lite edition: force localhost_only=true for all webhook kinds.
|
|
if !edition.Current().AllowsChannels() {
|
|
req.LocalhostOnly = true
|
|
}
|
|
|
|
raw, secretHash, secretPrefix, err := generateWebhookSecret()
|
|
if err != nil {
|
|
slog.Error("webhook.admin.secret_generate_failed", "error", err)
|
|
writeError(w, http.StatusInternalServerError, protocol.ErrInternal, i18n.T(locale, i18n.MsgInternalError, "secret generation"))
|
|
return
|
|
}
|
|
|
|
// Encrypt raw secret at rest. If encKey is empty, encryptedSecret is "" (requires rotation).
|
|
encryptedSecret, encErr := crypto.Encrypt(raw, h.encKey)
|
|
if encErr != nil {
|
|
slog.Error("webhook.admin.secret_encrypt_failed", "error", encErr)
|
|
writeError(w, http.StatusInternalServerError, protocol.ErrInternal, i18n.T(locale, i18n.MsgInternalError, "secret encryption"))
|
|
return
|
|
}
|
|
|
|
ctx := r.Context()
|
|
tenantID := store.TenantIDFromContext(ctx)
|
|
now := time.Now()
|
|
|
|
wh := &store.WebhookData{
|
|
ID: store.GenNewID(),
|
|
TenantID: tenantID,
|
|
AgentID: req.AgentID,
|
|
Name: req.Name,
|
|
Kind: req.Kind,
|
|
SecretPrefix: secretPrefix,
|
|
SecretHash: secretHash,
|
|
EncryptedSecret: encryptedSecret,
|
|
Scopes: req.Scopes,
|
|
ChannelID: req.ChannelID,
|
|
RateLimitPerMin: req.RateLimitPerMin,
|
|
IPAllowlist: req.IPAllowlist,
|
|
RequireHMAC: req.RequireHMAC,
|
|
LocalhostOnly: req.LocalhostOnly,
|
|
Revoked: false,
|
|
CreatedBy: extractUserID(r),
|
|
CreatedAt: now,
|
|
UpdatedAt: now,
|
|
}
|
|
if wh.Scopes == nil {
|
|
wh.Scopes = []string{}
|
|
}
|
|
if wh.IPAllowlist == nil {
|
|
wh.IPAllowlist = []string{}
|
|
}
|
|
|
|
if err := h.webhooks.Create(ctx, wh); err != nil {
|
|
slog.Error("webhook.admin.create_failed", "error", err)
|
|
writeError(w, http.StatusInternalServerError, protocol.ErrInternal, i18n.T(locale, i18n.MsgFailedToCreate, "webhook", "internal error"))
|
|
return
|
|
}
|
|
|
|
slog.Info("webhook.created", "id", wh.ID, "tenant_id", tenantID, "actor", wh.CreatedBy, "kind", wh.Kind)
|
|
h.emitCacheInvalidate(wh.ID.String())
|
|
|
|
writeJSON(w, http.StatusCreated, webhookCreateResp{
|
|
ID: wh.ID,
|
|
TenantID: wh.TenantID,
|
|
AgentID: wh.AgentID,
|
|
Name: wh.Name,
|
|
Kind: wh.Kind,
|
|
SecretPrefix: wh.SecretPrefix,
|
|
Secret: raw,
|
|
HMACSigningKey: raw, // raw secret bytes are the HMAC key (encrypted at rest; decrypted at sign time)
|
|
Scopes: wh.Scopes,
|
|
ChannelID: wh.ChannelID,
|
|
RateLimitPerMin: wh.RateLimitPerMin,
|
|
IPAllowlist: wh.IPAllowlist,
|
|
RequireHMAC: wh.RequireHMAC,
|
|
LocalhostOnly: wh.LocalhostOnly,
|
|
CreatedAt: wh.CreatedAt,
|
|
})
|
|
}
|
|
|
|
// --- List ---
|
|
|
|
func (h *WebhooksAdminHandler) handleList(w http.ResponseWriter, r *http.Request) {
|
|
locale := extractLocale(r)
|
|
|
|
if !requireTenantAdmin(w, r, h.tenants) {
|
|
slog.Warn("security.webhook.admin_denied", "action", "list", "path", r.URL.Path,
|
|
"user_id", store.UserIDFromContext(r.Context()))
|
|
return
|
|
}
|
|
|
|
f := store.WebhookListFilter{
|
|
Query: r.URL.Query().Get("q"),
|
|
IncludeRevoked: r.URL.Query().Get("include_revoked") == "true",
|
|
Limit: webhookListDefaultLimit,
|
|
}
|
|
if agentIDStr := r.URL.Query().Get("agent_id"); agentIDStr != "" {
|
|
aid, err := uuid.Parse(agentIDStr)
|
|
if err != nil {
|
|
writeError(w, http.StatusBadRequest, protocol.ErrInvalidRequest, i18n.T(locale, i18n.MsgInvalidID, "agent_id"))
|
|
return
|
|
}
|
|
f.AgentID = &aid
|
|
}
|
|
if l := r.URL.Query().Get("limit"); l != "" {
|
|
if n, perr := strconv.Atoi(l); perr == nil && n > 0 {
|
|
if n > webhookListMaxLimit {
|
|
n = webhookListMaxLimit
|
|
}
|
|
f.Limit = n
|
|
}
|
|
}
|
|
if o := r.URL.Query().Get("offset"); o != "" {
|
|
if n, perr := strconv.Atoi(o); perr == nil && n >= 0 {
|
|
f.Offset = n
|
|
}
|
|
}
|
|
|
|
rows, err := h.webhooks.List(r.Context(), f)
|
|
if err != nil {
|
|
slog.Error("webhook.admin.list_failed", "error", err)
|
|
writeError(w, http.StatusInternalServerError, protocol.ErrInternal, i18n.T(locale, i18n.MsgFailedToList, "webhooks"))
|
|
return
|
|
}
|
|
total, err := h.webhooks.Count(r.Context(), f)
|
|
if err != nil {
|
|
slog.Error("webhook.admin.count_failed", "error", err)
|
|
writeError(w, http.StatusInternalServerError, protocol.ErrInternal, i18n.T(locale, i18n.MsgFailedToList, "webhooks"))
|
|
return
|
|
}
|
|
if rows == nil {
|
|
rows = []store.WebhookData{}
|
|
}
|
|
writeJSON(w, http.StatusOK, map[string]any{
|
|
"items": rows,
|
|
"total": total,
|
|
"limit": f.Limit,
|
|
"offset": f.Offset,
|
|
})
|
|
}
|
|
|
|
// --- Get ---
|
|
|
|
func (h *WebhooksAdminHandler) handleGet(w http.ResponseWriter, r *http.Request) {
|
|
locale := extractLocale(r)
|
|
|
|
if !requireTenantAdmin(w, r, h.tenants) {
|
|
slog.Warn("security.webhook.admin_denied", "action", "get", "path", r.URL.Path,
|
|
"user_id", store.UserIDFromContext(r.Context()))
|
|
return
|
|
}
|
|
|
|
id, ok := parseWebhookID(w, r, locale)
|
|
if !ok {
|
|
return
|
|
}
|
|
|
|
wh, err := h.webhooks.GetByID(r.Context(), id)
|
|
if err != nil || wh == nil {
|
|
writeError(w, http.StatusNotFound, protocol.ErrNotFound, i18n.T(locale, i18n.MsgNotFound, "webhook", id.String()))
|
|
return
|
|
}
|
|
|
|
// Cross-tenant isolation: GetByID is tenant-scoped via context, but verify explicitly.
|
|
tenantID := store.TenantIDFromContext(r.Context())
|
|
if !store.IsOwnerRole(r.Context()) && wh.TenantID != tenantID {
|
|
writeError(w, http.StatusNotFound, protocol.ErrNotFound, i18n.T(locale, i18n.MsgNotFound, "webhook", id.String()))
|
|
return
|
|
}
|
|
|
|
writeJSON(w, http.StatusOK, wh)
|
|
}
|
|
|
|
// --- Update ---
|
|
|
|
// updateWebhookReq is the request body for PATCH /v1/webhooks/{id}.
|
|
// All fields are optional; omitted fields are not changed.
|
|
type updateWebhookReq struct {
|
|
Name *string `json:"name,omitempty"`
|
|
Scopes []string `json:"scopes,omitempty"`
|
|
ChannelID *uuid.UUID `json:"channel_id,omitempty"`
|
|
RateLimitPerMin *int `json:"rate_limit_per_min,omitempty"`
|
|
IPAllowlist []string `json:"ip_allowlist,omitempty"`
|
|
RequireHMAC *bool `json:"require_hmac,omitempty"`
|
|
LocalhostOnly *bool `json:"localhost_only,omitempty"`
|
|
}
|
|
|
|
func (h *WebhooksAdminHandler) handleUpdate(w http.ResponseWriter, r *http.Request) {
|
|
locale := extractLocale(r)
|
|
|
|
if !requireTenantAdmin(w, r, h.tenants) {
|
|
slog.Warn("security.webhook.admin_denied", "action", "update", "path", r.URL.Path,
|
|
"user_id", store.UserIDFromContext(r.Context()))
|
|
return
|
|
}
|
|
|
|
id, ok := parseWebhookID(w, r, locale)
|
|
if !ok {
|
|
return
|
|
}
|
|
|
|
ctx := r.Context()
|
|
|
|
// Verify ownership before mutating.
|
|
wh, err := h.webhooks.GetByID(ctx, id)
|
|
if err != nil || wh == nil {
|
|
writeError(w, http.StatusNotFound, protocol.ErrNotFound, i18n.T(locale, i18n.MsgNotFound, "webhook", id.String()))
|
|
return
|
|
}
|
|
tenantID := store.TenantIDFromContext(ctx)
|
|
if !store.IsOwnerRole(ctx) && wh.TenantID != tenantID {
|
|
writeError(w, http.StatusNotFound, protocol.ErrNotFound, i18n.T(locale, i18n.MsgNotFound, "webhook", id.String()))
|
|
return
|
|
}
|
|
|
|
var req updateWebhookReq
|
|
if !bindJSON(w, r, locale, &req) {
|
|
return
|
|
}
|
|
|
|
updates := make(map[string]any)
|
|
if req.Name != nil {
|
|
if *req.Name == "" {
|
|
writeError(w, http.StatusBadRequest, protocol.ErrInvalidRequest, i18n.T(locale, i18n.MsgRequired, "name"))
|
|
return
|
|
}
|
|
if len(*req.Name) > 100 {
|
|
writeError(w, http.StatusBadRequest, protocol.ErrInvalidRequest, i18n.T(locale, i18n.MsgInvalidRequest, "name must be 100 characters or less"))
|
|
return
|
|
}
|
|
updates["name"] = *req.Name
|
|
}
|
|
if req.Scopes != nil {
|
|
updates["scopes"] = req.Scopes
|
|
}
|
|
if req.ChannelID != nil {
|
|
updates["channel_id"] = *req.ChannelID
|
|
}
|
|
if req.RateLimitPerMin != nil {
|
|
updates["rate_limit_per_min"] = *req.RateLimitPerMin
|
|
}
|
|
if req.IPAllowlist != nil {
|
|
updates["ip_allowlist"] = req.IPAllowlist
|
|
}
|
|
if req.RequireHMAC != nil {
|
|
updates["require_hmac"] = *req.RequireHMAC
|
|
}
|
|
if req.LocalhostOnly != nil {
|
|
// Lite edition: cannot unset localhost_only.
|
|
if !*req.LocalhostOnly && !edition.Current().AllowsChannels() {
|
|
writeError(w, http.StatusForbidden, protocol.ErrUnauthorized, i18n.T(locale, i18n.MsgInvalidRequest, "localhost_only cannot be disabled on Lite edition"))
|
|
return
|
|
}
|
|
updates["localhost_only"] = *req.LocalhostOnly
|
|
}
|
|
|
|
if len(updates) == 0 {
|
|
// Nothing to update — return current state.
|
|
writeJSON(w, http.StatusOK, wh)
|
|
return
|
|
}
|
|
|
|
if err := h.webhooks.Update(ctx, id, updates); err != nil {
|
|
slog.Error("webhook.admin.update_failed", "error", err, "id", id)
|
|
writeError(w, http.StatusInternalServerError, protocol.ErrInternal, i18n.T(locale, i18n.MsgFailedToUpdate, "webhook", "internal error"))
|
|
return
|
|
}
|
|
|
|
slog.Info("webhook.updated", "id", id, "tenant_id", tenantID, "actor", extractUserID(r))
|
|
|
|
// Re-fetch to return updated state.
|
|
updated, err := h.webhooks.GetByID(ctx, id)
|
|
if err != nil || updated == nil {
|
|
writeError(w, http.StatusInternalServerError, protocol.ErrInternal, i18n.T(locale, i18n.MsgInternalError, "fetch updated webhook"))
|
|
return
|
|
}
|
|
writeJSON(w, http.StatusOK, updated)
|
|
}
|
|
|
|
// --- Rotate Secret ---
|
|
|
|
func (h *WebhooksAdminHandler) handleRotate(w http.ResponseWriter, r *http.Request) {
|
|
locale := extractLocale(r)
|
|
|
|
// Auth first — don't leak config state (encKey presence) to unauthenticated callers.
|
|
if !requireTenantAdmin(w, r, h.tenants) {
|
|
slog.Warn("security.webhook.admin_denied", "action", "rotate", "path", r.URL.Path,
|
|
"user_id", store.UserIDFromContext(r.Context()))
|
|
return
|
|
}
|
|
|
|
// Defense-in-depth: same guard as handleCreate — encryption key must be present
|
|
// before we generate and persist a new secret.
|
|
if h.encKey == "" {
|
|
slog.Error("security.webhook.admin_no_enc_key", "action", "rotate")
|
|
writeError(w, http.StatusServiceUnavailable, protocol.ErrInternal, i18n.T(locale, i18n.MsgWebhookEncryptionUnavailable))
|
|
return
|
|
}
|
|
|
|
id, ok := parseWebhookID(w, r, locale)
|
|
if !ok {
|
|
return
|
|
}
|
|
|
|
ctx := r.Context()
|
|
|
|
// Verify ownership before mutating.
|
|
wh, err := h.webhooks.GetByID(ctx, id)
|
|
if err != nil || wh == nil {
|
|
writeError(w, http.StatusNotFound, protocol.ErrNotFound, i18n.T(locale, i18n.MsgNotFound, "webhook", id.String()))
|
|
return
|
|
}
|
|
tenantID := store.TenantIDFromContext(ctx)
|
|
if !store.IsOwnerRole(ctx) && wh.TenantID != tenantID {
|
|
writeError(w, http.StatusNotFound, protocol.ErrNotFound, i18n.T(locale, i18n.MsgNotFound, "webhook", id.String()))
|
|
return
|
|
}
|
|
|
|
raw, newHash, newPrefix, err := generateWebhookSecret()
|
|
if err != nil {
|
|
slog.Error("webhook.admin.secret_generate_failed", "error", err)
|
|
writeError(w, http.StatusInternalServerError, protocol.ErrInternal, i18n.T(locale, i18n.MsgInternalError, "secret generation"))
|
|
return
|
|
}
|
|
|
|
newEncryptedSecret, encErr := crypto.Encrypt(raw, h.encKey)
|
|
if encErr != nil {
|
|
slog.Error("webhook.admin.secret_encrypt_failed", "error", encErr)
|
|
writeError(w, http.StatusInternalServerError, protocol.ErrInternal, i18n.T(locale, i18n.MsgInternalError, "secret encryption"))
|
|
return
|
|
}
|
|
|
|
if err := h.webhooks.RotateSecret(ctx, id, newHash, newPrefix, newEncryptedSecret); err != nil {
|
|
slog.Error("webhook.admin.rotate_failed", "error", err, "id", id)
|
|
writeError(w, http.StatusInternalServerError, protocol.ErrInternal, i18n.T(locale, i18n.MsgInternalError, "rotate secret"))
|
|
return
|
|
}
|
|
|
|
slog.Info("webhook.rotated", "id", id, "tenant_id", tenantID, "actor", extractUserID(r))
|
|
|
|
// Invalidate the cache so the middleware picks up the new hash immediately.
|
|
h.emitCacheInvalidate(id.String())
|
|
|
|
writeJSON(w, http.StatusOK, map[string]any{
|
|
"id": id,
|
|
"secret": raw, // new raw secret — shown ONCE; use as HMAC key
|
|
"hmac_signing_key": raw, // same as secret; raw bytes are HMAC key (encrypted at rest)
|
|
"secret_prefix": newPrefix,
|
|
})
|
|
}
|
|
|
|
// --- Revoke ---
|
|
|
|
func (h *WebhooksAdminHandler) handleRevoke(w http.ResponseWriter, r *http.Request) {
|
|
locale := extractLocale(r)
|
|
|
|
if !requireTenantAdmin(w, r, h.tenants) {
|
|
slog.Warn("security.webhook.admin_denied", "action", "revoke", "path", r.URL.Path,
|
|
"user_id", store.UserIDFromContext(r.Context()))
|
|
return
|
|
}
|
|
|
|
id, ok := parseWebhookID(w, r, locale)
|
|
if !ok {
|
|
return
|
|
}
|
|
|
|
ctx := r.Context()
|
|
|
|
// Verify ownership before revoking.
|
|
wh, err := h.webhooks.GetByID(ctx, id)
|
|
if err != nil || wh == nil {
|
|
writeError(w, http.StatusNotFound, protocol.ErrNotFound, i18n.T(locale, i18n.MsgNotFound, "webhook", id.String()))
|
|
return
|
|
}
|
|
tenantID := store.TenantIDFromContext(ctx)
|
|
if !store.IsOwnerRole(ctx) && wh.TenantID != tenantID {
|
|
writeError(w, http.StatusNotFound, protocol.ErrNotFound, i18n.T(locale, i18n.MsgNotFound, "webhook", id.String()))
|
|
return
|
|
}
|
|
|
|
if err := h.webhooks.Revoke(ctx, id); err != nil {
|
|
slog.Error("webhook.admin.revoke_failed", "error", err, "id", id)
|
|
writeError(w, http.StatusNotFound, protocol.ErrNotFound, i18n.T(locale, i18n.MsgNotFound, "webhook", id.String()))
|
|
return
|
|
}
|
|
|
|
slog.Info("webhook.revoked", "id", id, "tenant_id", tenantID, "actor", extractUserID(r))
|
|
|
|
// Invalidate the cache so the middleware rejects the old secret immediately.
|
|
h.emitCacheInvalidate(id.String())
|
|
|
|
writeJSON(w, http.StatusOK, map[string]string{"status": "revoked"})
|
|
}
|
|
|
|
// --- List Calls (delivery history) ---
|
|
|
|
// webhookCallResp is the trimmed delivery-history record returned to the admin UI.
|
|
// Raw request_payload is omitted (may be large / hold caller data); response is the
|
|
// already-truncated (≤32 KB) audit body and is safe to surface for debugging.
|
|
type webhookCallResp struct {
|
|
ID uuid.UUID `json:"id"`
|
|
DeliveryID uuid.UUID `json:"delivery_id"`
|
|
Mode string `json:"mode"`
|
|
Status string `json:"status"`
|
|
Attempts int `json:"attempts"`
|
|
NextAttemptAt *time.Time `json:"next_attempt_at,omitempty"`
|
|
StartedAt *time.Time `json:"started_at,omitempty"`
|
|
CompletedAt *time.Time `json:"completed_at,omitempty"`
|
|
CreatedAt time.Time `json:"created_at"`
|
|
LastError *string `json:"last_error,omitempty"`
|
|
Response string `json:"response,omitempty"`
|
|
}
|
|
|
|
const (
|
|
webhookListDefaultLimit = 20
|
|
webhookListMaxLimit = 200
|
|
)
|
|
|
|
const (
|
|
webhookCallsDefaultLimit = 20
|
|
webhookCallsMaxLimit = 200
|
|
)
|
|
|
|
func (h *WebhooksAdminHandler) handleListCalls(w http.ResponseWriter, r *http.Request) {
|
|
locale := extractLocale(r)
|
|
|
|
if !requireTenantAdmin(w, r, h.tenants) {
|
|
slog.Warn("security.webhook.admin_denied", "action", "list_calls", "path", r.URL.Path,
|
|
"user_id", store.UserIDFromContext(r.Context()))
|
|
return
|
|
}
|
|
|
|
if h.calls == nil {
|
|
writeError(w, http.StatusServiceUnavailable, protocol.ErrInternal, i18n.T(locale, i18n.MsgInternalError, "call store unavailable"))
|
|
return
|
|
}
|
|
|
|
id, ok := parseWebhookID(w, r, locale)
|
|
if !ok {
|
|
return
|
|
}
|
|
|
|
ctx := r.Context()
|
|
|
|
// Verify ownership before exposing call history (same pattern as handleGet).
|
|
wh, err := h.webhooks.GetByID(ctx, id)
|
|
if err != nil || wh == nil {
|
|
writeError(w, http.StatusNotFound, protocol.ErrNotFound, i18n.T(locale, i18n.MsgNotFound, "webhook", id.String()))
|
|
return
|
|
}
|
|
tenantID := store.TenantIDFromContext(ctx)
|
|
if !store.IsOwnerRole(ctx) && wh.TenantID != tenantID {
|
|
writeError(w, http.StatusNotFound, protocol.ErrNotFound, i18n.T(locale, i18n.MsgNotFound, "webhook", id.String()))
|
|
return
|
|
}
|
|
|
|
f := store.WebhookCallListFilter{WebhookID: &id, Limit: webhookCallsDefaultLimit}
|
|
if s := r.URL.Query().Get("status"); s != "" {
|
|
f.Status = s
|
|
}
|
|
if l := r.URL.Query().Get("limit"); l != "" {
|
|
if n, perr := strconv.Atoi(l); perr == nil && n > 0 {
|
|
if n > webhookCallsMaxLimit {
|
|
n = webhookCallsMaxLimit
|
|
}
|
|
f.Limit = n
|
|
}
|
|
}
|
|
if o := r.URL.Query().Get("offset"); o != "" {
|
|
if n, perr := strconv.Atoi(o); perr == nil && n >= 0 {
|
|
f.Offset = n
|
|
}
|
|
}
|
|
|
|
rows, err := h.calls.List(ctx, f)
|
|
if err != nil {
|
|
slog.Error("webhook.admin.list_calls_failed", "error", err, "id", id)
|
|
writeError(w, http.StatusInternalServerError, protocol.ErrInternal, i18n.T(locale, i18n.MsgFailedToList, "webhook calls"))
|
|
return
|
|
}
|
|
total, err := h.calls.Count(ctx, f)
|
|
if err != nil {
|
|
slog.Error("webhook.admin.count_calls_failed", "error", err, "id", id)
|
|
writeError(w, http.StatusInternalServerError, protocol.ErrInternal, i18n.T(locale, i18n.MsgFailedToList, "webhook calls"))
|
|
return
|
|
}
|
|
|
|
out := make([]webhookCallResp, 0, len(rows))
|
|
for i := range rows {
|
|
c := &rows[i]
|
|
out = append(out, webhookCallResp{
|
|
ID: c.ID,
|
|
DeliveryID: c.DeliveryID,
|
|
Mode: c.Mode,
|
|
Status: c.Status,
|
|
Attempts: c.Attempts,
|
|
NextAttemptAt: c.NextAttemptAt,
|
|
StartedAt: c.StartedAt,
|
|
CompletedAt: c.CompletedAt,
|
|
CreatedAt: c.CreatedAt,
|
|
LastError: c.LastError,
|
|
Response: string(c.Response),
|
|
})
|
|
}
|
|
writeJSON(w, http.StatusOK, map[string]any{
|
|
"items": out,
|
|
"total": total,
|
|
"limit": f.Limit,
|
|
"offset": f.Offset,
|
|
})
|
|
}
|
|
|
|
// --- Get Call (single delivery detail) ---
|
|
|
|
// webhookCallDetailResp is the full delivery record for GET /v1/webhooks/{id}/calls/{callId}.
|
|
// Unlike the list DTO it includes request_payload (canonical audit body) and the full response,
|
|
// plus callback_url / idempotency_key — for debugging a single invocation.
|
|
type webhookCallDetailResp struct {
|
|
ID uuid.UUID `json:"id"`
|
|
WebhookID uuid.UUID `json:"webhook_id"`
|
|
AgentID *uuid.UUID `json:"agent_id,omitempty"`
|
|
DeliveryID uuid.UUID `json:"delivery_id"`
|
|
IdempotencyKey *string `json:"idempotency_key,omitempty"`
|
|
Mode string `json:"mode"`
|
|
Status string `json:"status"`
|
|
CallbackURL *string `json:"callback_url,omitempty"`
|
|
Attempts int `json:"attempts"`
|
|
NextAttemptAt *time.Time `json:"next_attempt_at,omitempty"`
|
|
StartedAt *time.Time `json:"started_at,omitempty"`
|
|
CompletedAt *time.Time `json:"completed_at,omitempty"`
|
|
CreatedAt time.Time `json:"created_at"`
|
|
LastError *string `json:"last_error,omitempty"`
|
|
RequestPayload string `json:"request_payload,omitempty"`
|
|
Response string `json:"response,omitempty"`
|
|
}
|
|
|
|
func (h *WebhooksAdminHandler) handleGetCall(w http.ResponseWriter, r *http.Request) {
|
|
locale := extractLocale(r)
|
|
|
|
if !requireTenantAdmin(w, r, h.tenants) {
|
|
slog.Warn("security.webhook.admin_denied", "action", "get_call", "path", r.URL.Path,
|
|
"user_id", store.UserIDFromContext(r.Context()))
|
|
return
|
|
}
|
|
|
|
if h.calls == nil {
|
|
writeError(w, http.StatusServiceUnavailable, protocol.ErrInternal, i18n.T(locale, i18n.MsgInternalError, "call store unavailable"))
|
|
return
|
|
}
|
|
|
|
id, ok := parseWebhookID(w, r, locale)
|
|
if !ok {
|
|
return
|
|
}
|
|
callID, err := uuid.Parse(r.PathValue("callId"))
|
|
if err != nil {
|
|
writeError(w, http.StatusBadRequest, protocol.ErrInvalidRequest, i18n.T(locale, i18n.MsgInvalidID, "call"))
|
|
return
|
|
}
|
|
|
|
ctx := r.Context()
|
|
|
|
// Verify webhook ownership first (same pattern as handleGet/handleListCalls).
|
|
wh, err := h.webhooks.GetByID(ctx, id)
|
|
if err != nil || wh == nil {
|
|
writeError(w, http.StatusNotFound, protocol.ErrNotFound, i18n.T(locale, i18n.MsgNotFound, "webhook", id.String()))
|
|
return
|
|
}
|
|
tenantID := store.TenantIDFromContext(ctx)
|
|
if !store.IsOwnerRole(ctx) && wh.TenantID != tenantID {
|
|
writeError(w, http.StatusNotFound, protocol.ErrNotFound, i18n.T(locale, i18n.MsgNotFound, "webhook", id.String()))
|
|
return
|
|
}
|
|
|
|
// GetByID is tenant-scoped; also verify the call belongs to THIS webhook.
|
|
c, err := h.calls.GetByID(ctx, callID)
|
|
if err != nil || c == nil || c.WebhookID != id {
|
|
writeError(w, http.StatusNotFound, protocol.ErrNotFound, i18n.T(locale, i18n.MsgNotFound, "webhook call", callID.String()))
|
|
return
|
|
}
|
|
if !store.IsOwnerRole(ctx) && c.TenantID != tenantID {
|
|
writeError(w, http.StatusNotFound, protocol.ErrNotFound, i18n.T(locale, i18n.MsgNotFound, "webhook call", callID.String()))
|
|
return
|
|
}
|
|
|
|
writeJSON(w, http.StatusOK, webhookCallDetailResp{
|
|
ID: c.ID,
|
|
WebhookID: c.WebhookID,
|
|
AgentID: c.AgentID,
|
|
DeliveryID: c.DeliveryID,
|
|
IdempotencyKey: c.IdempotencyKey,
|
|
Mode: c.Mode,
|
|
Status: c.Status,
|
|
CallbackURL: c.CallbackURL,
|
|
Attempts: c.Attempts,
|
|
NextAttemptAt: c.NextAttemptAt,
|
|
StartedAt: c.StartedAt,
|
|
CompletedAt: c.CompletedAt,
|
|
CreatedAt: c.CreatedAt,
|
|
LastError: c.LastError,
|
|
RequestPayload: string(c.RequestPayload),
|
|
Response: string(c.Response),
|
|
})
|
|
}
|
|
|
|
// --- Test (server-side invocation) ---
|
|
|
|
// testWebhookReq is the request body for POST /v1/webhooks/{id}/test.
|
|
// For kind=llm: input (+ optional model). For kind=message: channel_name/chat_id/content/media_*.
|
|
type testWebhookReq struct {
|
|
// llm fields
|
|
Input string `json:"input,omitempty"`
|
|
Model string `json:"model,omitempty"`
|
|
// message fields
|
|
ChannelName string `json:"channel_name,omitempty"`
|
|
ChatID string `json:"chat_id,omitempty"`
|
|
Content string `json:"content,omitempty"`
|
|
MediaURL string `json:"media_url,omitempty"`
|
|
MediaCaption string `json:"media_caption,omitempty"`
|
|
FallbackToText bool `json:"fallback_to_text,omitempty"`
|
|
}
|
|
|
|
func (h *WebhooksAdminHandler) handleTest(w http.ResponseWriter, r *http.Request) {
|
|
locale := extractLocale(r)
|
|
|
|
if !requireTenantAdmin(w, r, h.tenants) {
|
|
slog.Warn("security.webhook.admin_denied", "action", "test", "path", r.URL.Path,
|
|
"user_id", store.UserIDFromContext(r.Context()))
|
|
return
|
|
}
|
|
|
|
id, ok := parseWebhookID(w, r, locale)
|
|
if !ok {
|
|
return
|
|
}
|
|
|
|
ctx := r.Context()
|
|
|
|
// Verify ownership before invoking.
|
|
wh, err := h.webhooks.GetByID(ctx, id)
|
|
if err != nil || wh == nil {
|
|
writeError(w, http.StatusNotFound, protocol.ErrNotFound, i18n.T(locale, i18n.MsgNotFound, "webhook", id.String()))
|
|
return
|
|
}
|
|
tenantID := store.TenantIDFromContext(ctx)
|
|
if !store.IsOwnerRole(ctx) && wh.TenantID != tenantID {
|
|
writeError(w, http.StatusNotFound, protocol.ErrNotFound, i18n.T(locale, i18n.MsgNotFound, "webhook", id.String()))
|
|
return
|
|
}
|
|
if wh.Revoked {
|
|
writeError(w, http.StatusConflict, protocol.ErrInvalidRequest, i18n.T(locale, i18n.MsgWebhookRevoked))
|
|
return
|
|
}
|
|
|
|
var req testWebhookReq
|
|
if !bindJSON(w, r, locale, &req) {
|
|
return
|
|
}
|
|
|
|
switch wh.Kind {
|
|
case "llm":
|
|
if h.llmTester == nil {
|
|
writeError(w, http.StatusServiceUnavailable, protocol.ErrInternal, i18n.T(locale, i18n.MsgInternalError, "llm tester unavailable"))
|
|
return
|
|
}
|
|
if wh.AgentID == nil {
|
|
writeError(w, http.StatusBadRequest, protocol.ErrInvalidRequest, i18n.T(locale, i18n.MsgWebhookAgentNotFound))
|
|
return
|
|
}
|
|
if req.Input == "" {
|
|
writeError(w, http.StatusBadRequest, protocol.ErrInvalidRequest, i18n.T(locale, i18n.MsgRequired, "input"))
|
|
return
|
|
}
|
|
resp, runErr := h.llmTester.RunTest(ctx, wh, req.Input, req.Model)
|
|
if runErr != nil {
|
|
slog.Warn("webhook.admin.test_failed", "id", id, "kind", "llm", "error", runErr)
|
|
writeError(w, http.StatusBadGateway, protocol.ErrInternal, i18n.T(locale, i18n.MsgInternalError, runErr.Error()))
|
|
return
|
|
}
|
|
slog.Info("webhook.tested", "id", id, "tenant_id", tenantID, "kind", "llm", "actor", extractUserID(r))
|
|
writeJSON(w, http.StatusOK, resp)
|
|
case "message":
|
|
if h.msgTester == nil {
|
|
writeError(w, http.StatusForbidden, protocol.ErrUnauthorized, i18n.T(locale, i18n.MsgWebhookMessageTestRequiresStandard))
|
|
return
|
|
}
|
|
if wh.ChannelID == nil && req.ChannelName == "" {
|
|
writeError(w, http.StatusBadRequest, protocol.ErrInvalidRequest, i18n.T(locale, i18n.MsgRequired, "channel_name"))
|
|
return
|
|
}
|
|
if req.ChatID == "" {
|
|
writeError(w, http.StatusBadRequest, protocol.ErrInvalidRequest, i18n.T(locale, i18n.MsgRequired, "chat_id"))
|
|
return
|
|
}
|
|
if req.Content == "" && req.MediaURL == "" {
|
|
writeError(w, http.StatusBadRequest, protocol.ErrInvalidRequest, i18n.T(locale, i18n.MsgRequired, "content"))
|
|
return
|
|
}
|
|
if len(req.Content) > webhookContentMaxBytes {
|
|
writeError(w, http.StatusBadRequest, protocol.ErrInvalidRequest, i18n.T(locale, i18n.MsgInvalidRequest, "content exceeds 16 KB limit"))
|
|
return
|
|
}
|
|
resp, runErr := h.msgTester.RunTest(ctx, wh, webhookMessageReq{
|
|
ChannelName: req.ChannelName,
|
|
ChatID: req.ChatID,
|
|
Content: req.Content,
|
|
MediaURL: req.MediaURL,
|
|
MediaCaption: req.MediaCaption,
|
|
FallbackToText: req.FallbackToText,
|
|
})
|
|
if runErr != nil {
|
|
slog.Warn("webhook.admin.test_failed", "id", id, "kind", "message", "error", runErr)
|
|
writeError(w, http.StatusBadGateway, protocol.ErrInternal, i18n.T(locale, i18n.MsgInternalError, runErr.Error()))
|
|
return
|
|
}
|
|
slog.Info("webhook.tested", "id", id, "tenant_id", tenantID, "kind", "message", "actor", extractUserID(r))
|
|
writeJSON(w, http.StatusOK, resp)
|
|
default:
|
|
writeError(w, http.StatusBadRequest, protocol.ErrInvalidRequest, i18n.T(locale, i18n.MsgInvalidRequest, "unknown webhook kind"))
|
|
}
|
|
}
|
|
|
|
// --- Helpers ---
|
|
|
|
// generateWebhookSecret creates a new webhook secret in format "wh_<base32(24 bytes)>".
|
|
// Returns (rawSecret, secretHash, secretPrefix, error).
|
|
// secretPrefix = first 8 chars of rawSecret (includes "wh_" + start of base32).
|
|
// secretHash = hex(SHA-256(rawSecret)) — stored in DB, used as HMAC signing key.
|
|
func generateWebhookSecret() (raw, secretHash, secretPrefix string, err error) {
|
|
b := make([]byte, 24)
|
|
if _, err = rand.Read(b); err != nil {
|
|
return "", "", "", err
|
|
}
|
|
// base32 (no padding) produces 40 chars for 24 bytes.
|
|
encoded := base32.StdEncoding.WithPadding(base32.NoPadding).EncodeToString(b)
|
|
raw = "wh_" + encoded // total 43 chars
|
|
|
|
h := sha256.Sum256([]byte(raw))
|
|
secretHash = hex.EncodeToString(h[:])
|
|
|
|
// First 8 chars of the full raw secret (includes "wh_" + first 5 base32 chars).
|
|
secretPrefix = raw[:8]
|
|
return raw, secretHash, secretPrefix, nil
|
|
}
|
|
|
|
// parseWebhookID parses the {id} path value, writing a 400 on error.
|
|
func parseWebhookID(w http.ResponseWriter, r *http.Request, locale string) (uuid.UUID, bool) {
|
|
idStr := r.PathValue("id")
|
|
id, err := uuid.Parse(idStr)
|
|
if err != nil {
|
|
writeError(w, http.StatusBadRequest, protocol.ErrInvalidRequest, i18n.T(locale, i18n.MsgInvalidID, "webhook"))
|
|
return uuid.Nil, false
|
|
}
|
|
return id, true
|
|
}
|
|
|
|
// emitCacheInvalidate broadcasts a cache invalidation event for webhook secrets.
|
|
// This signals the WebhookAuthMiddleware (phase 03) to drop cached entries.
|
|
func (h *WebhooksAdminHandler) emitCacheInvalidate(webhookID string) {
|
|
if h.msgBus == nil {
|
|
return
|
|
}
|
|
h.msgBus.Broadcast(bus.Event{
|
|
Name: protocol.EventCacheInvalidate,
|
|
Payload: bus.CacheInvalidatePayload{Kind: "webhooks", Key: webhookID},
|
|
})
|
|
}
|