internal/webhooks/webhook_service_registration.go
Rama main · réplica de lectura del repositorio original.
package webhooks
import (
"context"
"fmt"
neturl "net/url"
"slices"
"strings"
"time"
"github.com/google/uuid"
"github.com/lib/pq"
"go.opentelemetry.io/otel/attribute"
otelcodes "go.opentelemetry.io/otel/codes"
"go.opentelemetry.io/otel/trace"
svcerrors "github.com/sarathsp06/sparrow/internal/errors"
"github.com/sarathsp06/sparrow/internal/storage"
"github.com/sarathsp06/sparrow/internal/tenant"
"github.com/sarathsp06/sparrow/internal/webhooks/client"
"github.com/sarathsp06/sparrow/internal/webhooks/store"
)
// setWebhookActive is the shared implementation of PauseWebhook and ResumeWebhook.
// It loads the webhook, checks whether a state transition is needed, and persists the change.
func (s *WebhookService) setWebhookActive(ctx context.Context, webhookID string, consumer string, active bool) error {
if webhookID == "" {
return svcerrors.Error(svcerrors.InvalidArgument, "webhook ID is required")
}
if consumer == "" {
return svcerrors.Error(svcerrors.InvalidArgument, "consumer is required")
}
tenantID := tenant.FromContext(ctx)
id, err := parseUUID(webhookID, "webhook ID")
if err != nil {
return err
}
webhook, err := s.webhookRepo.GetWebhookByID(ctx, tenantID, id, consumer)
if err != nil {
s.logger.ErrorContext(ctx, "Failed to get webhook", "error", err)
return fmt.Errorf("failed to retrieve webhook: %w", err)
}
if webhook.Active == active {
if active {
return svcerrors.Error(svcerrors.FailedPrecondition, "webhook is already active")
}
return svcerrors.Error(svcerrors.FailedPrecondition, "webhook is already paused")
}
webhook.Active = active
webhook.UpdatedAt = time.Now()
if err := s.webhookRepo.UpdateWebhook(ctx, tenantID, webhook); err != nil {
action := "pause"
if active {
action = "resume"
}
s.logger.ErrorContext(ctx, "Failed to "+action+" webhook", "error", err)
return fmt.Errorf("failed to %s webhook: %w", action, err)
}
action := "paused"
if active {
action = "resumed"
}
s.logger.InfoContext(ctx, "Webhook "+action+" successfully", "webhook_id", webhookID)
return nil
}
// CreateWebhook creates a webhook registration with HTTP configuration support.
// It returns the registration as stored and the plaintext signing secret,
// which callers show once: the stored registration only has it encrypted.
func (s *WebhookService) CreateWebhook(ctx context.Context, req WebhookRegistrationRequest) (*store.WebhookRegistration, string, error) {
redactedURL := client.RedactURL(req.URL)
span := trace.SpanFromContext(ctx)
span.SetAttributes(
attribute.String("consumer", req.Consumer),
attribute.StringSlice("events", req.Events),
attribute.String("url", redactedURL),
)
s.logger.InfoContext(ctx, "Processing enhanced webhook creation request",
"consumer", req.Consumer,
"events", req.Events,
"url", redactedURL,
)
tenantID := tenant.FromContext(ctx)
if req.Consumer == "" {
return nil, "", svcerrors.Error(svcerrors.InvalidArgument, "consumer is required")
}
// Validate webhook URL against SSRF
if err := ValidateWebhookURL(req.URL, s.networkPolicy); err != nil {
return nil, "", err
}
if err := validateHeaders("headers", stringHeaders(req.Headers)); err != nil {
return nil, "", err
}
secretHeaders, err := canonicalHeaders("secret_headers", req.SecretHeaders)
if err != nil {
return nil, "", err
}
if err := validateHeaders("secret_headers", secretHeaders); err != nil {
return nil, "", err
}
req.SecretHeaders = secretHeaders
if err := req.TemplateSettings.validate(); err != nil {
return nil, "", err
}
if err := validateTransformTemplate(req.TransformTemplate); err != nil {
return nil, "", err
}
if req.RequiresTransform && len(req.Events) > 0 && strings.TrimSpace(req.TransformTemplate) == "" {
return nil, "", svcerrors.Error(svcerrors.InvalidArgument, "requires_transform is set, so transform_template is required for the subscriptions created from events (or register without events and add each subscription with its own template)")
}
// Convert request to internal webhook registration
webhookReg, err := req.ToWebhookRegistration()
if err != nil {
return nil, "", svcerrors.Wrapf(err, svcerrors.InvalidArgument, "invalid webhook configuration: %v", err)
}
// Generate ID if not provided
if webhookReg.ID == "" {
webhookReg.ID = uuid.New().String()
}
// Validate event names
for _, event := range req.Events {
if event == "" {
return nil, "", svcerrors.Error(svcerrors.InvalidArgument, "empty event name not allowed")
}
if err := checkEventForConsumer(req.Consumer, event); err != nil {
return nil, "", err
}
}
webhookID, err := parseUUID(webhookReg.ID, "webhook ID")
if err != nil {
return nil, "", err
}
// Convert internal webhook to store model for database operation
storeWebhook := &store.WebhookRegistration{
ID: webhookID,
Consumer: webhookReg.Consumer,
URL: webhookReg.URL,
Timeout: webhookReg.HTTPConfig.RequestTimeoutSeconds,
Active: webhookReg.Active,
Description: webhookReg.Description,
Health: store.WebhookHealth(webhookReg.Health),
MaxRetries: DerefIntOr(webhookReg.HTTPConfig.MaxRetries, DefaultMaxRetries),
RetryBackoffSeconds: webhookReg.HTTPConfig.RetryBackoffSeconds,
CaptureResponseBody: DerefBoolOr(webhookReg.HTTPConfig.CaptureResponseBody, false),
FollowRedirects: DerefBoolOr(webhookReg.HTTPConfig.FollowRedirects, true),
VerifySSL: DerefBoolOr(webhookReg.HTTPConfig.VerifySSL, true),
RequestTimeoutSeconds: webhookReg.HTTPConfig.RequestTimeoutSeconds,
UserAgent: webhookReg.HTTPConfig.UserAgent,
ContentType: webhookReg.HTTPConfig.ContentType,
RateLimitRPS: webhookReg.HTTPConfig.RateLimitRPS,
RequiresTransform: req.RequiresTransform,
CreatedAt: time.Now(),
UpdatedAt: time.Now(),
}
// Set signature type, defaulting to HMAC
sigType := store.SignatureType(webhookReg.SignatureType)
if sigType != store.SignatureTypeEd25519 {
sigType = store.SignatureTypeHMAC
}
storeWebhook.SignatureType = sigType
// Convert headers to string map for store model
headersMap := make(map[string]string)
for k, v := range webhookReg.Headers {
if str, ok := v.(string); ok {
headersMap[k] = str
}
}
storeWebhook.Headers = headersMap
// Encrypt webhook secret if provided, or generate one if missing
if webhookReg.HTTPConfig.WebhookSecret != "" {
encSecret, err := s.EncryptWebhookSecret(webhookReg.HTTPConfig.WebhookSecret)
if err != nil {
return nil, "", fmt.Errorf("failed to encrypt webhook secret: %w", err)
}
storeWebhook.WebhookSecret = encSecret
} else {
// Auto-generate a secret if missing to ensure all webhooks are signed
secret, err := generateWebhookSecret()
if err != nil {
return nil, "", fmt.Errorf("failed to generate webhook secret: %w", err)
}
encSecret, err := s.EncryptWebhookSecret(secret)
if err != nil {
return nil, "", fmt.Errorf("failed to encrypt generated webhook secret: %w", err)
}
storeWebhook.WebhookSecret = encSecret
webhookReg.HTTPConfig.WebhookSecret = secret // returned in plaintext once
}
// Generate Ed25519 keypair only when signature_type is "ed25519".
// The private key is envelope-encrypted and stored; the public key is derived at runtime.
if sigType == store.SignatureTypeEd25519 {
encPrivKey, err := s.generateEncryptedEd25519Key()
if err != nil {
return nil, "", err
}
storeWebhook.Ed25519PrivateKey = encPrivKey
}
// Encrypt secret headers if provided
if len(req.SecretHeaders) > 0 {
encrypted, err := s.EncryptSecretHeaders(req.SecretHeaders)
if err != nil {
return nil, "", fmt.Errorf("failed to encrypt secret headers: %w", err)
}
storeWebhook.SecretHeaders = encrypted
}
// Convert expected status codes
expectedCodes := make(pq.Int64Array, len(webhookReg.HTTPConfig.ExpectedStatusCodes))
for i, code := range webhookReg.HTTPConfig.ExpectedStatusCodes {
expectedCodes[i] = int64(code)
}
storeWebhook.ExpectedStatusCodes = expectedCodes
// Build subscriptions slice for atomic creation
var subscriptions []*store.EventSubscription
for _, event := range req.Events {
subscriptions = append(subscriptions, &store.EventSubscription{
EventName: event,
Consumer: req.Consumer,
TransformEnabled: strings.TrimSpace(req.TransformTemplate) != "",
TransformTemplate: req.TransformTemplate,
OnTransformError: req.TemplateSettings.OnTransformError,
TemplateMissingKey: req.TemplateSettings.TemplateMissingKey,
})
}
// Atomically register the webhook and all subscriptions in a single transaction
firstVersion := store.SubscriptionTemplateVersion{Source: req.TemplateMeta.Source, Notes: req.TemplateMeta.Notes, SavedBy: req.TemplateMeta.SavedBy}
if err := s.webhookRepo.RegisterWebhookWithSubscriptions(ctx, tenantID, storeWebhook, subscriptions, firstVersion); err != nil {
s.logger.ErrorContext(ctx, "Failed to register webhook",
"consumer", req.Consumer,
"events", req.Events,
"url", client.RedactURL(req.URL),
"error", err,
)
span.RecordError(err)
span.SetStatus(otelcodes.Error, "failed to register webhook")
return nil, "", fmt.Errorf("failed to register webhook: %w", err)
}
// Initialize rate limit state if rate limiting is configured
if storeWebhook.RateLimitRPS != nil {
if err := s.webhookRepo.UpsertRateLimitState(ctx, tenantID, webhookID); err != nil {
s.logger.WarnContext(ctx, "Failed to initialize rate limit state",
"webhook_id", webhookReg.ID,
"error", err,
)
// Non-fatal: the rate limit row will be created on first delivery slot acquisition
}
}
// Update metrics
if s.metrics != nil {
s.metrics.WebhookRegistrations.Add(ctx, 1)
}
s.logger.InfoContext(ctx, "Enhanced webhook registered successfully",
"webhook_id", webhookReg.ID,
"consumer", req.Consumer,
"events", req.Events,
"url", client.RedactURL(req.URL),
"http_config_provided", req.HTTPConfig != nil,
)
span.SetStatus(otelcodes.Ok, "webhook created successfully")
return storeWebhook, webhookReg.HTTPConfig.WebhookSecret, nil
}
// UnregisterWebhook removes a webhook registration
func (s *WebhookService) UnregisterWebhook(ctx context.Context, webhookID string, consumer string) error {
s.logger.InfoContext(ctx, "Processing webhook un registration request",
"webhook_id", webhookID,
"consumer", consumer,
)
if webhookID == "" {
return svcerrors.Error(svcerrors.InvalidArgument, "webhook_id is required")
}
if consumer == "" {
return svcerrors.Error(svcerrors.InvalidArgument, "consumer is required")
}
tenantID := tenant.FromContext(ctx)
id, err := parseUUID(webhookID, "webhook ID")
if err != nil {
return err
}
// Check if webhook exists in consumer
_, err = s.webhookRepo.GetWebhookByID(ctx, tenantID, id, consumer)
if err != nil {
return fmt.Errorf("failed to retrieve webhook: %w", err)
}
if err := s.webhookRepo.UnregisterWebhook(ctx, tenantID, id); err != nil {
s.logger.ErrorContext(ctx, "Failed to unregister webhook",
"webhook_id", webhookID,
"error", err,
)
return fmt.Errorf("failed to unregister webhook: %w", err)
}
s.logger.InfoContext(ctx, "Webhook unregistered successfully",
"webhook_id", webhookID,
)
return nil
}
// ListWebhooks lists all registered webhooks with optional consumer and other filters.
// When consumer is empty, returns webhooks across all consumers.
// When health is non-empty, only webhooks with that health status are returned.
func (s *WebhookService) ListWebhooks(ctx context.Context, consumer string, webhookID string, event string, activeOnly bool, health string, limit, offset int32) ([]*store.WebhookRegistration, int32, error) {
s.logger.InfoContext(ctx, "Processing list webhooks request",
"consumer", consumer,
"webhook_id", webhookID,
"event", event,
"active_only", activeOnly,
"limit", limit,
"offset", offset,
)
tenantID := tenant.FromContext(ctx)
l, o := normalizePagination(int(limit), int(offset))
limit, offset = clampInt32(l), clampInt32(o)
if webhookID != "" {
id, err := parseUUID(webhookID, "webhook ID")
if err != nil {
return nil, 0, err
}
reg, err := s.webhookRepo.GetWebhookByID(ctx, tenantID, id, consumer)
if err != nil {
if storage.IsNotFound(err) {
return []*store.WebhookRegistration{}, 0, nil
}
return nil, 0, fmt.Errorf("failed to retrieve webhook: %w", err)
}
if activeOnly && !reg.Active {
return []*store.WebhookRegistration{}, 0, nil
}
if health != "" && string(reg.Health) != health {
return []*store.WebhookRegistration{}, 0, nil
}
return []*store.WebhookRegistration{reg}, 1, nil
}
registrations, totalCount, err := s.webhookRepo.ListWebhooksPaginated(ctx, tenantID, consumer, event, activeOnly, store.WebhookHealth(health), int(limit), int(offset))
if err != nil {
s.logger.ErrorContext(ctx, "Failed to list webhooks",
"consumer", consumer,
"error", err,
)
return nil, 0, fmt.Errorf("failed to list webhooks: %w", err)
}
s.logger.InfoContext(ctx, "Listed webhooks successfully",
"consumer", consumer,
"count", len(registrations),
"total", totalCount,
)
return registrations, clampInt32(totalCount), nil
}
// PauseWebhook temporarily disables webhook deliveries
func (s *WebhookService) PauseWebhook(ctx context.Context, webhookID string, consumer string, reason string) error {
s.logger.InfoContext(ctx, "Pausing webhook", "webhook_id", webhookID, "consumer", consumer, "reason", reason)
return s.setWebhookActive(ctx, webhookID, consumer, false)
}
// WebhookResumeResult describes a resumed webhook.
type WebhookResumeResult struct {
// PausedDeliveries counts the webhook's deliveries held as paused. They
// are not sent automatically; the caller decides what to retry.
PausedDeliveries int
}
// ResumeWebhook re-enables webhook deliveries, whether it was paused by an
// operator or auto-disabled. Deliveries held while it was paused stay paused
// until retried, like those of a resumed subscription.
func (s *WebhookService) ResumeWebhook(ctx context.Context, webhookID string, consumer string) (*WebhookResumeResult, error) {
s.logger.InfoContext(ctx, "Resuming webhook", "webhook_id", webhookID, "consumer", consumer)
if err := s.setWebhookActive(ctx, webhookID, consumer, true); err != nil {
return nil, err
}
id, err := parseUUID(webhookID, "webhook ID")
if err != nil {
return nil, err
}
status := string(store.StatusPaused)
count, err := s.webhookRepo.CountDeliveries(ctx, tenant.FromContext(ctx), store.DeliveryFilter{
Consumer: consumer,
WebhookID: &id,
Status: &status,
})
if err != nil {
return nil, fmt.Errorf("failed to count paused deliveries: %w", err)
}
return &WebhookResumeResult{PausedDeliveries: count}, nil
}
// UpdateWebhookConfig updates webhook configuration.
// When updateMask is non-empty, only the listed field paths are applied.
// When updateMask is empty, falls back to legacy behavior (all non-zero fields applied).
//
// Supported mask paths:
//
// "url", "active", "description", "events", "headers", "secret_headers",
// "signature_type", "http_config", "http_config.webhook_secret",
// "http_config.rate_limit_rps", "requires_transform"
//
// requires_transform is only changed when named in the mask. Turning it on
// fails while any subscription lacks a transform, and a webhook that requires
// one cannot have its events replaced in bulk (that recreates subscriptions
// without templates).
func (s *WebhookService) UpdateWebhookConfig(ctx context.Context, webhookID string, consumer string, events []string, url string, headers map[string]string, active bool, description string, httpConfig *WebhookHTTPConfig, secretHeaders map[string]string, signatureType string, requiresTransform bool, updateMask []string) error {
s.logger.InfoContext(ctx, "Processing update webhook config request",
"webhook_id", webhookID,
"consumer", consumer,
"update_mask", updateMask)
if webhookID == "" {
return svcerrors.Error(svcerrors.InvalidArgument, "webhook ID is required")
}
if consumer == "" {
return svcerrors.Error(svcerrors.InvalidArgument, "consumer is required")
}
tenantID := tenant.FromContext(ctx)
webhookUUID, err := parseUUID(webhookID, "webhook ID")
if err != nil {
return err
}
webhook, err := s.webhookRepo.GetWebhookByID(ctx, tenantID, webhookUUID, consumer)
if err != nil {
s.logger.ErrorContext(ctx, "Failed to get webhook", "error", err)
return fmt.Errorf("failed to retrieve webhook: %w", err)
}
oldURL := webhook.URL
oldSecretHeaders := webhook.SecretHeaders
// Build a set for O(1) lookup. When empty, all non-zero fields are applied (legacy).
mask := make(map[string]bool, len(updateMask))
for _, p := range updateMask {
mask[p] = true
}
useMask := len(mask) > 0
shouldUpdate := func(field string) bool {
if !useMask {
return true // legacy: apply everything
}
return mask[field]
}
// Build new subscription list (pure logic, no DB calls)
var newSubs []*store.EventSubscription
replaceEvents := shouldUpdate("events") && len(events) > 0
if replaceEvents {
for _, event := range events {
if err := checkEventForConsumer(consumer, event); err != nil {
return err
}
newSubs = append(newSubs, &store.EventSubscription{
EventName: event,
})
}
}
// Apply config mutations to the in-memory webhook (pure logic, no DB calls)
if shouldUpdate("url") && url != "" {
normalizedURL := strings.TrimSpace(url)
if normalizedURL == "" {
return svcerrors.Error(svcerrors.InvalidArgument, "URL is required")
}
if err := ValidateWebhookURL(normalizedURL, s.networkPolicy); err != nil {
return err
}
webhook.URL = normalizedURL
}
if shouldUpdate("headers") && headers != nil {
if err := validateHeaders("headers", headers); err != nil {
return err
}
webhook.Headers = headers
}
if shouldUpdate("active") {
webhook.Active = active
}
if shouldUpdate("description") && description != "" {
webhook.Description = description
}
// Apply HTTP config updates if provided
if shouldUpdate("http_config") && httpConfig != nil {
if httpConfig.MaxRetries != nil {
webhook.MaxRetries = *httpConfig.MaxRetries
}
if httpConfig.RetryBackoffSeconds > 0 {
webhook.RetryBackoffSeconds = httpConfig.RetryBackoffSeconds
}
if httpConfig.RequestTimeoutSeconds > 0 {
webhook.RequestTimeoutSeconds = httpConfig.RequestTimeoutSeconds
}
if len(httpConfig.ExpectedStatusCodes) > 0 {
int64Codes := make([]int64, len(httpConfig.ExpectedStatusCodes))
for i, c := range httpConfig.ExpectedStatusCodes {
int64Codes[i] = int64(c)
}
webhook.ExpectedStatusCodes = int64Codes
}
// Only update the webhook secret if explicitly requested via mask.
// Without mask (legacy mode), non-empty secret is applied.
// With mask, "http_config.webhook_secret" must be in the mask.
updateSecret := false
if useMask {
updateSecret = mask["http_config.webhook_secret"]
} else {
updateSecret = httpConfig.WebhookSecret != ""
}
if updateSecret && httpConfig.WebhookSecret != "" {
encSecret, err := s.EncryptWebhookSecret(httpConfig.WebhookSecret)
if err != nil {
return fmt.Errorf("failed to encrypt webhook secret: %w", err)
}
webhook.WebhookSecret = encSecret
}
if httpConfig.UserAgent != "" {
webhook.UserAgent = httpConfig.UserAgent
}
if httpConfig.ContentType != "" {
webhook.ContentType = httpConfig.ContentType
}
if httpConfig.CaptureResponseBody != nil {
webhook.CaptureResponseBody = *httpConfig.CaptureResponseBody
}
if httpConfig.FollowRedirects != nil {
webhook.FollowRedirects = *httpConfig.FollowRedirects
}
if httpConfig.VerifySSL != nil {
webhook.VerifySSL = *httpConfig.VerifySSL
}
// RateLimitRPS: pointer field — nil means "not provided" (keep existing),
// non-nil overrides. With mask, "http_config.rate_limit_rps" must be in the mask.
updateRateLimit := false
if useMask {
updateRateLimit = mask["http_config.rate_limit_rps"]
} else {
updateRateLimit = httpConfig.RateLimitRPS != nil
}
if updateRateLimit {
webhook.RateLimitRPS = httpConfig.RateLimitRPS
}
// Enforce the same bounds the create path enforces: validate the
// merged result, not just the delta (e.g. request_timeout_seconds
// must stay within 1-300 after a PATCH).
codes := make(IntArray, len(webhook.ExpectedStatusCodes))
for i, code := range webhook.ExpectedStatusCodes {
codes[i] = int(code)
}
merged := WebhookHTTPConfig{
MaxRetries: &webhook.MaxRetries,
RetryBackoffSeconds: webhook.RetryBackoffSeconds,
RequestTimeoutSeconds: webhook.RequestTimeoutSeconds,
ExpectedStatusCodes: codes,
ContentType: webhook.ContentType,
RateLimitRPS: webhook.RateLimitRPS,
}
if err := merged.ValidateConfig(); err != nil {
return err
}
}
// Merge secret header updates into the existing encrypted map.
// Empty-string values remove a header; omitted keys stay untouched.
if shouldUpdate("secret_headers") {
if secretHeaders, err = canonicalHeaders("secret_headers", secretHeaders); err != nil {
return err
}
if err := validateHeaders("secret_headers", secretHeaders); err != nil {
return err
}
mergedSecretHeaders, err := s.DecryptSecretHeaders(webhook.SecretHeaders)
if err != nil {
return fmt.Errorf("failed to decrypt existing secret headers: %w", err)
}
mergedSecretHeaders = canonicalNames(mergedSecretHeaders)
if len(secretHeaders) == 0 {
webhook.SecretHeaders = nil
} else {
if mergedSecretHeaders == nil {
mergedSecretHeaders = map[string]string{}
}
for key, value := range secretHeaders {
if value == "" {
delete(mergedSecretHeaders, key)
continue
}
mergedSecretHeaders[key] = value
}
encrypted, err := s.EncryptSecretHeaders(mergedSecretHeaders)
if err != nil {
return fmt.Errorf("failed to encrypt secret headers: %w", err)
}
webhook.SecretHeaders = encrypted
}
}
if err := s.checkSecretHeadersFollowURL(oldURL, webhook.URL, oldSecretHeaders, secretHeaders, shouldUpdate("secret_headers")); err != nil {
return err
}
// Update signature_type if in mask (or legacy non-empty)
if shouldUpdate("signature_type") && signatureType != "" {
newSigType := store.SignatureType(signatureType)
if newSigType != store.SignatureTypeHMAC && newSigType != store.SignatureTypeEd25519 {
return svcerrors.Errorf(svcerrors.InvalidArgument, "invalid signature_type: %q (must be \"hmac\" or \"ed25519\")", signatureType)
}
oldSigType := webhook.SignatureType
webhook.SignatureType = newSigType
// Generate Ed25519 keypair when switching to ed25519
if newSigType == store.SignatureTypeEd25519 && oldSigType != store.SignatureTypeEd25519 {
encPrivKey, err := s.generateEncryptedEd25519Key()
if err != nil {
return err
}
webhook.Ed25519PrivateKey = encPrivKey
}
// Clear Ed25519 key when switching away from ed25519
if newSigType == store.SignatureTypeHMAC && oldSigType == store.SignatureTypeEd25519 {
webhook.Ed25519PrivateKey = nil
}
}
// Turning requires_transform on is checked inside the transaction, under
// an exclusive lock that subscription writes (shared lock) wait on.
turningOnTransform := mask["requires_transform"] && requiresTransform && !webhook.RequiresTransform
if mask["requires_transform"] {
webhook.RequiresTransform = requiresTransform
}
if replaceEvents && webhook.RequiresTransform {
return svcerrors.Error(svcerrors.FailedPrecondition,
"this webhook requires a payload transform, so its events cannot be replaced in bulk (that recreates subscriptions without templates); add or delete subscriptions one by one instead")
}
// Persist subscription replacement + webhook update atomically.
err = s.webhookRepo.RunInTransaction(func(txRepo store.RepositoryInterface) error {
if turningOnTransform {
if _, err := txRepo.LockWebhook(ctx, tenantID, webhookUUID, consumer, true); err != nil {
return fmt.Errorf("failed to lock webhook: %w", err)
}
subs, err := txRepo.ListSubscriptions(ctx, tenantID, webhookUUID)
if err != nil {
return fmt.Errorf("failed to list subscriptions: %w", err)
}
var missing []string
for _, sub := range subs {
if !sub.HasTransform() {
missing = append(missing, sub.EventName)
}
}
if len(missing) > 0 {
return svcerrors.Errorf(svcerrors.FailedPrecondition,
"cannot require a payload transform: the subscriptions for %s have none; add a transform_template to each first", strings.Join(missing, ", "))
}
}
if replaceEvents {
if err := txRepo.ReplaceWebhookSubscriptions(ctx, tenantID, webhookUUID, consumer, newSubs); err != nil {
return fmt.Errorf("failed to update webhook subscriptions: %w", err)
}
}
return txRepo.UpdateWebhook(ctx, tenantID, webhook)
})
if err != nil {
s.logger.ErrorContext(ctx, "Failed to update webhook config",
"webhook_id", webhookID,
"error", err)
return fmt.Errorf("failed to update webhook configuration: %w", err)
}
// Manage rate limit state: upsert when rate limit is set, delete when cleared
if webhook.RateLimitRPS != nil {
if err := s.webhookRepo.UpsertRateLimitState(ctx, tenantID, webhookUUID); err != nil {
s.logger.WarnContext(ctx, "Failed to upsert rate limit state",
"webhook_id", webhookID,
"error", err,
)
}
} else {
// Rate limit removed (or was never set) — clean up any existing state
if err := s.webhookRepo.DeleteRateLimitState(ctx, tenantID, webhookUUID); err != nil {
s.logger.WarnContext(ctx, "Failed to delete rate limit state",
"webhook_id", webhookID,
"error", err,
)
}
}
s.logger.InfoContext(ctx, "Webhook configuration updated successfully",
"webhook_id", webhookID)
return nil
}
// RotateWebhookSecret replaces a webhook's HMAC signing secret with a freshly
// generated one and returns it in plaintext. This is the only time the new
// secret is readable; every later read masks it. Deliveries signed after the
// rotation use the new secret, so receivers must be updated with it.
func (s *WebhookService) RotateWebhookSecret(ctx context.Context, webhookID string, consumer string) (string, error) {
if consumer == "" {
return "", svcerrors.Error(svcerrors.InvalidArgument, "consumer is required")
}
webhookUUID, err := parseUUID(webhookID, "webhook ID")
if err != nil {
return "", err
}
webhook, err := s.webhookRepo.GetWebhookByID(ctx, tenant.FromContext(ctx), webhookUUID, consumer)
if err != nil {
return "", fmt.Errorf("failed to retrieve webhook: %w", err)
}
secret, err := generateWebhookSecret()
if err != nil {
return "", fmt.Errorf("failed to generate webhook secret: %w", err)
}
encSecret, err := s.EncryptWebhookSecret(secret)
if err != nil {
return "", fmt.Errorf("failed to encrypt webhook secret: %w", err)
}
webhook.WebhookSecret = encSecret
if err := s.webhookRepo.UpdateWebhook(ctx, tenant.FromContext(ctx), webhook); err != nil {
return "", fmt.Errorf("failed to store rotated webhook secret: %w", err)
}
s.logger.InfoContext(ctx, "Rotated webhook signing secret", "webhook_id", webhookID, "consumer", consumer)
return secret, nil
}
// checkSecretHeadersFollowURL refuses to point a webhook at another origin
// while it keeps secret headers this request did not set. Secret header
// values are never returned by the API, so without this anyone who may edit
// the URL (a portal consumer, say) could read them by moving the webhook to
// a server they control. Each existing header must be given a new value, or
// "" to remove it; an empty secret_headers object removes them all.
func (s *WebhookService) checkSecretHeadersFollowURL(oldURL, newURL string, oldEncrypted []byte, set map[string]string, updated bool) error {
if len(oldEncrypted) == 0 || sameOrigin(oldURL, newURL) {
return nil
}
if updated && len(set) == 0 {
return nil // all removed
}
old, err := s.DecryptSecretHeaders(oldEncrypted)
if err != nil {
return fmt.Errorf("failed to decrypt existing secret headers: %w", err)
}
var missing []string
for name := range canonicalNames(old) {
if _, ok := set[name]; !ok || !updated {
missing = append(missing, name)
}
}
if len(missing) == 0 {
return nil
}
slices.Sort(missing)
return svcerrors.Errorf(svcerrors.InvalidArgument,
"changing the URL to another origin requires secret_headers to be set again: give %s a new value, or \"\" to remove it",
strings.Join(missing, ", "))
}
// sameOrigin is client.SameOrigin for URL strings; one that does not parse
// is another origin.
func sameOrigin(a, b string) bool {
ua, errA := neturl.Parse(a)
ub, errB := neturl.Parse(b)
if errA != nil || errB != nil {
return false
}
return client.SameOrigin(ua, ub)
}