package webhooks import ( "context" "log/slog" "math" "net" "regexp" "time" "github.com/google/uuid" "github.com/sarathsp06/sparrow/internal/crypto" svcerrors "github.com/sarathsp06/sparrow/internal/errors" "github.com/sarathsp06/sparrow/internal/observability" "github.com/sarathsp06/sparrow/internal/webhooks/client" "github.com/sarathsp06/sparrow/internal/webhooks/queue" "github.com/sarathsp06/sparrow/internal/webhooks/store" ) type WebhookService struct { jobInserter queue.JobInserter webhookRepo store.RepositoryInterface crypto *crypto.Service logger *slog.Logger metrics *observability.SparrowMetrics networkPolicy client.NetworkPolicy // autoRegisterEvents lets a push to an unknown event name create a // schema-less event type instead of failing with NotFound. autoRegisterEvents bool // deliveryClient sends test events; nil turns them off. deliveryClient *client.WebhookClient testEvents testEventLimiter } // WebhookManager manages webhook registrations and their lifecycle. type WebhookManager interface { CreateWebhook(ctx context.Context, req WebhookRegistrationRequest) (reg *store.WebhookRegistration, secret string, err error) UnregisterWebhook(ctx context.Context, webhookID string, consumer string) error ListWebhooks(ctx context.Context, consumer string, webhookID string, event string, activeOnly bool, health string, limit, offset int32) ([]*store.WebhookRegistration, int32, error) 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 RotateWebhookSecret(ctx context.Context, webhookID string, consumer string) (string, error) PauseWebhook(ctx context.Context, webhookID string, consumer string, reason string) error ResumeWebhook(ctx context.Context, webhookID string, consumer string) (*WebhookResumeResult, error) GetConsumerStats(ctx context.Context, consumer string) (*ConsumerStatsData, error) ListConsumers(ctx context.Context, query string, limit int32) ([]string, error) } // EventManager manages event type registrations and event publishing. type EventManager interface { RegisterEvent(ctx context.Context, name string, description string, schema map[string]any, metadata map[string]string, active bool) (string, time.Time, error) ListEvents(ctx context.Context, activeOnly bool, consumer string, limit, offset int32) ([]*store.EventRegistration, int32, error) UpdateEvent(ctx context.Context, name string, description string, schema map[string]any, metadata map[string]string, active bool, allowBreaking bool) (*EventTypeSaveResult, error) GetEvent(ctx context.Context, name string) (*store.EventRegistration, error) ListEventTypeVersions(ctx context.Context, name string) ([]*store.EventRegistrationVersion, error) GetEventTypeVersion(ctx context.Context, name string, version int) (*store.EventRegistrationVersion, error) ExportEventTypes(ctx context.Context, sel EventTypeExportSelection) ([]EventTypeDefinition, BundleStamp, error) ImportEventTypes(ctx context.Context, items []EventTypeDefinition, stamp *BundleStamp, opts EventTypeImportOptions) (*EventTypeImportResult, error) // PushEvent returns (eventID, isDuplicate, schemaValid, warnings, err). // isDuplicate is true when idempotencyKey matched an existing event; the // other fields then describe that existing event, not a new one. PushEvent(ctx context.Context, consumer string, event string, payload map[string]any, ttlSeconds int64, metadata map[string]string, labels map[string]string, idempotencyKey *string, scheduledAt *time.Time) (eventID string, isDuplicate bool, schemaValid bool, warnings []string, err error) RePushEvent(ctx context.Context, eventID string) (string, []string, error) GetEventRecord(ctx context.Context, eventID string) (*store.EventRecord, int32, int32, int32, int32, error) ListEventReports(ctx context.Context, filter store.EventReportFilter) (*EventReportPage, error) } // SubscriptionManager manages event subscriptions and payload-transform templates. type SubscriptionManager interface { CreateSubscription(ctx context.Context, webhookID, eventName, consumer string, headers map[string]string, method string, timeout int, transformEnabled bool, transformTemplate string, labelFilters map[string]string, settings SubscriptionTemplateSettings) (string, time.Time, error) GetSubscription(ctx context.Context, subscriptionID string, consumer string) (*store.EventSubscription, error) ListSubscriptions(ctx context.Context, consumer string, webhookID string, eventName string, limit, offset int32) ([]*store.EventSubscription, int32, error) UpdateSubscription(ctx context.Context, subscriptionID string, consumer string, headers map[string]string, method string, timeout int, transformEnabled bool, transformTemplate string, labelFilters map[string]string, settings SubscriptionTemplateSettings, meta TemplateSaveMeta) error // ListSubscriptionTemplateVersions returns the saved template history of a // subscription, newest (current) first. ListSubscriptionTemplateVersions(ctx context.Context, subscriptionID string, consumer string, limit int) ([]*store.SubscriptionTemplateVersion, error) DeleteSubscription(ctx context.Context, subscriptionID string, consumer string) error PauseSubscription(ctx context.Context, subscriptionID, consumer, reason string) (*store.EventSubscription, error) ResumeSubscription(ctx context.Context, subscriptionID, consumer string) (*ResumeResult, error) TestSubscriptionTemplate(ctx context.Context, eventName, transformTemplate, consumer string, strict bool, payload map[string]any) (string, error) ListSubscriptionsByWebhookIDs(ctx context.Context, webhookIDs []uuid.UUID) ([]*store.EventSubscription, error) GetTemplateFunctions() []TemplateFunctionInfo } // DeliveryManager exposes delivery status, attempt history, and retries. type DeliveryManager interface { GetDeliveryStatus(ctx context.Context, deliveryID string, consumer string) (*store.WebhookDelivery, error) GetDeliveryAttempts(ctx context.Context, deliveryID string, consumer string) ([]*store.WebhookHealthEvent, error) ListDeliveries(ctx context.Context, filter store.DeliveryFilter) (*DeliveryPage, error) RetryDelivery(ctx context.Context, consumer string, deliveryID string, webhookID string, force bool) ([]string, int32, error) } // HealthManager exposes per-webhook and aggregate health. type HealthManager interface { GetWebhookHealth(ctx context.Context, webhookID string, consumer string) (*WebhookHealthData, error) GetHealthSummary(ctx context.Context, consumer string) (*HealthSummaryData, error) } // BatchManager runs prepared bulk re-push and bulk retry jobs. type BatchManager interface { RePushEvents(ctx context.Context, scope BatchScope, repushID string) error GetRepushStatus(ctx context.Context, scope BatchScope, repushID string) (*store.BatchJob, error) CancelRepush(ctx context.Context, scope BatchScope, repushID string) error RetryDeliveries(ctx context.Context, scope BatchScope, retryID string) error GetRetryStatus(ctx context.Context, scope BatchScope, retryID string) (*store.BatchJob, error) CancelRetry(ctx context.Context, scope BatchScope, retryID string) error } // AlertConfigManager manages opt-in email recipients for Sparrow's // self-generated webhook.health_changed / webhook.delivery_failed events. type AlertConfigManager interface { CreateAlertConfig(ctx context.Context, consumer, webhookID, email string, eventTypes []string) (*store.AlertConfig, error) ListAlertConfigs(ctx context.Context, consumer string, webhookID string) ([]*store.AlertConfig, error) DeleteAlertConfig(ctx context.Context, consumer, id string) error AlertDeliveryConfigured(ctx context.Context) (bool, error) } // SecretRevealer decrypts stored webhook secrets for masking and exposes the // Ed25519 signing public key. Used by response conversion, never for delivery. type SecretRevealer interface { DecryptSecretHeaders(encrypted []byte) (map[string]string, error) DecryptWebhookSecret(encrypted []byte) (string, error) WebhookSigningPublicKeyHex(encryptedPrivKey []byte) string } //go:generate go tool gowrap gen -g -i WebhookServiceInterface -t ../../templates/opentelemetry.tmpl -o WebhookServiceInterface_otel.go // WebhookServiceInterface is the composite of every domain interface, satisfied // by *WebhookService. Consumers should depend on the narrow per-domain // interfaces; the composite exists for wiring and the OTel decorator. type WebhookServiceInterface interface { WebhookManager EventManager SubscriptionManager DeliveryManager HealthManager BatchManager SecretRevealer AlertConfigManager TestEventSender } type TemplateFunctionInfo struct { Name string Description string } var _ WebhookServiceInterface = (*WebhookService)(nil) // NewWebhookService creates a new WebhookService instance // WebhookServiceOption configures a WebhookService. type WebhookServiceOption func(*WebhookService) // WithAllowPrivateNetworks disables SSRF protection for webhook URL validation, // permitting loopback and private-network addresses. Useful for self-hosted // deployments where webhook targets live on the same network, and required for // integration tests that use httptest.NewServer. func WithAllowPrivateNetworks(allow bool) WebhookServiceOption { return func(s *WebhookService) { s.networkPolicy.AllowPrivate = allow } } // WithAllowedNetworks permits webhook URLs that resolve into these CIDRs // (e.g. internal services on a VPN) while every other private, loopback and // cloud-metadata address stays blocked. func WithAllowedNetworks(nets []*net.IPNet) WebhookServiceOption { return func(s *WebhookService) { s.networkPolicy.AllowedNetworks = nets } } // WithAutoRegisterEvents makes a push to an unregistered event name create a // schema-less event type instead of returning NotFound. Off by default: it is // a development convenience, and in production it turns typos into permanent // event type names, since event types are never deleted. func WithAutoRegisterEvents(enabled bool) WebhookServiceOption { return func(s *WebhookService) { s.autoRegisterEvents = enabled } } func NewWebhookService(queueManager queue.JobInserter, webhookRepo store.RepositoryInterface, cryptoSvc *crypto.Service, opts ...WebhookServiceOption) *WebhookService { metrics, err := observability.NewSparrowMetrics() if err != nil { // Log error but continue without metrics slog.Default().With("component", "webhook-service").Error("Failed to initialize metrics", "error", err) } svc := &WebhookService{ jobInserter: queueManager, webhookRepo: webhookRepo, crypto: cryptoSvc, logger: slog.Default().With("component", "webhook-service"), metrics: metrics, } for _, opt := range opts { opt(svc) } return svc } // --- Common Helpers --- // parseUUID parses a UUID string and returns a typed error on failure. // entityName is used in the error message (e.g. "webhook ID", "subscription ID"). func parseUUID(s string, entityName string) (uuid.UUID, error) { id, err := uuid.Parse(s) if err != nil { return uuid.Nil, svcerrors.Errorf(svcerrors.InvalidArgument, "invalid %s: %v", entityName, err) } return id, nil } // maxPageLimit caps list page sizes to bound response size and DB load. const maxPageLimit = 1000 // normalizePagination applies default limit (50), caps limit at maxPageLimit, // and ensures offset is non-negative. // DeliveryPage is one page of a delivery list. RetryID and RetryTotal are // set when the list was asked to snapshot its matches for a batch retry. type DeliveryPage struct { Items []*store.WebhookDelivery HasMore bool RetryID string RetryTotal int } // EventReportPage is one page of an event occurrence list. RepushID and // RepushTotal are set when the list was asked to snapshot its matches for a // batch re-push. type EventReportPage struct { Items []*store.EventReportWithStats HasMore bool RepushID string RepushTotal int } func normalizePagination(limit, offset int) (int, int) { if limit <= 0 { limit = 50 } else if limit > maxPageLimit { limit = maxPageLimit } if offset < 0 { offset = 0 } return limit, offset } // clampInt32 converts n for the int32 API fields, saturating instead of // wrapping when n is out of range. func clampInt32(n int) int32 { return int32(max(math.MinInt32, min(n, math.MaxInt32))) //nolint:gosec // clamped to the int32 range above } // Label validation constraints const ( maxLabelsPerMap = 20 // max key-value pairs per labels/labelFilters map maxLabelKeyLen = 64 // max characters per label key maxLabelValueLen = 256 // max characters per label value ) // labelKeyPattern restricts label keys to alphanumeric, dot, underscore, and hyphen. var labelKeyPattern = regexp.MustCompile(`^[a-zA-Z0-9._-]+$`) // validateLabels checks that a labels/label_filters map meets size and format constraints. func validateLabels(m map[string]string, fieldName string) error { if len(m) > maxLabelsPerMap { return svcerrors.Errorf(svcerrors.InvalidArgument, "%s: too many entries (%d), maximum is %d", fieldName, len(m), maxLabelsPerMap) } for k, v := range m { if k == "" { return svcerrors.Errorf(svcerrors.InvalidArgument, "%s: key must not be empty", fieldName) } if len(k) > maxLabelKeyLen { return svcerrors.Errorf(svcerrors.InvalidArgument, "%s: key %q exceeds maximum length of %d characters", fieldName, k, maxLabelKeyLen) } if !labelKeyPattern.MatchString(k) { return svcerrors.Errorf(svcerrors.InvalidArgument, "%s: key %q contains invalid characters (allowed: alphanumeric, '.', '_', '-')", fieldName, k) } if len(v) > maxLabelValueLen { return svcerrors.Errorf(svcerrors.InvalidArgument, "%s: value for key %q exceeds maximum length of %d characters", fieldName, k, maxLabelValueLen) } } return nil }