diff --git a/framework/configstore/migrations.go b/framework/configstore/migrations.go index ca25ea02d79..fecd99dabf9 100644 --- a/framework/configstore/migrations.go +++ b/framework/configstore/migrations.go @@ -446,6 +446,7 @@ var configstoreMigrationSteps = []migrationStep{ {IDs: []string{"add_inference_geo_multiplier_column"}, run: migrationAddInferenceGeoMultiplierColumn}, {IDs: []string{"repair_bare_wildcard_allowed_models"}, run: migrationRepairBareWildcardAllowedModels}, {IDs: []string{"add_bedrock_project_id_columns"}, run: migrationAddBedrockProjectIDColumns}, + {IDs: []string{"add_webhook_endpoints_table"}, run: migrationAddWebhookEndpointsTable}, } // quoteSQLiteIdentifier quotes a SQLite identifier, escaping any double quotes. @@ -10678,3 +10679,31 @@ func migrationAddSidekiqKindStatusCreatedIndex(ctx context.Context, db *gorm.DB, } return nil } + +// migrationAddWebhookEndpointsTable creates the config_webhook_endpoints table. +func migrationAddWebhookEndpointsTable(ctx context.Context, db *gorm.DB, logger schemas.Logger) error { + migrationName := "add_webhook_endpoints_table" + logger.Info("[configstore] starting migration %s", migrationName) + defer logger.Info("[configstore] finished migration %s", migrationName) + m := migrator.New(db, migrator.DefaultOptions, []*migrator.Migration{{ + ID: migrationName, + Migrate: func(tx *gorm.DB) error { + tx = tx.WithContext(ctx) + mg := tx.Migrator() + if !mg.HasTable(&tables.TableWebhookEndpoint{}) { + if err := mg.CreateTable(&tables.TableWebhookEndpoint{}); err != nil { + return fmt.Errorf("create config_webhook_endpoints table: %w", err) + } + } + return nil + }, + Rollback: func(tx *gorm.DB) error { + tx = tx.WithContext(ctx) + return tx.Migrator().DropTable(&tables.TableWebhookEndpoint{}) + }, + }}) + if err := m.Migrate(); err != nil { + return fmt.Errorf("error while running webhook endpoints table migration: %s", err.Error()) + } + return nil +} diff --git a/framework/configstore/rdb.go b/framework/configstore/rdb.go index c00f3bc0b39..01408ecfbd6 100644 --- a/framework/configstore/rdb.go +++ b/framework/configstore/rdb.go @@ -5,6 +5,7 @@ import ( "crypto/rand" "crypto/rsa" "crypto/x509" + "encoding/base64" "encoding/json" "encoding/pem" "errors" @@ -7376,3 +7377,169 @@ func (s *RDBConfigStore) GetOAuth2SessionByID(ctx context.Context, id string) (* } return &rt, nil } + +// generateWebhookSecret returns a new signing secret in the Standard Webhooks +// format: "whsec_" + base64 of 32 random bytes. +func generateWebhookSecret() (string, error) { + buf := make([]byte, 32) + if _, err := rand.Read(buf); err != nil { + return "", fmt.Errorf("failed to generate webhook secret: %w", err) + } + return "whsec_" + base64.StdEncoding.EncodeToString(buf), nil +} + +// GetWebhookEndpoints returns all registered webhook endpoints. +func (s *RDBConfigStore) GetWebhookEndpoints(ctx context.Context) ([]tables.TableWebhookEndpoint, error) { + var endpoints []tables.TableWebhookEndpoint + if err := s.DB().WithContext(ctx).Order("created_at ASC").Find(&endpoints).Error; err != nil { + return nil, err + } + return endpoints, nil +} + +// GetWebhookEndpointByID retrieves a webhook endpoint by its ID. +func (s *RDBConfigStore) GetWebhookEndpointByID(ctx context.Context, id string) (*tables.TableWebhookEndpoint, error) { + var endpoint tables.TableWebhookEndpoint + if err := s.DB().WithContext(ctx).Where("id = ?", id).First(&endpoint).Error; err != nil { + if errors.Is(err, gorm.ErrRecordNotFound) { + return nil, ErrNotFound + } + return nil, err + } + return &endpoint, nil +} + +// GetWebhookEndpointByName retrieves a webhook endpoint by its unique name. +func (s *RDBConfigStore) GetWebhookEndpointByName(ctx context.Context, name string) (*tables.TableWebhookEndpoint, error) { + var endpoint tables.TableWebhookEndpoint + if err := s.DB().WithContext(ctx).Where("name = ?", name).First(&endpoint).Error; err != nil { + if errors.Is(err, gorm.ErrRecordNotFound) { + return nil, ErrNotFound + } + return nil, err + } + return &endpoint, nil +} + +// CreateWebhookEndpoint persists a new webhook endpoint. Callers are expected +// to run endpoint.Validate() on user-supplied input first. When no signing +// secret is supplied one is generated server-side; in both cases +// endpoint.Secret holds the plaintext value after return so the caller can +// surface it exactly once — reads through the store return it encrypted-at-rest +// and API responses never include it. +func (s *RDBConfigStore) CreateWebhookEndpoint(ctx context.Context, endpoint *tables.TableWebhookEndpoint) error { + if endpoint == nil { + return fmt.Errorf("webhook endpoint cannot be nil") + } + if endpoint.ID == "" { + endpoint.ID = uuid.NewString() + } + if endpoint.Secret != nil && endpoint.Secret.IsFromSecret() && endpoint.Secret.GetValue() == "" { + // The admin API never accepts a secret (always server-generated); the + // only caller-supplied secret is a config.json literal or env/vault + // reference. A reference that resolved to nothing must never be + // persisted — deliveries would sign with an empty key — so fail here + // and let config load surface it as a warn-and-skip. + return fmt.Errorf("webhook secret reference did not resolve to a value") + } + if endpoint.Secret == nil || endpoint.Secret.GetValue() == "" { + secret, err := generateWebhookSecret() + if err != nil { + return err + } + endpoint.Secret = &schemas.SecretVar{Val: secret} + } + // BeforeSave encrypts Secret/HeadersJSON and stamps EncryptionStatus in + // place. Persist a shallow copy so those mutations never land on the + // caller's struct: the caller keeps the plaintext Secret to surface once, + // and a failed create leaves nothing half-encrypted for a retry to + // re-encrypt. Reference fields (Secret pointer, Headers map) are only ever + // reassigned by the hook, never mutated through, so the shallow copy is safe. + persist := *endpoint + return s.DB().WithContext(ctx).Transaction(func(tx *gorm.DB) error { + var existing tables.TableWebhookEndpoint + if err := tx.Where("name = ?", endpoint.Name).First(&existing).Error; err == nil { + return fmt.Errorf("webhook endpoint with name %q %w", endpoint.Name, ErrAlreadyExists) + } else if !errors.Is(err, gorm.ErrRecordNotFound) { + return err + } + return s.parseGormError(tx.Create(&persist).Error) + }) +} + +// UpdateWebhookEndpoint updates an endpoint's caller-editable fields. Callers +// are expected to run endpoint.Validate() on user-supplied input first. +// Signing secrets are never modified here — use RotateWebhookEndpointSecret. +// Changing the URL resets the consecutive-failure counter; re-enabling a +// disabled endpoint does not — only a successful delivery clears the streak. +func (s *RDBConfigStore) UpdateWebhookEndpoint(ctx context.Context, endpoint *tables.TableWebhookEndpoint) error { + if endpoint == nil { + return fmt.Errorf("webhook endpoint cannot be nil") + } + return s.DB().Transaction(func(tx *gorm.DB) error { + var existing tables.TableWebhookEndpoint + if err := dbForUpdate(tx.WithContext(ctx)).Where("id = ?", endpoint.ID).First(&existing).Error; err != nil { + if errors.Is(err, gorm.ErrRecordNotFound) { + return ErrNotFound + } + return err + } + if endpoint.URL != existing.URL { + existing.ConsecutiveFailures = 0 + } + existing.Name = endpoint.Name + existing.URL = endpoint.URL + existing.Events = endpoint.Events + existing.Headers = endpoint.Headers + existing.IncludeResponse = endpoint.IncludeResponse + existing.AllowPrivateNetwork = endpoint.AllowPrivateNetwork + existing.Disabled = endpoint.Disabled + existing.MaxRetries = endpoint.MaxRetries + existing.RetryBackoffInitialSeconds = endpoint.RetryBackoffInitialSeconds + existing.RetryBackoffMaxSeconds = endpoint.RetryBackoffMaxSeconds + existing.AttemptTimeoutSeconds = endpoint.AttemptTimeoutSeconds + existing.MaxResponsePayloadKBs = endpoint.MaxResponsePayloadKBs + existing.MaxConcurrentDeliveries = endpoint.MaxConcurrentDeliveries + existing.ConfigHash = endpoint.ConfigHash + return s.parseGormError(tx.WithContext(ctx).Save(&existing).Error) + }) +} + +// DeleteWebhookEndpoint removes a webhook endpoint by ID. +func (s *RDBConfigStore) DeleteWebhookEndpoint(ctx context.Context, id string) error { + result := s.DB().WithContext(ctx).Where("id = ?", id).Delete(&tables.TableWebhookEndpoint{}) + if result.Error != nil { + return result.Error + } + if result.RowsAffected == 0 { + return ErrNotFound + } + return nil +} + +// RotateWebhookEndpointSecret replaces the endpoint's signing secret with a +// freshly generated one, effective immediately — deliveries attempted after +// the rotation sign only with the new secret. The returned endpoint carries +// the new secret in plaintext so the caller can surface it exactly once. +func (s *RDBConfigStore) RotateWebhookEndpointSecret(ctx context.Context, id string) (*tables.TableWebhookEndpoint, error) { + newSecret, err := generateWebhookSecret() + if err != nil { + return nil, err + } + var rotated tables.TableWebhookEndpoint + err = s.DB().Transaction(func(tx *gorm.DB) error { + if err := dbForUpdate(tx.WithContext(ctx)).Where("id = ?", id).First(&rotated).Error; err != nil { + if errors.Is(err, gorm.ErrRecordNotFound) { + return ErrNotFound + } + return err + } + rotated.Secret = &schemas.SecretVar{Val: newSecret} + return s.parseGormError(tx.WithContext(ctx).Save(&rotated).Error) + }) + if err != nil { + return nil, err + } + rotated.Secret = &schemas.SecretVar{Val: newSecret} + return &rotated, nil +} diff --git a/framework/configstore/rdb_test.go b/framework/configstore/rdb_test.go index a385c261e3c..49c4437abf5 100644 --- a/framework/configstore/rdb_test.go +++ b/framework/configstore/rdb_test.go @@ -4,6 +4,7 @@ import ( "context" "encoding/json" "fmt" + "strings" "testing" "time" @@ -56,6 +57,7 @@ func setupRDBTestStore(t *testing.T) *RDBConfigStore { &tables.TableMCPPerUserHeaderCredential{}, &tables.TableMCPPerUserHeaderFlow{}, &tables.TableOAuth2RefreshToken{}, + &tables.TableWebhookEndpoint{}, ) require.NoError(t, err, "Failed to migrate test database") @@ -2313,3 +2315,249 @@ func TestUpsertModelParametersBatch_SQLite(t *testing.T) { require.NoError(t, err) assert.Len(t, got, 3) } + +func testWebhookEndpoint(name string) *tables.TableWebhookEndpoint { + return &tables.TableWebhookEndpoint{ + Name: name, + URL: "https://93.184.216.34/hook", + Events: []tables.WebhookEvent{tables.WebhookEventAsyncJobCompleted}, + } +} + +func TestWebhookEndpointCreate(t *testing.T) { + store := setupRDBTestStore(t) + ctx := context.Background() + + endpoint := testWebhookEndpoint("create-test") + require.NoError(t, store.CreateWebhookEndpoint(ctx, endpoint)) + + // Server generates the ID and a Standard Webhooks style secret, and leaves + // the plaintext on the struct for one-time display. + assert.NotEmpty(t, endpoint.ID) + require.NotNil(t, endpoint.Secret) + secret := endpoint.Secret.GetValue() + assert.True(t, strings.HasPrefix(secret, "whsec_"), "secret %q missing whsec_ prefix", secret) + assert.Len(t, secret, len("whsec_")+44) // base64 of 32 bytes + + fetched, err := store.GetWebhookEndpointByID(ctx, endpoint.ID) + require.NoError(t, err) + assert.Equal(t, "create-test", fetched.Name) + assert.Equal(t, "https://93.184.216.34/hook", fetched.URL) + assert.Equal(t, []tables.WebhookEvent{tables.WebhookEventAsyncJobCompleted}, fetched.Events) + assert.Equal(t, secret, fetched.Secret.GetValue()) + + byName, err := store.GetWebhookEndpointByName(ctx, "create-test") + require.NoError(t, err) + assert.Equal(t, endpoint.ID, byName.ID) + + // Distinct endpoints get distinct generated secrets. + second := testWebhookEndpoint("create-test-2") + require.NoError(t, store.CreateWebhookEndpoint(ctx, second)) + assert.NotEqual(t, secret, second.Secret.GetValue()) +} + +func TestWebhookEndpointCreateDuplicateName(t *testing.T) { + store := setupRDBTestStore(t) + ctx := context.Background() + + require.NoError(t, store.CreateWebhookEndpoint(ctx, testWebhookEndpoint("dup-name"))) + err := store.CreateWebhookEndpoint(ctx, testWebhookEndpoint("dup-name")) + assert.ErrorIs(t, err, ErrAlreadyExists) +} + +func TestWebhookEndpointCreateRejectsUnresolvedSecretRef(t *testing.T) { + store := setupRDBTestStore(t) + ctx := context.Background() + + // An env reference that resolves to nothing must never be persisted — + // deliveries would sign with an empty key. The API never accepts a + // secret, so a reference can only arrive from config.json. + endpoint := testWebhookEndpoint("unresolved-ref") + endpoint.Secret = schemas.NewSecretVar("env.E2E_WEBHOOK_SECRET_THAT_DOES_NOT_EXIST") + require.True(t, endpoint.Secret.IsFromSecret()) + + err := store.CreateWebhookEndpoint(ctx, endpoint) + require.Error(t, err) + assert.Contains(t, err.Error(), "did not resolve") + + // A reference that resolves is kept as the signing key. + t.Setenv("E2E_WEBHOOK_SECRET_SET", "whsec_from_env") + resolved := testWebhookEndpoint("resolved-ref") + resolved.Secret = schemas.NewSecretVar("env.E2E_WEBHOOK_SECRET_SET") + require.NoError(t, store.CreateWebhookEndpoint(ctx, resolved)) + assert.Equal(t, "whsec_from_env", resolved.Secret.GetValue()) +} + +func TestWebhookEndpointCreateFailureKeepsCallerPlaintext(t *testing.T) { + store := setupRDBTestStore(t) + ctx := context.Background() + + require.NoError(t, store.CreateWebhookEndpoint(ctx, testWebhookEndpoint("plaintext-kept"))) + + // A failed create (duplicate name here) must leave the caller's secret + // as the plaintext it supplied — not the BeforeSave ciphertext — so a + // retry cannot double-encrypt and persist unusable key material. + retry := testWebhookEndpoint("plaintext-kept") + retry.Secret = schemas.NewSecretVar("whsec_caller_supplied") + err := store.CreateWebhookEndpoint(ctx, retry) + require.ErrorIs(t, err, ErrAlreadyExists) + assert.Equal(t, "whsec_caller_supplied", retry.Secret.GetValue()) + + // And the retry (under a fresh name) persists a working secret. + retry.Name = "plaintext-kept-2" + retry.ID = "" + require.NoError(t, store.CreateWebhookEndpoint(ctx, retry)) + fetched, err := store.GetWebhookEndpointByID(ctx, retry.ID) + require.NoError(t, err) + assert.Equal(t, "whsec_caller_supplied", fetched.Secret.GetValue()) +} + +func TestWebhookEndpointUpdate(t *testing.T) { + store := setupRDBTestStore(t) + ctx := context.Background() + + endpoint := testWebhookEndpoint("update-test") + require.NoError(t, store.CreateWebhookEndpoint(ctx, endpoint)) + originalSecret := endpoint.Secret.GetValue() + + // Seed a failure streak without going through hooks. + require.NoError(t, store.DB().Model(&tables.TableWebhookEndpoint{}). + Where("id = ?", endpoint.ID).UpdateColumn("consecutive_failures", 7).Error) + + // A non-URL change keeps the failure counter and the stored secret. + loaded, err := store.GetWebhookEndpointByID(ctx, endpoint.ID) + require.NoError(t, err) + loaded.IncludeResponse = true + loaded.Events = []tables.WebhookEvent{tables.WebhookEventAsyncJobCompleted, tables.WebhookEventAsyncJobFailed} + require.NoError(t, store.UpdateWebhookEndpoint(ctx, loaded)) + + fetched, err := store.GetWebhookEndpointByID(ctx, endpoint.ID) + require.NoError(t, err) + assert.True(t, fetched.IncludeResponse) + assert.Len(t, fetched.Events, 2) + assert.Equal(t, 7, fetched.ConsecutiveFailures) + assert.Equal(t, originalSecret, fetched.Secret.GetValue(), "update must not touch the signing secret") + + // Changing the URL resets the failure counter. + fetched.URL = "https://93.184.216.35/hook" + require.NoError(t, store.UpdateWebhookEndpoint(ctx, fetched)) + fetched, err = store.GetWebhookEndpointByID(ctx, endpoint.ID) + require.NoError(t, err) + assert.Equal(t, "https://93.184.216.35/hook", fetched.URL) + assert.Equal(t, 0, fetched.ConsecutiveFailures) +} + +func TestWebhookEndpointUpdateReenablePreservesFailureCounter(t *testing.T) { + store := setupRDBTestStore(t) + ctx := context.Background() + + endpoint := testWebhookEndpoint("reenable-test") + require.NoError(t, store.CreateWebhookEndpoint(ctx, endpoint)) + + loaded, err := store.GetWebhookEndpointByID(ctx, endpoint.ID) + require.NoError(t, err) + loaded.Disabled = true + require.NoError(t, store.UpdateWebhookEndpoint(ctx, loaded)) + require.NoError(t, store.DB().Model(&tables.TableWebhookEndpoint{}). + Where("id = ?", endpoint.ID).UpdateColumn("consecutive_failures", 25).Error) + + disabled, err := store.GetWebhookEndpointByID(ctx, endpoint.ID) + require.NoError(t, err) + assert.True(t, disabled.Disabled) + + disabled.Disabled = false + require.NoError(t, store.UpdateWebhookEndpoint(ctx, disabled)) + + // Re-enabling does not touch the failure counter — only a successful + // delivery (or a URL change) resets it. + reenabled, err := store.GetWebhookEndpointByID(ctx, endpoint.ID) + require.NoError(t, err) + assert.False(t, reenabled.Disabled) + assert.Equal(t, 25, reenabled.ConsecutiveFailures) +} + +func TestWebhookEndpointUpdateNotFound(t *testing.T) { + store := setupRDBTestStore(t) + + endpoint := testWebhookEndpoint("ghost") + endpoint.ID = "does-not-exist" + assert.ErrorIs(t, store.UpdateWebhookEndpoint(context.Background(), endpoint), ErrNotFound) +} + +func TestWebhookEndpointDelete(t *testing.T) { + store := setupRDBTestStore(t) + ctx := context.Background() + + endpoint := testWebhookEndpoint("delete-test") + require.NoError(t, store.CreateWebhookEndpoint(ctx, endpoint)) + + require.NoError(t, store.DeleteWebhookEndpoint(ctx, endpoint.ID)) + _, err := store.GetWebhookEndpointByID(ctx, endpoint.ID) + assert.ErrorIs(t, err, ErrNotFound) + assert.ErrorIs(t, store.DeleteWebhookEndpoint(ctx, endpoint.ID), ErrNotFound) +} + +func TestWebhookEndpointRotateSecret(t *testing.T) { + store := setupRDBTestStore(t) + ctx := context.Background() + + endpoint := testWebhookEndpoint("rotate-test") + require.NoError(t, store.CreateWebhookEndpoint(ctx, endpoint)) + originalSecret := endpoint.Secret.GetValue() + + rotated, err := store.RotateWebhookEndpointSecret(ctx, endpoint.ID) + require.NoError(t, err) + + newSecret := rotated.Secret.GetValue() + assert.True(t, strings.HasPrefix(newSecret, "whsec_")) + assert.NotEqual(t, originalSecret, newSecret) + + // Rotation is immediate: only the new secret is stored. + fetched, err := store.GetWebhookEndpointByID(ctx, endpoint.ID) + require.NoError(t, err) + assert.Equal(t, newSecret, fetched.Secret.GetValue()) + + _, err = store.RotateWebhookEndpointSecret(ctx, "does-not-exist") + assert.ErrorIs(t, err, ErrNotFound) +} + +func TestWebhookEndpointList(t *testing.T) { + store := setupRDBTestStore(t) + ctx := context.Background() + + endpoints, err := store.GetWebhookEndpoints(ctx) + require.NoError(t, err) + assert.Empty(t, endpoints) + + require.NoError(t, store.CreateWebhookEndpoint(ctx, testWebhookEndpoint("list-a"))) + require.NoError(t, store.CreateWebhookEndpoint(ctx, testWebhookEndpoint("list-b"))) + + endpoints, err = store.GetWebhookEndpoints(ctx) + require.NoError(t, err) + assert.Len(t, endpoints, 2) +} + +func TestWebhookEndpointUpdatePersistsTuningAndHeaders(t *testing.T) { + store := setupRDBTestStore(t) + ctx := context.Background() + + endpoint := testWebhookEndpoint("update-persist-test") + require.NoError(t, store.CreateWebhookEndpoint(ctx, endpoint)) + + loaded, err := store.GetWebhookEndpointByID(ctx, endpoint.ID) + require.NoError(t, err) + loaded.MaxRetries = 2 + loaded.AttemptTimeoutSeconds = 7 + loaded.MaxConcurrentDeliveries = 3 + loaded.Headers = map[string]schemas.SecretVar{"Authorization": {Val: "Bearer tok"}} + require.NoError(t, store.UpdateWebhookEndpoint(ctx, loaded)) + + fetched, err := store.GetWebhookEndpointByID(ctx, endpoint.ID) + require.NoError(t, err) + assert.Equal(t, 2, fetched.MaxRetries, "updates must persist tuning knobs") + assert.Equal(t, 7, fetched.AttemptTimeoutSeconds) + assert.Equal(t, 3, fetched.MaxConcurrentDeliveries) + require.Len(t, fetched.Headers, 1) + auth := fetched.Headers["Authorization"] + assert.Equal(t, "Bearer tok", auth.GetValue()) +} diff --git a/framework/configstore/store.go b/framework/configstore/store.go index a43bd0a1367..2e8c86c0d66 100644 --- a/framework/configstore/store.go +++ b/framework/configstore/store.go @@ -675,6 +675,15 @@ type ConfigStore interface { GetInFlightSidekiqJobByKind(ctx context.Context, kind string) (*tables.TableSidekiqJob, error) MarkStaleSidekiqJobsFailed(ctx context.Context, staleBefore time.Time) (int64, error) + // Webhook Endpoints + GetWebhookEndpoints(ctx context.Context) ([]tables.TableWebhookEndpoint, error) + GetWebhookEndpointByID(ctx context.Context, id string) (*tables.TableWebhookEndpoint, error) + GetWebhookEndpointByName(ctx context.Context, name string) (*tables.TableWebhookEndpoint, error) + CreateWebhookEndpoint(ctx context.Context, endpoint *tables.TableWebhookEndpoint) error + UpdateWebhookEndpoint(ctx context.Context, endpoint *tables.TableWebhookEndpoint) error + DeleteWebhookEndpoint(ctx context.Context, id string) error + RotateWebhookEndpointSecret(ctx context.Context, id string) (*tables.TableWebhookEndpoint, error) + // DB returns the underlying database connection. DB() *gorm.DB diff --git a/framework/configstore/tables/clientconfig_test.go b/framework/configstore/tables/clientconfig_test.go index 18c2414f4f0..2b22e424b55 100644 --- a/framework/configstore/tables/clientconfig_test.go +++ b/framework/configstore/tables/clientconfig_test.go @@ -15,9 +15,7 @@ func TestTableClientConfigAfterFindReplacesMetadata(t *testing.T) { "theme": "dark", }, } - require.NoError(t, config.AfterFind(nil)) - assert.Equal(t, map[string]any{"theme": "light"}, config.Metadata) } @@ -27,8 +25,6 @@ func TestTableClientConfigAfterFindClearsMetadataWhenEmpty(t *testing.T) { "stale": "value", }, } - require.NoError(t, config.AfterFind(nil)) - assert.Nil(t, config.Metadata) } diff --git a/framework/configstore/tables/encryption_test.go b/framework/configstore/tables/encryption_test.go index 83f280e9b92..aad4759ba8e 100644 --- a/framework/configstore/tables/encryption_test.go +++ b/framework/configstore/tables/encryption_test.go @@ -40,6 +40,7 @@ func setupTestDB(t *testing.T) *gorm.DB { &TableOauthConfig{}, &TableOauthToken{}, &TableVectorStoreConfig{}, + &TableWebhookEndpoint{}, ) require.NoError(t, err) return db diff --git a/framework/configstore/tables/webhooks.go b/framework/configstore/tables/webhooks.go new file mode 100644 index 00000000000..56fd8b5fcb6 --- /dev/null +++ b/framework/configstore/tables/webhooks.go @@ -0,0 +1,286 @@ +package tables + +import ( + "encoding/json" + "fmt" + "net/url" + "slices" + "strings" + "time" + + "github.com/bytedance/sonic" + bifrost "github.com/maximhq/bifrost/core" + "github.com/maximhq/bifrost/core/schemas" + "github.com/maximhq/bifrost/framework/encrypt" + "gorm.io/gorm" +) + +// WebhookEvent identifies a server-side event that can be delivered to a +// registered webhook endpoint. +type WebhookEvent string + +const ( + // WebhookEventAsyncJobCompleted fires when an async inference job finishes successfully. + WebhookEventAsyncJobCompleted WebhookEvent = "async_job.completed" + // WebhookEventAsyncJobFailed fires when an async inference job reaches a terminal failure. + WebhookEventAsyncJobFailed WebhookEvent = "async_job.failed" +) + +// WebhookEvents lists every supported webhook event. +var WebhookEvents = []WebhookEvent{ + WebhookEventAsyncJobCompleted, + WebhookEventAsyncJobFailed, +} + +// IsValid reports whether e is a supported webhook event. +func (e WebhookEvent) IsValid() bool { + return slices.Contains(WebhookEvents, e) +} + +// TableWebhookEndpoint represents a registered webhook endpoint in the database. +type TableWebhookEndpoint struct { + ID string `gorm:"type:varchar(36);primaryKey" json:"id"` + Name string `gorm:"type:varchar(255);uniqueIndex;not null" json:"name"` + URL string `gorm:"type:text;not null" json:"url"` + + // Secret signs outgoing deliveries. Excluded from JSON — API responses + // expose it only once at creation/rotation time. + Secret *schemas.SecretVar `gorm:"type:text" json:"-"` + + EventsJSON string `gorm:"type:text" json:"-"` // JSON serialized []WebhookEvent + HeadersJSON string `gorm:"type:text" json:"-"` // JSON serialized map[string]string (encrypted at rest): custom delivery headers + IncludeResponse bool `gorm:"default:false" json:"include_response"` + AllowPrivateNetwork bool `gorm:"default:false" json:"allow_private_network"` + + // Per-endpoint delivery tuning. Zero means "use the delivery worker's + // default" — every knob must be positive when set. + MaxRetries int `gorm:"default:0" json:"max_retries,omitempty"` // Retries after the first delivery attempt (default: 4) + RetryBackoffInitialSeconds int `gorm:"default:0" json:"retry_backoff_initial_seconds,omitempty"` // Delay before the first retry; doubles per retry (default: 30) + RetryBackoffMaxSeconds int `gorm:"default:0" json:"retry_backoff_max_seconds,omitempty"` // Cap on the per-retry delay (default: 1800) + AttemptTimeoutSeconds int `gorm:"default:0" json:"attempt_timeout_seconds,omitempty"` // End-to-end bound for one delivery attempt (default: 10) + MaxResponsePayloadKBs int `gorm:"column:max_response_payload_kbs;default:0" json:"max_response_payload_kbs,omitempty"` // Cap for inlined response payloads in KB (default: 256) + MaxConcurrentDeliveries int `gorm:"default:0" json:"max_concurrent_deliveries,omitempty"` // Concurrent in-flight deliveries to this endpoint per node (default: 10) + + Disabled bool `gorm:"default:false" json:"disabled"` + + ConsecutiveFailures int `gorm:"default:0" json:"consecutive_failures"` + LastSuccessAt *time.Time `json:"last_success_at,omitempty"` + LastFailureAt *time.Time `json:"last_failure_at,omitempty"` + + // Config hash is used to detect the changes synced from config.json file + // Every time we sync the config.json file, we will update the config hash + ConfigHash string `gorm:"type:varchar(255);null" json:"config_hash"` + + EncryptionStatus string `gorm:"type:varchar(20);default:'plain_text'" json:"-"` + + CreatedAt time.Time `gorm:"index;not null" json:"created_at"` + UpdatedAt time.Time `gorm:"index;not null" json:"updated_at"` + + // Virtual fields for runtime use (not stored in DB) + Events []WebhookEvent `gorm:"-" json:"events"` + Headers map[string]schemas.SecretVar `gorm:"-" json:"headers,omitempty"` +} + +// TableName sets the table name for the webhook endpoint model +func (TableWebhookEndpoint) TableName() string { return "config_webhook_endpoints" } + +// protectedWebhookHeaders are delivery headers callers can never override: +// the Standard Webhooks signing headers plus protocol- and identity-level +// headers the delivery client owns. +var protectedWebhookHeaders = map[string]bool{ + "webhook-id": true, + "webhook-timestamp": true, + "webhook-signature": true, + "x-bifrost-event": true, + "content-type": true, + "content-length": true, + "user-agent": true, + "host": true, + "connection": true, + "transfer-encoding": true, +} + +// IsProtectedWebhookHeader reports whether a custom delivery header name is +// reserved and must not be caller-supplied. +func IsProtectedWebhookHeader(name string) bool { + return protectedWebhookHeaders[strings.ToLower(name)] +} + +// isValidHeaderName reports whether name is a valid HTTP header field name +// (RFC 7230 token characters). +func isValidHeaderName(name string) bool { + if name == "" { + return false + } + for _, r := range name { + switch { + case r >= 'a' && r <= 'z', r >= 'A' && r <= 'Z', r >= '0' && r <= '9': + case strings.ContainsRune("!#$%&'*+-.^_`|~", r): + default: + return false + } + } + return true +} + +// Validate checks the caller-editable fields of a webhook endpoint: name, +// URL, and events. Entry points that accept endpoint definitions (API +// handlers, config file loading) must call this before persisting; the store +// methods themselves do not re-validate. +func (w *TableWebhookEndpoint) Validate() error { + if w == nil { + return fmt.Errorf("webhook endpoint cannot be nil") + } + if w.Name == "" { + return fmt.Errorf("webhook endpoint name cannot be empty") + } + if err := validateWebhookEndpointURL(w.URL, w.AllowPrivateNetwork); err != nil { + return err + } + if len(w.Events) == 0 { + return fmt.Errorf("webhook endpoint must subscribe to at least one event") + } + seen := make(map[WebhookEvent]bool, len(w.Events)) + for _, event := range w.Events { + if !event.IsValid() { + return fmt.Errorf("unknown webhook event %q", event) + } + if seen[event] { + return fmt.Errorf("duplicate webhook event %q", event) + } + seen[event] = true + } + for name, value := range map[string]int{ + "max_retries": w.MaxRetries, + "retry_backoff_initial_seconds": w.RetryBackoffInitialSeconds, + "retry_backoff_max_seconds": w.RetryBackoffMaxSeconds, + "attempt_timeout_seconds": w.AttemptTimeoutSeconds, + "max_response_payload_kbs": w.MaxResponsePayloadKBs, + "max_concurrent_deliveries": w.MaxConcurrentDeliveries, + } { + if value < 0 { + return fmt.Errorf("webhook endpoint %s must be positive when set", name) + } + } + for name := range w.Headers { + if !isValidHeaderName(name) { + return fmt.Errorf("invalid webhook header name %q", name) + } + if IsProtectedWebhookHeader(name) { + return fmt.Errorf("webhook header %q is reserved and cannot be overridden", name) + } + } + return nil +} + +// validateWebhookEndpointURL validates a webhook delivery URL. HTTPS is +// required unless allowPrivateNetwork is set (which also unlocks private +// address ranges for LAN/cluster receivers); URLs must not carry credentials +// or fragments. Scheme allowlisting and IP-range checks (link-local and +// metadata addresses rejected regardless of allowPrivateNetwork) are +// delegated to bifrost.ValidateExternalURL. +func validateWebhookEndpointURL(rawURL string, allowPrivateNetwork bool) error { + if rawURL == "" { + return fmt.Errorf("webhook URL cannot be empty") + } + parsed, err := url.Parse(rawURL) + if err != nil { + return fmt.Errorf("invalid webhook URL: %w", err) + } + if parsed.Scheme == "http" && !allowPrivateNetwork { + return fmt.Errorf("webhook URL must use https (http requires allow_private_network)") + } + if parsed.User != nil { + return fmt.Errorf("webhook URL must not contain credentials") + } + if parsed.Fragment != "" { + return fmt.Errorf("webhook URL must not contain a fragment") + } + return bifrost.ValidateExternalURL(rawURL, allowPrivateNetwork) +} + +// BeforeSave is a GORM hook that serializes the events list and custom +// headers into their JSON columns and encrypts the sensitive fields before +// writing to the database. +func (w *TableWebhookEndpoint) BeforeSave(tx *gorm.DB) error { + if w.Events != nil { + data, err := json.Marshal(w.Events) + if err != nil { + return err + } + w.EventsJSON = string(data) + } else { + w.EventsJSON = "[]" + } + + if w.Headers != nil { + headersToSerialize := make(map[string]string, len(w.Headers)) + for key, value := range w.Headers { + if value.IsFromSecret() { + headersToSerialize[key] = value.GetRawRef() + } else { + headersToSerialize[key] = value.GetValue() + } + } + data, err := json.Marshal(headersToSerialize) + if err != nil { + return err + } + w.HeadersJSON = string(data) + } else { + w.HeadersJSON = "{}" + } + + // Encrypt sensitive fields after serialization. + // Always set EncryptionStatus when encryption is enabled so the startup + // batch pass does not re-process this row indefinitely. + if encrypt.IsEnabled() { + if w.Secret != nil { + // Copy to avoid encrypting the caller's value through the pointer + secret := *w.Secret + if err := encryptSecretVar(&secret); err != nil { + return fmt.Errorf("failed to encrypt webhook secret: %w", err) + } + w.Secret = &secret + } + if w.HeadersJSON != "" && w.HeadersJSON != "{}" { + encrypted, err := encrypt.Encrypt(w.HeadersJSON) + if err != nil { + return fmt.Errorf("failed to encrypt webhook headers: %w", err) + } + w.HeadersJSON = encrypted + } + w.EncryptionStatus = EncryptionStatusEncrypted + } + + return nil +} + +// AfterFind is a GORM hook that decrypts the sensitive fields (if encrypted) +// and deserializes the events and headers JSON columns after reading from +// the database. +func (w *TableWebhookEndpoint) AfterFind(tx *gorm.DB) error { + if w.EncryptionStatus == EncryptionStatusEncrypted { + if err := decryptSecretVar(w.Secret); err != nil { + return fmt.Errorf("failed to decrypt webhook secret: %w", err) + } + if w.HeadersJSON != "" && w.HeadersJSON != "{}" { + decrypted, err := encrypt.Decrypt(w.HeadersJSON) + if err != nil { + return fmt.Errorf("failed to decrypt webhook headers: %w", err) + } + w.HeadersJSON = decrypted + } + } + if w.EventsJSON != "" { + if err := sonic.Unmarshal([]byte(w.EventsJSON), &w.Events); err != nil { + return err + } + } + if w.HeadersJSON != "" && w.HeadersJSON != "{}" { + if err := sonic.Unmarshal([]byte(w.HeadersJSON), &w.Headers); err != nil { + return err + } + } + return nil +} diff --git a/framework/configstore/tables/webhooks_test.go b/framework/configstore/tables/webhooks_test.go new file mode 100644 index 00000000000..b29d195dd5e --- /dev/null +++ b/framework/configstore/tables/webhooks_test.go @@ -0,0 +1,237 @@ +package tables + +import ( + "encoding/json" + "strings" + "testing" + + "github.com/maximhq/bifrost/core/schemas" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +// ============================================================================ +// TableWebhookEndpoint validation tests +// ============================================================================ + +func validatableWebhookEndpoint(name string) *TableWebhookEndpoint { + return &TableWebhookEndpoint{ + Name: name, + URL: "https://93.184.216.34/hook", + Events: []WebhookEvent{WebhookEventAsyncJobCompleted}, + } +} + +func TestValidateWebhookEndpointURL(t *testing.T) { + // IP-literal hosts keep these cases deterministic (no DNS lookups). + tests := []struct { + name string + url string + allowPrivateNetwork bool + wantErr string + }{ + {"https public", "https://93.184.216.34/hook", false, ""}, + {"http public denied", "http://93.184.216.34/hook", false, "must use https"}, + {"http allowed with private network flag", "http://10.1.2.3/hook", true, ""}, + {"https private denied without flag", "https://10.1.2.3/hook", false, "private IP"}, + {"link-local blocked despite flag", "https://169.254.169.254/hook", true, "link-local"}, + {"metadata endpoint blocked", "https://169.254.169.254/latest/meta-data", false, "link-local"}, + {"credentials rejected", "https://user:pw@93.184.216.34/hook", false, "credentials"}, + {"fragment rejected", "https://93.184.216.34/hook#section", false, "fragment"}, + {"unsupported scheme", "ftp://93.184.216.34/hook", false, "only https and http"}, + {"empty", "", false, "cannot be empty"}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + err := validateWebhookEndpointURL(tt.url, tt.allowPrivateNetwork) + if tt.wantErr == "" { + assert.NoError(t, err) + } else { + require.Error(t, err) + assert.Contains(t, err.Error(), tt.wantErr) + } + }) + } +} + +func TestTableWebhookEndpointValidate(t *testing.T) { + t.Run("nil endpoint", func(t *testing.T) { + var endpoint *TableWebhookEndpoint + assert.Error(t, endpoint.Validate()) + }) + + t.Run("empty name", func(t *testing.T) { + endpoint := validatableWebhookEndpoint("") + assert.ErrorContains(t, endpoint.Validate(), "name cannot be empty") + }) + + t.Run("invalid url", func(t *testing.T) { + endpoint := validatableWebhookEndpoint("bad-url") + endpoint.URL = "http://93.184.216.34/hook" + assert.ErrorContains(t, endpoint.Validate(), "must use https") + }) + + t.Run("no events", func(t *testing.T) { + endpoint := validatableWebhookEndpoint("no-events") + endpoint.Events = nil + assert.ErrorContains(t, endpoint.Validate(), "at least one event") + }) + + t.Run("unknown event", func(t *testing.T) { + endpoint := validatableWebhookEndpoint("bad-event") + endpoint.Events = []WebhookEvent{"async_job.started"} + assert.ErrorContains(t, endpoint.Validate(), "unknown webhook event") + }) + + t.Run("duplicate event", func(t *testing.T) { + endpoint := validatableWebhookEndpoint("dup-event") + endpoint.Events = []WebhookEvent{WebhookEventAsyncJobCompleted, WebhookEventAsyncJobCompleted} + assert.ErrorContains(t, endpoint.Validate(), "duplicate webhook event") + }) + + t.Run("valid", func(t *testing.T) { + endpoint := validatableWebhookEndpoint("valid") + endpoint.Events = []WebhookEvent{WebhookEventAsyncJobCompleted, WebhookEventAsyncJobFailed} + assert.NoError(t, endpoint.Validate()) + }) +} + +// ============================================================================ +// TableWebhookEndpoint encryption tests +// ============================================================================ + +func TestTableWebhookEndpoint_EncryptDecrypt(t *testing.T) { + db := setupTestDB(t) + + endpoint := &TableWebhookEndpoint{ + ID: "wh-encrypt-1", + Name: "encrypt-test", + URL: "https://receiver.example.com/hook", + Secret: &schemas.SecretVar{Val: "whsec_current_secret_value"}, + Events: []WebhookEvent{WebhookEventAsyncJobCompleted, WebhookEventAsyncJobFailed}, + } + require.NoError(t, db.Create(endpoint).Error) + + // The stored row must hold ciphertext, never the plaintext secret. + row := rawRow(t, db, "config_webhook_endpoints", "wh-encrypt-1") + assert.Equal(t, EncryptionStatusEncrypted, row["encryption_status"]) + assert.NotEmpty(t, row["secret"]) + assert.NotEqual(t, "whsec_current_secret_value", row["secret"]) + + // Reading back through GORM decrypts and restores the runtime fields. + var fetched TableWebhookEndpoint + require.NoError(t, db.First(&fetched, "id = ?", "wh-encrypt-1").Error) + assert.Equal(t, "whsec_current_secret_value", secretVarPtrValue(fetched.Secret)) + assert.Equal(t, []WebhookEvent{WebhookEventAsyncJobCompleted, WebhookEventAsyncJobFailed}, fetched.Events) +} + +func TestTableWebhookEndpoint_SecretsExcludedFromJSON(t *testing.T) { + db := setupTestDB(t) + + endpoint := &TableWebhookEndpoint{ + ID: "wh-json-1", + Name: "json-test", + URL: "https://receiver.example.com/hook", + Secret: &schemas.SecretVar{Val: "whsec_must_not_leak"}, + Events: []WebhookEvent{WebhookEventAsyncJobCompleted}, + } + require.NoError(t, db.Create(endpoint).Error) + + var fetched TableWebhookEndpoint + require.NoError(t, db.First(&fetched, "id = ?", "wh-json-1").Error) + + data, err := json.Marshal(fetched) + require.NoError(t, err) + assert.False(t, strings.Contains(string(data), "whsec_"), "serialized endpoint must not contain secret material: %s", data) + assert.Contains(t, string(data), "async_job.completed") +} + +func TestTableWebhookEndpoint_EnvRefSecretNotEncrypted(t *testing.T) { + db := setupTestDB(t) + + t.Setenv("TEST_WEBHOOK_SECRET", "whsec_from_env") + endpoint := &TableWebhookEndpoint{ + ID: "wh-env-1", + Name: "env-ref-test", + URL: "https://receiver.example.com/hook", + Secret: schemas.NewSecretVar("env.TEST_WEBHOOK_SECRET"), + Events: []WebhookEvent{WebhookEventAsyncJobFailed}, + } + require.NoError(t, db.Create(endpoint).Error) + + // Env-referenced secrets store the reference, not a resolved (or encrypted) value. + row := rawRow(t, db, "config_webhook_endpoints", "wh-env-1") + assert.Equal(t, "env.TEST_WEBHOOK_SECRET", row["secret"]) + + var fetched TableWebhookEndpoint + require.NoError(t, db.First(&fetched, "id = ?", "wh-env-1").Error) + require.NotNil(t, fetched.Secret) + assert.True(t, fetched.Secret.IsFromSecret()) + assert.Equal(t, "whsec_from_env", fetched.Secret.GetValue()) +} + +func TestTableWebhookEndpoint_NilEventsStoredAsEmptyList(t *testing.T) { + db := setupTestDB(t) + + endpoint := &TableWebhookEndpoint{ + ID: "wh-events-1", + Name: "events-test", + URL: "https://receiver.example.com/hook", + Secret: &schemas.SecretVar{Val: "whsec_x"}, + } + require.NoError(t, db.Create(endpoint).Error) + + row := rawRow(t, db, "config_webhook_endpoints", "wh-events-1") + assert.Equal(t, "[]", row["events_json"]) +} + +func TestWebhookEndpointHeaderValidation(t *testing.T) { + endpoint := validatableWebhookEndpoint("header-test") + endpoint.Headers = map[string]schemas.SecretVar{"Authorization": {Val: "Bearer x"}} + require.NoError(t, endpoint.Validate()) + + for name, headers := range map[string]map[string]schemas.SecretVar{ + "reserved signing header": {"Webhook-Signature": {Val: "x"}}, + "reserved content type": {"Content-Type": {Val: "x"}}, + "invalid name": {"bad header": {Val: "x"}}, + "empty name": {"": {Val: "x"}}, + } { + endpoint := validatableWebhookEndpoint("header-test") + endpoint.Headers = headers + assert.Error(t, endpoint.Validate(), name) + } +} + +func TestTableWebhookEndpoint_HeadersEncryptDecrypt(t *testing.T) { + db := setupTestDB(t) + + endpoint := &TableWebhookEndpoint{ + ID: "wh-headers-1", + Name: "headers-test", + URL: "https://receiver.example.com/hook", + Events: []WebhookEvent{WebhookEventAsyncJobCompleted}, + Headers: map[string]schemas.SecretVar{ + "Authorization": {Val: "Bearer receiver-token"}, + "X-Env-Header": *schemas.NewSecretVar("env.WEBHOOK_HEADER_TOKEN"), + }, + } + require.NoError(t, db.Create(endpoint).Error) + + // The stored column must hold ciphertext, never plaintext header values. + row := rawRow(t, db, "config_webhook_endpoints", "wh-headers-1") + headersColumn, _ := row["headers_json"].(string) + require.NotEmpty(t, headersColumn) + assert.NotContains(t, headersColumn, "receiver-token") + assert.NotContains(t, headersColumn, "Authorization") + + // Reading back decrypts values and keeps env references as references. + var fetched TableWebhookEndpoint + require.NoError(t, db.First(&fetched, "id = ?", "wh-headers-1").Error) + require.Len(t, fetched.Headers, 2) + authHeader := fetched.Headers["Authorization"] + assert.Equal(t, "Bearer receiver-token", authHeader.GetValue()) + envHeader := fetched.Headers["X-Env-Header"] + assert.True(t, envHeader.IsFromSecret(), "env references must survive the round-trip as references") + assert.Equal(t, "env.WEBHOOK_HEADER_TOKEN", envHeader.GetRawRef()) +} diff --git a/transports/bifrost-http/lib/config_test.go b/transports/bifrost-http/lib/config_test.go index 91f3721375c..cc3c82a9f7b 100644 --- a/transports/bifrost-http/lib/config_test.go +++ b/transports/bifrost-http/lib/config_test.go @@ -1594,6 +1594,34 @@ func (m *MockConfigStore) MarkStaleSidekiqJobsFailed(ctx context.Context, staleB return 0, nil } +func (m *MockConfigStore) GetWebhookEndpoints(ctx context.Context) ([]tables.TableWebhookEndpoint, error) { + return nil, nil +} + +func (m *MockConfigStore) GetWebhookEndpointByID(ctx context.Context, id string) (*tables.TableWebhookEndpoint, error) { + return nil, configstore.ErrNotFound +} + +func (m *MockConfigStore) GetWebhookEndpointByName(ctx context.Context, name string) (*tables.TableWebhookEndpoint, error) { + return nil, configstore.ErrNotFound +} + +func (m *MockConfigStore) CreateWebhookEndpoint(ctx context.Context, endpoint *tables.TableWebhookEndpoint) error { + return nil +} + +func (m *MockConfigStore) UpdateWebhookEndpoint(ctx context.Context, endpoint *tables.TableWebhookEndpoint) error { + return nil +} + +func (m *MockConfigStore) DeleteWebhookEndpoint(ctx context.Context, id string) error { + return nil +} + +func (m *MockConfigStore) RotateWebhookEndpointSecret(ctx context.Context, id string) (*tables.TableWebhookEndpoint, error) { + return nil, configstore.ErrNotFound +} + func TestMergeGovernanceConfig_SyncsComplexityAnalyzerConfig(t *testing.T) { initTestLogger()