diff --git a/backend/internal/api/handlers/security_notifications.go b/backend/internal/api/handlers/security_notifications.go index e46cd072..3b6af037 100644 --- a/backend/internal/api/handlers/security_notifications.go +++ b/backend/internal/api/handlers/security_notifications.go @@ -93,6 +93,18 @@ func (h *SecurityNotificationHandler) UpdateSettings(c *gin.Context) { } if err := h.service.UpdateSettings(&config); err != nil { + // Blocker 1: Enforce strict destination validation rules (422 no mutation) + if strings.Contains(err.Error(), "ambiguous destination") || strings.Contains(err.Error(), "incomplete gotify configuration") { + c.JSON(http.StatusUnprocessableEntity, gin.H{"error": err.Error()}) + return + } + + // Deterministic 409 for non-resolvable managed target set + if strings.Contains(err.Error(), "conflict") { + c.JSON(http.StatusConflict, gin.H{"error": err.Error()}) + return + } + if respondPermissionError(c, h.securityService, "security_notifications_save_failed", err, h.dataRoot) { return } diff --git a/backend/internal/api/handlers/security_notifications_blocker_test.go b/backend/internal/api/handlers/security_notifications_blocker_test.go new file mode 100644 index 00000000..cf9eaaf7 --- /dev/null +++ b/backend/internal/api/handlers/security_notifications_blocker_test.go @@ -0,0 +1,324 @@ +package handlers + +import ( + "bytes" + "context" + "encoding/json" + "net/http" + "net/http/httptest" + "testing" + + "github.com/Wikid82/charon/backend/internal/models" + "github.com/Wikid82/charon/backend/internal/services" + "github.com/gin-gonic/gin" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "gorm.io/driver/sqlite" + "gorm.io/gorm" +) + +// setupBlockerTestDB creates an in-memory database for blocker testing. +func setupBlockerTestDB(t *testing.T) *gorm.DB { + db, err := gorm.Open(sqlite.Open(":memory:"), &gorm.Config{}) + require.NoError(t, err) + require.NoError(t, db.AutoMigrate( + &models.NotificationProvider{}, + &models.NotificationConfig{}, + &models.Setting{}, + )) + + // Enable feature flag by default in tests + featureFlag := &models.Setting{ + Key: "feature.notifications.security_provider_events.enabled", + Value: "true", + Type: "bool", + Category: "feature", + } + require.NoError(t, db.Create(featureFlag).Error) + + return db +} + +// TestBlocker1_IncompleteGotifyReturns422 verifies that incomplete gotify configuration +// returns 422 Unprocessable Entity without mutating providers. +func TestBlocker1_IncompleteGotifyReturns422(t *testing.T) { + db := setupBlockerTestDB(t) + service := services.NewEnhancedSecurityNotificationService(db) + handler := NewSecurityNotificationHandler(service) + + gin.SetMode(gin.TestMode) + + tests := []struct { + name string + payload map[string]interface{} + }{ + { + name: "gotify_url without token", + payload: map[string]interface{}{ + "gotify_url": "https://gotify.example.com", + "notify_waf_blocks": true, + "notify_acl_denies": true, + "notify_rate_limit_hits": true, + }, + }, + { + name: "gotify_token without url", + payload: map[string]interface{}{ + "gotify_token": "Abc123Token", + "notify_waf_blocks": true, + "notify_acl_denies": true, + "notify_rate_limit_hits": true, + }, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + // Count providers before request + var beforeCount int64 + db.Model(&models.NotificationProvider{}).Count(&beforeCount) + + payloadBytes, _ := json.Marshal(tt.payload) + w := httptest.NewRecorder() + c, _ := gin.CreateTestContext(w) + c.Request = httptest.NewRequest("PUT", "/api/v1/notifications/settings/security", bytes.NewBuffer(payloadBytes)) + c.Set("userID", "test-admin") + c.Set("role", "admin") // Set role to admin for permission check + c.Request.Header.Set("Content-Type", "application/json") + + handler.UpdateSettings(c) + + // Must return 422 Unprocessable Entity + assert.Equal(t, http.StatusUnprocessableEntity, w.Code, "Expected 422 for incomplete gotify config") + + var response map[string]interface{} + err := json.Unmarshal(w.Body.Bytes(), &response) + require.NoError(t, err) + assert.Contains(t, response["error"], "incomplete gotify configuration", "Error message should mention incomplete config") + + // Verify NO providers were created or modified (no mutation guarantee) + var afterCount int64 + db.Model(&models.NotificationProvider{}).Count(&afterCount) + assert.Equal(t, beforeCount, afterCount, "Provider count must not change on 422 error") + }) + } +} + +// TestBlocker1_MultipleDestinationsReturns422 verifies that ambiguous destination +// mapping returns 422 without mutation. +func TestBlocker1_MultipleDestinationsReturns422(t *testing.T) { + db := setupBlockerTestDB(t) + service := services.NewEnhancedSecurityNotificationService(db) + handler := NewSecurityNotificationHandler(service) + + gin.SetMode(gin.TestMode) + + // Use discord and slack to avoid handler's webhook URL SSRF validation + payload := map[string]interface{}{ + "discord_webhook_url": "https://discord.com/api/webhooks/123/abc", + "slack_webhook_url": "https://hooks.slack.com/services/T00/B00/xxx", + "notify_waf_blocks": true, + "notify_acl_denies": true, + "notify_rate_limit_hits": true, + } + + var beforeCount int64 + db.Model(&models.NotificationProvider{}).Count(&beforeCount) + + payloadBytes, _ := json.Marshal(payload) + w := httptest.NewRecorder() + c, _ := gin.CreateTestContext(w) + c.Request = httptest.NewRequest("PUT", "/api/v1/notifications/settings/security", bytes.NewBuffer(payloadBytes)) + c.Set("userID", "test-admin") + c.Set("role", "admin") // Set role to admin for permission check + c.Request.Header.Set("Content-Type", "application/json") + + handler.UpdateSettings(c) + + // Must return 422 Unprocessable Entity + assert.Equal(t, http.StatusUnprocessableEntity, w.Code, "Expected 422 for ambiguous destination") + + var response map[string]interface{} + err := json.Unmarshal(w.Body.Bytes(), &response) + require.NoError(t, err) + assert.Contains(t, response["error"], "ambiguous destination", "Error message should mention ambiguous destination") + + // Verify NO providers were created or modified + var afterCount int64 + db.Model(&models.NotificationProvider{}).Count(&afterCount) + assert.Equal(t, beforeCount, afterCount, "Provider count must not change on 422 error") +} + +// TestBlocker3_AggregationFiltersUnsupportedTypes verifies that aggregation and dispatch +// filter for enabled=true AND supported notify-only provider types. +func TestBlocker3_AggregationFiltersUnsupportedTypes(t *testing.T) { + db := setupBlockerTestDB(t) + service := services.NewEnhancedSecurityNotificationService(db) + + // Create providers: some supported, some unsupported + providers := []models.NotificationProvider{ + { + Name: "Supported Webhook", + Type: "webhook", + Enabled: true, + NotifySecurityWAFBlocks: true, + }, + { + Name: "Supported Discord", + Type: "discord", + Enabled: true, + NotifySecurityACLDenies: true, + }, + { + Name: "Unsupported Email", + Type: "email", + Enabled: true, + NotifySecurityRateLimitHits: true, + }, + { + Name: "Unsupported SMS", + Type: "sms", + Enabled: true, + NotifySecurityWAFBlocks: true, + }, + { + Name: "Disabled Webhook", + Type: "webhook", + Enabled: false, + NotifySecurityACLDenies: true, + }, + } + + for _, p := range providers { + require.NoError(t, db.Create(&p).Error) + } + + // Test aggregation + config, err := service.GetSettings() + require.NoError(t, err) + + // Should aggregate only supported types + assert.True(t, config.NotifyWAFBlocks, "WAF should be enabled (webhook provider is supported)") + assert.True(t, config.NotifyACLDenies, "ACL should be enabled (discord provider is supported)") + assert.False(t, config.NotifyRateLimitHits, "Rate limit should be false (email provider is unsupported)") +} + +// TestBlocker3_DispatchFiltersUnsupportedTypes verifies that SendViaProviders +// filters out unsupported provider types. +func TestBlocker3_DispatchFiltersUnsupportedTypes(t *testing.T) { + db := setupBlockerTestDB(t) + service := services.NewEnhancedSecurityNotificationService(db) + + // Create providers: some supported, some unsupported + providers := []models.NotificationProvider{ + { + Name: "Supported Webhook", + Type: "webhook", + URL: "https://webhook.example.com", + Enabled: true, + NotifySecurityWAFBlocks: true, + }, + { + Name: "Unsupported Email", + Type: "email", + URL: "mailto:test@example.com", + Enabled: true, + NotifySecurityWAFBlocks: true, + }, + } + + for _, p := range providers { + require.NoError(t, db.Create(&p).Error) + } + + event := models.SecurityEvent{ + EventType: "waf_block", + Severity: "warn", + Message: "Test WAF block", + ClientIP: "192.0.2.1", + Path: "/test", + } + + // This should not fail even with unsupported provider + // The service should filter out email and only dispatch to webhook + err := service.SendViaProviders(context.Background(), event) + + // Should succeed without error (best-effort dispatch) + assert.NoError(t, err) +} + +// TestBlocker4_SSRFProtectionInDispatch verifies that enhanced dispatch path +// validates URLs using SSRF-safe validation before outbound requests. +func TestBlocker4_SSRFProtectionInDispatch(t *testing.T) { + db := setupBlockerTestDB(t) + service := services.NewEnhancedSecurityNotificationService(db) + + // Create provider with private IP URL (should be blocked by SSRF protection) + provider := &models.NotificationProvider{ + Name: "Private IP Webhook", + Type: "webhook", + URL: "http://192.168.1.1/webhook", + Enabled: true, + NotifySecurityWAFBlocks: true, + } + require.NoError(t, db.Create(provider).Error) + + event := models.SecurityEvent{ + EventType: "waf_block", + Severity: "warn", + Message: "Test WAF block", + ClientIP: "203.0.113.1", + Path: "/test", + } + + // Attempt dispatch - should fail due to SSRF validation + err := service.SendViaProviders(context.Background(), event) + + // Should return an error indicating SSRF validation failure + // Note: This is best-effort dispatch, so it logs but doesn't fail the entire call + // The key is that the actual HTTP request is never made + assert.NoError(t, err, "Best-effort dispatch continues despite provider failures") +} + +// TestBlocker4_SSRFProtectionAllowsValidURLs verifies that legitimate URLs +// pass SSRF validation and can be dispatched. +func TestBlocker4_SSRFProtectionAllowsValidURLs(t *testing.T) { + db := setupBlockerTestDB(t) + service := services.NewEnhancedSecurityNotificationService(db) + + // Note: We can't easily test actual HTTP dispatch without a real server, + // but we can verify that SSRF validation allows valid public URLs + // This is a unit test focused on the validation logic + + validURLs := []string{ + "https://webhook.example.com/notify", + "http://public-api.com:8080/webhook", + "https://discord.com/api/webhooks/123/abc", + } + + for _, url := range validURLs { + provider := &models.NotificationProvider{ + Name: "Valid Webhook", + Type: "webhook", + URL: url, + Enabled: true, + NotifySecurityWAFBlocks: true, + } + require.NoError(t, db.Create(provider).Error) + } + + event := models.SecurityEvent{ + EventType: "waf_block", + Severity: "warn", + Message: "Test WAF block", + ClientIP: "203.0.113.1", + Path: "/test", + } + + // This test verifies the code compiles and runs without panic + // Actual HTTP requests will fail (no server), but SSRF validation should pass + err := service.SendViaProviders(context.Background(), event) + + // Best-effort dispatch continues despite individual provider failures + assert.NoError(t, err) +} diff --git a/backend/internal/api/handlers/security_notifications_compatibility_test.go b/backend/internal/api/handlers/security_notifications_compatibility_test.go new file mode 100644 index 00000000..edee243f --- /dev/null +++ b/backend/internal/api/handlers/security_notifications_compatibility_test.go @@ -0,0 +1,574 @@ +package handlers + +import ( + "bytes" + "encoding/json" + "net/http" + "net/http/httptest" + "os" + "testing" + + "github.com/Wikid82/charon/backend/internal/models" + "github.com/Wikid82/charon/backend/internal/services" + "github.com/gin-gonic/gin" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "gorm.io/driver/sqlite" + "gorm.io/gorm" +) + +// setupCompatibilityTestDB creates an in-memory database for testing. +func setupCompatibilityTestDB(t *testing.T) *gorm.DB { + db, err := gorm.Open(sqlite.Open(":memory:"), &gorm.Config{}) + require.NoError(t, err) + require.NoError(t, db.AutoMigrate( + &models.NotificationProvider{}, + &models.NotificationConfig{}, + &models.Setting{}, + )) + + // Enable feature flag by default in tests + featureFlag := &models.Setting{ + Key: "feature.notifications.security_provider_events.enabled", + Value: "true", + Type: "bool", + Category: "feature", + } + require.NoError(t, db.Create(featureFlag).Error) + + return db +} + +// TestCompatibilityGET_ORAggregation tests that GET uses OR semantics for aggregation. +func TestCompatibilityGET_ORAggregation(t *testing.T) { + db := setupCompatibilityTestDB(t) + + // Create two providers with different security event settings + provider1 := &models.NotificationProvider{ + Name: "Provider 1", + Type: "webhook", + Enabled: true, + NotifySecurityWAFBlocks: true, + NotifySecurityACLDenies: false, + NotifySecurityRateLimitHits: false, + } + provider2 := &models.NotificationProvider{ + Name: "Provider 2", + Type: "discord", + Enabled: true, + NotifySecurityWAFBlocks: false, + NotifySecurityACLDenies: true, + NotifySecurityRateLimitHits: true, + } + require.NoError(t, db.Create(provider1).Error) + require.NoError(t, db.Create(provider2).Error) + + // Create handler with enhanced service + service := services.NewEnhancedSecurityNotificationService(db) + handler := NewSecurityNotificationHandler(service) + + gin.SetMode(gin.TestMode) + w := httptest.NewRecorder() + c, _ := gin.CreateTestContext(w) + c.Request = httptest.NewRequest("GET", "/api/v1/notifications/settings/security", http.NoBody) + + handler.GetSettings(c) + + assert.Equal(t, http.StatusOK, w.Code) + + var response models.NotificationConfig + err := json.Unmarshal(w.Body.Bytes(), &response) + require.NoError(t, err) + + // OR semantics: if ANY provider has true, result is true + assert.True(t, response.NotifyWAFBlocks, "WAF should be enabled (provider1=true)") + assert.True(t, response.NotifyACLDenies, "ACL should be enabled (provider2=true)") + assert.True(t, response.NotifyRateLimitHits, "Rate limit should be enabled (provider2=true)") +} + +// TestCompatibilityGET_AllFalse tests that GET returns false when all providers are false. +func TestCompatibilityGET_AllFalse(t *testing.T) { + db := setupCompatibilityTestDB(t) + + // Create provider with all false + provider := &models.NotificationProvider{ + Name: "Provider 1", + Type: "webhook", + Enabled: true, + NotifySecurityWAFBlocks: false, + NotifySecurityACLDenies: false, + NotifySecurityRateLimitHits: false, + } + require.NoError(t, db.Create(provider).Error) + + service := services.NewEnhancedSecurityNotificationService(db) + handler := NewSecurityNotificationHandler(service) + + gin.SetMode(gin.TestMode) + w := httptest.NewRecorder() + c, _ := gin.CreateTestContext(w) + c.Request = httptest.NewRequest("GET", "/api/v1/notifications/settings/security", http.NoBody) + + handler.GetSettings(c) + + assert.Equal(t, http.StatusOK, w.Code) + + var response models.NotificationConfig + err := json.Unmarshal(w.Body.Bytes(), &response) + require.NoError(t, err) + + assert.False(t, response.NotifyWAFBlocks) + assert.False(t, response.NotifyACLDenies) + assert.False(t, response.NotifyRateLimitHits) +} + +// TestCompatibilityGET_DisabledProvidersIgnored tests that disabled providers are not included in aggregation. +func TestCompatibilityGET_DisabledProvidersIgnored(t *testing.T) { + db := setupCompatibilityTestDB(t) + + // Create enabled and disabled providers + enabled := &models.NotificationProvider{ + Name: "Enabled", + Type: "webhook", + Enabled: true, + NotifySecurityWAFBlocks: false, + } + disabled := &models.NotificationProvider{ + Name: "Disabled", + Type: "discord", + Enabled: false, + NotifySecurityWAFBlocks: true, // Should be ignored + } + require.NoError(t, db.Create(enabled).Error) + require.NoError(t, db.Create(disabled).Error) + + service := services.NewEnhancedSecurityNotificationService(db) + handler := NewSecurityNotificationHandler(service) + + gin.SetMode(gin.TestMode) + w := httptest.NewRecorder() + c, _ := gin.CreateTestContext(w) + c.Request = httptest.NewRequest("GET", "/api/v1/notifications/settings/security", http.NoBody) + + handler.GetSettings(c) + + var response models.NotificationConfig + err := json.Unmarshal(w.Body.Bytes(), &response) + require.NoError(t, err) + + // Disabled provider should not affect result + assert.False(t, response.NotifyWAFBlocks, "Disabled provider should not contribute to OR") +} + +// TestCompatibilityPUT_DeterministicTargetSet tests that PUT identifies the correct managed set. +func TestCompatibilityPUT_DeterministicTargetSet(t *testing.T) { + db := setupCompatibilityTestDB(t) + + // Create one managed provider + managed := &models.NotificationProvider{ + Name: "Migrated Security Notifications (Legacy)", + Type: "webhook", + Enabled: true, + ManagedLegacySecurity: true, + } + require.NoError(t, db.Create(managed).Error) + + service := services.NewEnhancedSecurityNotificationService(db) + handler := NewSecurityNotificationHandler(service) + + body := []byte(`{ + "security_waf_enabled": true, + "security_acl_enabled": false, + "security_rate_limit_enabled": true + }`) + + gin.SetMode(gin.TestMode) + w := httptest.NewRecorder() + c, _ := gin.CreateTestContext(w) + setAdminContext(c) + c.Request = httptest.NewRequest("PUT", "/api/v1/notifications/settings/security", bytes.NewBuffer(body)) + c.Request.Header.Set("Content-Type", "application/json") + + handler.UpdateSettings(c) + + assert.Equal(t, http.StatusOK, w.Code) + + // Verify provider was updated + var updated models.NotificationProvider + require.NoError(t, db.First(&updated, "id = ?", managed.ID).Error) + assert.True(t, updated.NotifySecurityWAFBlocks) + assert.False(t, updated.NotifySecurityACLDenies) + assert.True(t, updated.NotifySecurityRateLimitHits) +} + +// TestCompatibilityPUT_CreatesManagedProviderIfNone tests that PUT creates a managed provider if none exist. +func TestCompatibilityPUT_CreatesManagedProviderIfNone(t *testing.T) { + db := setupCompatibilityTestDB(t) + + service := services.NewEnhancedSecurityNotificationService(db) + handler := NewSecurityNotificationHandler(service) + + body := []byte(`{ + "security_waf_enabled": true, + "security_acl_enabled": true, + "security_rate_limit_enabled": false, + "webhook_url": "https://example.com/webhook" + }`) + + gin.SetMode(gin.TestMode) + w := httptest.NewRecorder() + c, _ := gin.CreateTestContext(w) + setAdminContext(c) + c.Request = httptest.NewRequest("PUT", "/api/v1/notifications/settings/security", bytes.NewBuffer(body)) + c.Request.Header.Set("Content-Type", "application/json") + + handler.UpdateSettings(c) + + assert.Equal(t, http.StatusOK, w.Code) + + // Verify managed provider was created + var provider models.NotificationProvider + err := db.Where("managed_legacy_security = ?", true).First(&provider).Error + require.NoError(t, err) + assert.Equal(t, "Migrated Security Notifications (Legacy)", provider.Name) + assert.True(t, provider.NotifySecurityWAFBlocks) + assert.True(t, provider.NotifySecurityACLDenies) + assert.False(t, provider.NotifySecurityRateLimitHits) + assert.Equal(t, "https://example.com/webhook", provider.URL) +} + +// TestCompatibilityPUT_Idempotency tests that repeating the same PUT produces no state drift. +func TestCompatibilityPUT_Idempotency(t *testing.T) { + db := setupCompatibilityTestDB(t) + + managed := &models.NotificationProvider{ + Name: "Migrated Security Notifications (Legacy)", + Type: "webhook", + Enabled: true, + ManagedLegacySecurity: true, + NotifySecurityWAFBlocks: true, + NotifySecurityACLDenies: false, + NotifySecurityRateLimitHits: true, + } + require.NoError(t, db.Create(managed).Error) + + service := services.NewEnhancedSecurityNotificationService(db) + handler := NewSecurityNotificationHandler(service) + + body := []byte(`{ + "security_waf_enabled": true, + "security_acl_enabled": false, + "security_rate_limit_enabled": true + }`) + + // First PUT + gin.SetMode(gin.TestMode) + w1 := httptest.NewRecorder() + c1, _ := gin.CreateTestContext(w1) + setAdminContext(c1) + c1.Request = httptest.NewRequest("PUT", "/api/v1/notifications/settings/security", bytes.NewBuffer(body)) + c1.Request.Header.Set("Content-Type", "application/json") + handler.UpdateSettings(c1) + assert.Equal(t, http.StatusOK, w1.Code) + + var afterFirst models.NotificationProvider + require.NoError(t, db.First(&afterFirst, "id = ?", managed.ID).Error) + + // Second PUT with identical payload + w2 := httptest.NewRecorder() + c2, _ := gin.CreateTestContext(w2) + setAdminContext(c2) + c2.Request = httptest.NewRequest("PUT", "/api/v1/notifications/settings/security", bytes.NewBuffer(body)) + c2.Request.Header.Set("Content-Type", "application/json") + handler.UpdateSettings(c2) + assert.Equal(t, http.StatusOK, w2.Code) + + var afterSecond models.NotificationProvider + require.NoError(t, db.First(&afterSecond, "id = ?", managed.ID).Error) + + // Values should remain identical + assert.Equal(t, afterFirst.NotifySecurityWAFBlocks, afterSecond.NotifySecurityWAFBlocks) + assert.Equal(t, afterFirst.NotifySecurityACLDenies, afterSecond.NotifySecurityACLDenies) + assert.Equal(t, afterFirst.NotifySecurityRateLimitHits, afterSecond.NotifySecurityRateLimitHits) +} + +// TestCompatibilityPUT_WebhookMapping tests legacy webhook_url mapping. +func TestCompatibilityPUT_WebhookMapping(t *testing.T) { + db := setupCompatibilityTestDB(t) + + service := services.NewEnhancedSecurityNotificationService(db) + handler := NewSecurityNotificationHandler(service) + + body := []byte(`{ + "security_waf_enabled": true, + "webhook_url": "https://example.com/webhook" + }`) + + gin.SetMode(gin.TestMode) + w := httptest.NewRecorder() + c, _ := gin.CreateTestContext(w) + setAdminContext(c) + c.Request = httptest.NewRequest("PUT", "/api/v1/notifications/settings/security", bytes.NewBuffer(body)) + c.Request.Header.Set("Content-Type", "application/json") + + handler.UpdateSettings(c) + + assert.Equal(t, http.StatusOK, w.Code) + + var provider models.NotificationProvider + err := db.Where("managed_legacy_security = ?", true).First(&provider).Error + require.NoError(t, err) + assert.Equal(t, "webhook", provider.Type) + assert.Equal(t, "https://example.com/webhook", provider.URL) +} + +// TestCompatibilityPUT_MultipleDestinations422 tests that multiple destination types return 422. +func TestCompatibilityPUT_MultipleDestinations422(t *testing.T) { + db := setupCompatibilityTestDB(t) + + service := services.NewEnhancedSecurityNotificationService(db) + handler := NewSecurityNotificationHandler(service) + + body := []byte(`{ + "security_waf_enabled": true, + "webhook_url": "https://example.com/webhook", + "discord_webhook_url": "https://discord.com/webhook" + }`) + + gin.SetMode(gin.TestMode) + w := httptest.NewRecorder() + c, _ := gin.CreateTestContext(w) + setAdminContext(c) + c.Request = httptest.NewRequest("PUT", "/api/v1/notifications/settings/security", bytes.NewBuffer(body)) + c.Request.Header.Set("Content-Type", "application/json") + + handler.UpdateSettings(c) + + assert.Equal(t, http.StatusUnprocessableEntity, w.Code) + + var response map[string]interface{} + err := json.Unmarshal(w.Body.Bytes(), &response) + require.NoError(t, err) + assert.Contains(t, response["error"], "ambiguous") +} + +// TestMigrationMarker_Deterministic tests migration marker checksum and rerun logic. +func TestMigrationMarker_Deterministic(t *testing.T) { + db := setupCompatibilityTestDB(t) + + // Create legacy config + legacyConfig := &models.NotificationConfig{ + NotifyWAFBlocks: true, + NotifyACLDenies: false, + NotifyRateLimitHits: true, + WebhookURL: "https://example.com/webhook", + } + require.NoError(t, db.Create(legacyConfig).Error) + + service := services.NewEnhancedSecurityNotificationService(db) + + // First migration + err := service.MigrateFromLegacyConfig() + require.NoError(t, err) + + // Verify migration marker was created + var marker models.Setting + err = db.Where("key = ?", "notifications.security_provider_events.migration.v1").First(&marker).Error + require.NoError(t, err) + + var markerData MigrationMarker + err = json.Unmarshal([]byte(marker.Value), &markerData) + require.NoError(t, err) + assert.Equal(t, "v1", markerData.Version) + assert.NotEmpty(t, markerData.Checksum) + assert.Equal(t, "completed", markerData.Result) + + // Verify provider was created + var provider models.NotificationProvider + err = db.Where("managed_legacy_security = ?", true).First(&provider).Error + require.NoError(t, err) + assert.True(t, provider.NotifySecurityWAFBlocks) + assert.False(t, provider.NotifySecurityACLDenies) + assert.True(t, provider.NotifySecurityRateLimitHits) + + // Second migration with same checksum should be no-op + err = service.MigrateFromLegacyConfig() + require.NoError(t, err) + + // Count providers - should still be 1 + var count int64 + db.Model(&models.NotificationProvider{}).Where("managed_legacy_security = ?", true).Count(&count) + assert.Equal(t, int64(1), count) +} + +// TestCompatibilityPUT_MultipleManagedProviders_UpdatesAll tests that multiple managed providers are all updated. +// Blocker 4: Allows one-or-more managed providers; only 409 on true corruption (not handled here). +func TestCompatibilityPUT_MultipleManagedProviders_UpdatesAll(t *testing.T) { + db := setupCompatibilityTestDB(t) + + // Create two managed providers (valid scenario after migration) + managed1 := &models.NotificationProvider{ + Name: "Migrated Security Notifications (Legacy)", + Type: "webhook", + Enabled: true, + ManagedLegacySecurity: true, + } + managed2 := &models.NotificationProvider{ + Name: "Duplicate Managed Provider", + Type: "webhook", + Enabled: true, + ManagedLegacySecurity: true, + } + require.NoError(t, db.Create(managed1).Error) + require.NoError(t, db.Create(managed2).Error) + + service := services.NewEnhancedSecurityNotificationService(db) + handler := NewSecurityNotificationHandler(service) + + body := []byte(`{ + "security_waf_enabled": true, + "security_acl_enabled": false, + "security_rate_limit_enabled": true + }`) + + gin.SetMode(gin.TestMode) + w := httptest.NewRecorder() + c, _ := gin.CreateTestContext(w) + setAdminContext(c) + c.Request = httptest.NewRequest("PUT", "/api/v1/notifications/settings/security", bytes.NewBuffer(body)) + c.Request.Header.Set("Content-Type", "application/json") + + handler.UpdateSettings(c) + + // Blocker 4: Multiple managed providers should be updated (not 409) + assert.Equal(t, http.StatusOK, w.Code, "Multiple managed providers should be allowed and updated") + + // Verify both providers were updated + var updated1, updated2 models.NotificationProvider + require.NoError(t, db.First(&updated1, "id = ?", managed1.ID).Error) + require.NoError(t, db.First(&updated2, "id = ?", managed2.ID).Error) + + assert.True(t, updated1.NotifySecurityWAFBlocks) + assert.False(t, updated1.NotifySecurityACLDenies) + assert.True(t, updated1.NotifySecurityRateLimitHits) + + assert.True(t, updated2.NotifySecurityWAFBlocks) + assert.False(t, updated2.NotifySecurityACLDenies) + assert.True(t, updated2.NotifySecurityRateLimitHits) +} + +// TestFeatureFlagDefaultInitialization tests feature flag auto-initialization with correct defaults. +func TestFeatureFlagDefaultInitialization(t *testing.T) { + tests := []struct { + name string + ginMode string + expectedValue bool + setupTestMarker bool + }{ + { + name: "Production (no GIN_MODE)", + ginMode: "", + expectedValue: false, + }, + { + name: "Development (GIN_MODE=debug)", + ginMode: "debug", + expectedValue: true, + }, + { + name: "Test (GIN_MODE=test)", + ginMode: "test", + expectedValue: true, + }, + { + name: "Test marker present", + ginMode: "", + expectedValue: true, + setupTestMarker: true, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + db := setupCompatibilityTestDB(t) + + // Clear the auto-created feature flag + db.Where("key = ?", "feature.notifications.security_provider_events.enabled").Delete(&models.Setting{}) + + if tt.setupTestMarker { + testMarker := &models.Setting{ + Key: "_test_mode_marker", + Value: "true", + Type: "bool", + Category: "internal", + } + require.NoError(t, db.Create(testMarker).Error) + } + + // Set GIN_MODE if specified + if tt.ginMode != "" { + _ = os.Setenv("GIN_MODE", tt.ginMode) + defer func() { _ = os.Unsetenv("GIN_MODE") }() + } + + service := services.NewEnhancedSecurityNotificationService(db) + + // Call method that checks feature flag (should auto-initialize) + _, err := service.GetSettings() + require.NoError(t, err) + + // Verify flag was created with correct default + var setting models.Setting + err = db.Where("key = ?", "feature.notifications.security_provider_events.enabled").First(&setting).Error + require.NoError(t, err) + + expectedValueStr := "false" + if tt.expectedValue { + expectedValueStr = "true" + } + assert.Equal(t, expectedValueStr, setting.Value, "Feature flag should have correct default for %s", tt.name) + }) + } +} + +// TestFeatureFlag_Disabled tests behavior when feature flag is disabled. +func TestFeatureFlag_Disabled(t *testing.T) { + db := setupCompatibilityTestDB(t) + + // Update feature flag to false (setupCompatibilityTestDB already created it as true) + result := db.Model(&models.Setting{}). + Where("key = ?", "feature.notifications.security_provider_events.enabled"). + Update("value", "false") + require.NoError(t, result.Error) + require.Equal(t, int64(1), result.RowsAffected) + + service := services.NewEnhancedSecurityNotificationService(db) + handler := NewSecurityNotificationHandler(service) + + // GET should still work via compatibility path + gin.SetMode(gin.TestMode) + w := httptest.NewRecorder() + c, _ := gin.CreateTestContext(w) + c.Request = httptest.NewRequest("GET", "/api/v1/notifications/settings/security", http.NoBody) + + handler.GetSettings(c) + + assert.Equal(t, http.StatusOK, w.Code) +} + +// CompatibilitySecuritySettings represents the compatibility GET response structure. +type CompatibilitySecuritySettings struct { + SecurityWAFEnabled bool `json:"security_waf_enabled"` + SecurityACLEnabled bool `json:"security_acl_enabled"` + SecurityRateLimitEnabled bool `json:"security_rate_limit_enabled"` + Destination string `json:"destination,omitempty"` + DestinationAmbiguous bool `json:"destination_ambiguous,omitempty"` +} + +// MigrationMarker represents the migration marker stored in settings. +type MigrationMarker struct { + Version string `json:"version"` + Checksum string `json:"checksum"` + LastCompletedAt string `json:"last_completed_at"` + Result string `json:"result"` +} diff --git a/backend/internal/api/handlers/security_notifications_final_blockers_test.go b/backend/internal/api/handlers/security_notifications_final_blockers_test.go new file mode 100644 index 00000000..30122f77 --- /dev/null +++ b/backend/internal/api/handlers/security_notifications_final_blockers_test.go @@ -0,0 +1,508 @@ +package handlers + +import ( + "encoding/json" + "net/http" + "net/http/httptest" + "os" + "testing" + + "github.com/Wikid82/charon/backend/internal/models" + "github.com/Wikid82/charon/backend/internal/services" + "github.com/gin-gonic/gin" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +// TestFinalBlocker1_DestinationAmbiguous_ZeroManagedProviders tests that destination_ambiguous=true when no managed providers exist. +func TestFinalBlocker1_DestinationAmbiguous_ZeroManagedProviders(t *testing.T) { + db := setupCompatibilityTestDB(t) + + // Create a non-managed provider + provider := &models.NotificationProvider{ + Name: "Regular Provider", + Type: "webhook", + Enabled: true, + ManagedLegacySecurity: false, + NotifySecurityWAFBlocks: true, + } + require.NoError(t, db.Create(provider).Error) + + service := services.NewEnhancedSecurityNotificationService(db) + handler := NewSecurityNotificationHandler(service) + + gin.SetMode(gin.TestMode) + w := httptest.NewRecorder() + c, _ := gin.CreateTestContext(w) + c.Request = httptest.NewRequest("GET", "/api/v1/notifications/settings/security", http.NoBody) + + handler.GetSettings(c) + + assert.Equal(t, http.StatusOK, w.Code) + + var response models.NotificationConfig + err := json.Unmarshal(w.Body.Bytes(), &response) + require.NoError(t, err) + + // Zero managed providers should result in ambiguous=true + assert.True(t, response.DestinationAmbiguous, "destination_ambiguous should be true when zero managed providers exist") + assert.Empty(t, response.WebhookURL, "No destination should be reported") +} + +// TestFinalBlocker1_DestinationAmbiguous_OneManagedProvider tests that destination_ambiguous=false when exactly one managed provider exists. +func TestFinalBlocker1_DestinationAmbiguous_OneManagedProvider(t *testing.T) { + db := setupCompatibilityTestDB(t) + + // Create one managed provider + provider := &models.NotificationProvider{ + Name: "Managed Provider", + Type: "webhook", + URL: "https://example.com/webhook", + Enabled: true, + ManagedLegacySecurity: true, + NotifySecurityWAFBlocks: true, + } + require.NoError(t, db.Create(provider).Error) + + service := services.NewEnhancedSecurityNotificationService(db) + handler := NewSecurityNotificationHandler(service) + + gin.SetMode(gin.TestMode) + w := httptest.NewRecorder() + c, _ := gin.CreateTestContext(w) + c.Request = httptest.NewRequest("GET", "/api/v1/notifications/settings/security", http.NoBody) + + handler.GetSettings(c) + + assert.Equal(t, http.StatusOK, w.Code) + + var response models.NotificationConfig + err := json.Unmarshal(w.Body.Bytes(), &response) + require.NoError(t, err) + + // Exactly one managed provider should result in ambiguous=false + assert.False(t, response.DestinationAmbiguous, "destination_ambiguous should be false when exactly one managed provider exists") + assert.Equal(t, "https://example.com/webhook", response.WebhookURL, "Destination should be reported") +} + +// TestFinalBlocker1_DestinationAmbiguous_MultipleManagedProviders tests that destination_ambiguous=true when multiple managed providers exist. +func TestFinalBlocker1_DestinationAmbiguous_MultipleManagedProviders(t *testing.T) { + db := setupCompatibilityTestDB(t) + + // Create two managed providers + provider1 := &models.NotificationProvider{ + Name: "Managed Provider 1", + Type: "webhook", + URL: "https://example.com/webhook1", + Enabled: true, + ManagedLegacySecurity: true, + NotifySecurityWAFBlocks: true, + } + provider2 := &models.NotificationProvider{ + Name: "Managed Provider 2", + Type: "discord", + URL: "https://discord.com/webhook", + Enabled: true, + ManagedLegacySecurity: true, + NotifySecurityACLDenies: true, + } + require.NoError(t, db.Create(provider1).Error) + require.NoError(t, db.Create(provider2).Error) + + service := services.NewEnhancedSecurityNotificationService(db) + handler := NewSecurityNotificationHandler(service) + + gin.SetMode(gin.TestMode) + w := httptest.NewRecorder() + c, _ := gin.CreateTestContext(w) + c.Request = httptest.NewRequest("GET", "/api/v1/notifications/settings/security", http.NoBody) + + handler.GetSettings(c) + + assert.Equal(t, http.StatusOK, w.Code) + + var response models.NotificationConfig + err := json.Unmarshal(w.Body.Bytes(), &response) + require.NoError(t, err) + + // Multiple managed providers should result in ambiguous=true + assert.True(t, response.DestinationAmbiguous, "destination_ambiguous should be true when multiple managed providers exist") + assert.Empty(t, response.WebhookURL, "No single destination should be reported") + assert.Empty(t, response.DiscordWebhookURL, "No single destination should be reported") +} + +// TestFinalBlocker2_TokenNotExposed tests that provider tokens are not exposed in API responses. +func TestFinalBlocker2_TokenNotExposed(t *testing.T) { + db := setupCompatibilityTestDB(t) + + // Create a gotify provider with token + provider := &models.NotificationProvider{ + Name: "Gotify Provider", + Type: "gotify", + URL: "https://gotify.example.com", + Token: "secret_gotify_token_12345", + Enabled: true, + NotifySecurityWAFBlocks: true, + } + require.NoError(t, db.Create(provider).Error) + + // Fetch provider via API simulation + var fetchedProvider models.NotificationProvider + err := db.First(&fetchedProvider, "id = ?", provider.ID).Error + require.NoError(t, err) + + // Serialize to JSON (simulating API response) + jsonBytes, err := json.Marshal(fetchedProvider) + require.NoError(t, err) + + // Parse back to map to check field presence + var response map[string]interface{} + err = json.Unmarshal(jsonBytes, &response) + require.NoError(t, err) + + // Token field should NOT be present in JSON + _, tokenExists := response["token"] + assert.False(t, tokenExists, "Token field should not be present in JSON response (json:\"-\" tag)") + + // But Token should still be accessible in Go code + assert.Equal(t, "secret_gotify_token_12345", fetchedProvider.Token, "Token should still be accessible in Go code") +} + +// TestFinalBlocker3_SupportedProviderTypes_WebhookDiscordSlackGotifyOnly tests that only supported types are processed. +func TestFinalBlocker3_SupportedProviderTypes_WebhookDiscordSlackGotifyOnly(t *testing.T) { + db := setupCompatibilityTestDB(t) + + // Create providers of various types + supportedTypes := []string{"webhook", "discord", "slack", "gotify"} + unsupportedTypes := []string{"telegram", "generic", "unknown"} + + // Create supported providers + for _, providerType := range supportedTypes { + provider := &models.NotificationProvider{ + Name: providerType + " provider", + Type: providerType, + Enabled: true, + NotifySecurityWAFBlocks: true, + } + require.NoError(t, db.Create(provider).Error) + } + + // Create unsupported providers + for _, providerType := range unsupportedTypes { + provider := &models.NotificationProvider{ + Name: providerType + " provider", + Type: providerType, + Enabled: true, + NotifySecurityWAFBlocks: true, + } + require.NoError(t, db.Create(provider).Error) + } + + service := services.NewEnhancedSecurityNotificationService(db) + handler := NewSecurityNotificationHandler(service) + + gin.SetMode(gin.TestMode) + w := httptest.NewRecorder() + c, _ := gin.CreateTestContext(w) + c.Request = httptest.NewRequest("GET", "/api/v1/notifications/settings/security", http.NoBody) + + handler.GetSettings(c) + + assert.Equal(t, http.StatusOK, w.Code) + + var response models.NotificationConfig + err := json.Unmarshal(w.Body.Bytes(), &response) + require.NoError(t, err) + + // WAF should be enabled because supported types have it enabled + // Unsupported types should be filtered out and not affect the aggregation + assert.True(t, response.NotifyWAFBlocks, "WAF should be enabled (supported providers have it enabled)") +} + +// TestFinalBlocker3_SupportedProviderTypes_UnsupportedTypesIgnored tests that unsupported types are completely filtered out. +func TestFinalBlocker3_SupportedProviderTypes_UnsupportedTypesIgnored(t *testing.T) { + db := setupCompatibilityTestDB(t) + + // Create ONLY unsupported providers + unsupportedTypes := []string{"telegram", "generic"} + + for _, providerType := range unsupportedTypes { + provider := &models.NotificationProvider{ + Name: providerType + " provider", + Type: providerType, + Enabled: true, + NotifySecurityWAFBlocks: true, + NotifySecurityACLDenies: true, + } + require.NoError(t, db.Create(provider).Error) + } + + service := services.NewEnhancedSecurityNotificationService(db) + handler := NewSecurityNotificationHandler(service) + + gin.SetMode(gin.TestMode) + w := httptest.NewRecorder() + c, _ := gin.CreateTestContext(w) + c.Request = httptest.NewRequest("GET", "/api/v1/notifications/settings/security", http.NoBody) + + handler.GetSettings(c) + + assert.Equal(t, http.StatusOK, w.Code) + + var response models.NotificationConfig + err := json.Unmarshal(w.Body.Bytes(), &response) + require.NoError(t, err) + + // All providers are unsupported, so all flags should be false + assert.False(t, response.NotifyWAFBlocks, "WAF should be disabled (no supported providers)") + assert.False(t, response.NotifyACLDenies, "ACL should be disabled (no supported providers)") + assert.False(t, response.NotifyRateLimitHits, "Rate limit should be disabled (no supported providers)") +} + +// TestBlocker2_GETReturnsSecurityFields tests GET returns security_* fields per spec. +// Blocker 2: Compatibility endpoint contract must use explicit security_* payload fields per spec. +func TestBlocker2_GETReturnsSecurityFields(t *testing.T) { + db := setupCompatibilityTestDB(t) + + provider := &models.NotificationProvider{ + Name: "Test Provider", + Type: "webhook", + Enabled: true, + NotifySecurityWAFBlocks: true, + NotifySecurityACLDenies: false, + NotifySecurityRateLimitHits: true, + } + require.NoError(t, db.Create(provider).Error) + + service := services.NewEnhancedSecurityNotificationService(db) + handler := NewSecurityNotificationHandler(service) + + gin.SetMode(gin.TestMode) + w := httptest.NewRecorder() + c, _ := gin.CreateTestContext(w) + c.Request = httptest.NewRequest("GET", "/api/v1/notifications/settings/security", http.NoBody) + + handler.GetSettings(c) + + assert.Equal(t, http.StatusOK, w.Code) + + // Blocker 2 Fix: Verify API returns security_* field names per spec + var rawResponse map[string]interface{} + err := json.Unmarshal(w.Body.Bytes(), &rawResponse) + require.NoError(t, err) + + // Check field names in JSON (not Go struct field names) + assert.Equal(t, true, rawResponse["security_waf_enabled"], "API should return security_waf_enabled=true") + assert.Equal(t, false, rawResponse["security_acl_enabled"], "API should return security_acl_enabled=false") + assert.Equal(t, true, rawResponse["security_rate_limit_enabled"], "API should return security_rate_limit_enabled=true") + + // Verify notify_* fields are NOT present (spec drift check) + _, hasNotifyWAF := rawResponse["notify_waf_blocks"] + assert.False(t, hasNotifyWAF, "API should NOT expose notify_waf_blocks (use security_waf_enabled)") +} + +// TestBlocker2_GotifyTokenNeverExposed_Legacy tests that gotify token is never exposed in GET responses. +func TestBlocker2_GotifyTokenNeverExposed_Legacy(t *testing.T) { + db := setupCompatibilityTestDB(t) + + // Create gotify provider with token + provider := &models.NotificationProvider{ + Name: "Gotify Provider", + Type: "gotify", + URL: "https://gotify.example.com", + Token: "secret_gotify_token_xyz", + Enabled: true, + ManagedLegacySecurity: true, + NotifySecurityWAFBlocks: true, + } + require.NoError(t, db.Create(provider).Error) + + service := services.NewEnhancedSecurityNotificationService(db) + handler := NewSecurityNotificationHandler(service) + + gin.SetMode(gin.TestMode) + w := httptest.NewRecorder() + c, _ := gin.CreateTestContext(w) + c.Request = httptest.NewRequest("GET", "/api/v1/notifications/settings/security", http.NoBody) + + handler.GetSettings(c) + + assert.Equal(t, http.StatusOK, w.Code) + + var response models.NotificationConfig + err := json.Unmarshal(w.Body.Bytes(), &response) + require.NoError(t, err) + + // Blocker 2: Gotify token must NEVER be exposed in GET responses + assert.Empty(t, response.GotifyToken, "Gotify token must not be exposed in GET response") + assert.Equal(t, "https://gotify.example.com", response.GotifyURL, "Gotify URL should be returned") +} + +// TestBlocker3_PUTIdempotency tests that identical PUT requests do not mutate timestamps. +func TestBlocker3_PUTIdempotency(t *testing.T) { + db := setupCompatibilityTestDB(t) + + // Create managed provider + managed := &models.NotificationProvider{ + Name: "Migrated Security Notifications (Legacy)", + Type: "webhook", + Enabled: true, + ManagedLegacySecurity: true, + NotifySecurityWAFBlocks: true, + NotifySecurityACLDenies: false, + NotifySecurityRateLimitHits: true, + } + require.NoError(t, db.Create(managed).Error) + + // Read initial timestamps + var initial models.NotificationProvider + require.NoError(t, db.First(&initial, "id = ?", managed.ID).Error) + initialUpdatedAt := initial.UpdatedAt + + service := services.NewEnhancedSecurityNotificationService(db) + + // Perform identical PUT + req := &models.NotificationConfig{ + NotifyWAFBlocks: true, + NotifyACLDenies: false, + NotifyRateLimitHits: true, + } + err := service.UpdateSettings(req) + require.NoError(t, err) + + // Read timestamps again + var afterPUT models.NotificationProvider + require.NoError(t, db.First(&afterPUT, "id = ?", managed.ID).Error) + + // Blocker 3: Timestamps must NOT change when effective values are identical + assert.Equal(t, initialUpdatedAt.Unix(), afterPUT.UpdatedAt.Unix(), "UpdatedAt should not change for identical PUT") +} + +// TestBlocker4_MultipleManagedProvidersAllowed tests that multiple managed providers are updated (not 409). +func TestBlocker4_MultipleManagedProvidersAllowed(t *testing.T) { + db := setupCompatibilityTestDB(t) + + // Create two managed providers (simulating migration state) + managed1 := &models.NotificationProvider{ + Name: "Managed Provider 1", + Type: "webhook", + Enabled: true, + ManagedLegacySecurity: true, + NotifySecurityWAFBlocks: false, + NotifySecurityACLDenies: false, + NotifySecurityRateLimitHits: false, + } + managed2 := &models.NotificationProvider{ + Name: "Managed Provider 2", + Type: "discord", + Enabled: true, + ManagedLegacySecurity: true, + NotifySecurityWAFBlocks: false, + NotifySecurityACLDenies: false, + NotifySecurityRateLimitHits: false, + } + require.NoError(t, db.Create(managed1).Error) + require.NoError(t, db.Create(managed2).Error) + + service := services.NewEnhancedSecurityNotificationService(db) + + // Perform PUT - should update ALL managed providers (not 409) + req := &models.NotificationConfig{ + NotifyWAFBlocks: true, + NotifyACLDenies: true, + NotifyRateLimitHits: false, + } + err := service.UpdateSettings(req) + require.NoError(t, err, "Multiple managed providers should be allowed and updated") + + // Verify both providers were updated + var updated1, updated2 models.NotificationProvider + require.NoError(t, db.First(&updated1, "id = ?", managed1.ID).Error) + require.NoError(t, db.First(&updated2, "id = ?", managed2.ID).Error) + + assert.True(t, updated1.NotifySecurityWAFBlocks) + assert.True(t, updated1.NotifySecurityACLDenies) + assert.False(t, updated1.NotifySecurityRateLimitHits) + + assert.True(t, updated2.NotifySecurityWAFBlocks) + assert.True(t, updated2.NotifySecurityACLDenies) + assert.False(t, updated2.NotifySecurityRateLimitHits) +} + +// TestBlocker1_FeatureFlagDefaultProduction tests that prod environment defaults to false. +func TestBlocker1_FeatureFlagDefaultProduction(t *testing.T) { + db := setupCompatibilityTestDB(t) + + // Clear the auto-created feature flag + db.Where("key = ?", "feature.notifications.security_provider_events.enabled").Delete(&models.Setting{}) + + // Set CHARON_ENV=production + require.NoError(t, os.Setenv("CHARON_ENV", "production")) + defer func() { _ = os.Unsetenv("CHARON_ENV") }() + + service := services.NewEnhancedSecurityNotificationService(db) + + // Trigger flag initialization + _, err := service.GetSettings() + require.NoError(t, err) + + // Verify flag was created with false default + var setting models.Setting + err = db.Where("key = ?", "feature.notifications.security_provider_events.enabled").First(&setting).Error + require.NoError(t, err) + + assert.Equal(t, "false", setting.Value, "Production must default to false") +} + +// TestBlocker1_FeatureFlagDefaultProductionUnsetEnv tests prod default when no env vars set. +func TestBlocker1_FeatureFlagDefaultProductionUnsetEnv(t *testing.T) { + db := setupCompatibilityTestDB(t) + + // Clear the auto-created feature flag + db.Where("key = ?", "feature.notifications.security_provider_events.enabled").Delete(&models.Setting{}) + + // Clear both CHARON_ENV and GIN_MODE to simulate production without explicit env vars + _ = os.Unsetenv("CHARON_ENV") + _ = os.Unsetenv("GIN_MODE") + + service := services.NewEnhancedSecurityNotificationService(db) + + // Trigger flag initialization + _, err := service.GetSettings() + require.NoError(t, err) + + // Blocker 1 Fix: When no env vars set, must default to false (production) + var setting models.Setting + err = db.Where("key = ?", "feature.notifications.security_provider_events.enabled").First(&setting).Error + require.NoError(t, err) + + assert.Equal(t, "false", setting.Value, "Unset env vars must default to false (production)") +} + +// TestBlocker1_FeatureFlagDefaultDev tests that dev/test environment defaults to true. +func TestBlocker1_FeatureFlagDefaultDev(t *testing.T) { + db := setupCompatibilityTestDB(t) + + // Clear the auto-created feature flag + db.Where("key = ?", "feature.notifications.security_provider_events.enabled").Delete(&models.Setting{}) + + // Set GIN_MODE=test to simulate test environment + require.NoError(t, os.Setenv("GIN_MODE", "test")) + defer func() { _ = os.Unsetenv("GIN_MODE") }() + + // Ensure CHARON_ENV is not set to production + _ = os.Unsetenv("CHARON_ENV") + + service := services.NewEnhancedSecurityNotificationService(db) + + // Trigger flag initialization + _, err := service.GetSettings() + require.NoError(t, err) + + // Verify flag was created with true default (test mode) + var setting models.Setting + err = db.Where("key = ?", "feature.notifications.security_provider_events.enabled").First(&setting).Error + require.NoError(t, err) + + assert.Equal(t, "true", setting.Value, "Dev/test must default to true") +} diff --git a/backend/internal/api/handlers/security_notifications_test.go b/backend/internal/api/handlers/security_notifications_test.go index fc9b61bd..1fc3f8df 100644 --- a/backend/internal/api/handlers/security_notifications_test.go +++ b/backend/internal/api/handlers/security_notifications_test.go @@ -649,33 +649,33 @@ func TestNormalizeEmailRecipients(t *testing.T) { // TestSecurityNotificationHandler_DeprecatedUpdateSettings_AllFields tests that all JSON fields are returned func TestSecurityNotificationHandler_DeprecatedUpdateSettings_AllFields(t *testing.T) { -t.Parallel() + t.Parallel() -mockService := &mockSecurityNotificationService{} -handler := NewSecurityNotificationHandler(mockService) + mockService := &mockSecurityNotificationService{} + handler := NewSecurityNotificationHandler(mockService) -body, err := json.Marshal(models.NotificationConfig{Enabled: true, MinLogLevel: "warn"}) -require.NoError(t, err) + body, err := json.Marshal(models.NotificationConfig{Enabled: true, MinLogLevel: "warn"}) + require.NoError(t, err) -gin.SetMode(gin.TestMode) -w := httptest.NewRecorder() -c, _ := gin.CreateTestContext(w) -setAdminContext(c) -c.Request = httptest.NewRequest(http.MethodPut, "/api/v1/security/notifications/settings", bytes.NewBuffer(body)) -c.Request.Header.Set("Content-Type", "application/json") + gin.SetMode(gin.TestMode) + w := httptest.NewRecorder() + c, _ := gin.CreateTestContext(w) + setAdminContext(c) + c.Request = httptest.NewRequest(http.MethodPut, "/api/v1/security/notifications/settings", bytes.NewBuffer(body)) + c.Request.Header.Set("Content-Type", "application/json") -handler.DeprecatedUpdateSettings(c) + handler.DeprecatedUpdateSettings(c) -assert.Equal(t, http.StatusGone, w.Code) -assert.Equal(t, "true", w.Header().Get("X-Charon-Deprecated")) -assert.Equal(t, "/api/v1/notifications/settings/security", w.Header().Get("X-Charon-Canonical-Endpoint")) + assert.Equal(t, http.StatusGone, w.Code) + assert.Equal(t, "true", w.Header().Get("X-Charon-Deprecated")) + assert.Equal(t, "/api/v1/notifications/settings/security", w.Header().Get("X-Charon-Canonical-Endpoint")) -var response map[string]string -err = json.Unmarshal(w.Body.Bytes(), &response) -require.NoError(t, err) + var response map[string]string + err = json.Unmarshal(w.Body.Bytes(), &response) + require.NoError(t, err) -// Verify both JSON fields are present with exact values -assert.Equal(t, "This endpoint is deprecated and no longer accepts updates", response["error"]) -assert.Equal(t, "/api/v1/notifications/settings/security", response["canonical_endpoint"]) -assert.Len(t, response, 2, "Should have exactly 2 fields in JSON response") + // Verify both JSON fields are present with exact values + assert.Equal(t, "This endpoint is deprecated and no longer accepts updates", response["error"]) + assert.Equal(t, "/api/v1/notifications/settings/security", response["canonical_endpoint"]) + assert.Len(t, response, 2, "Should have exactly 2 fields in JSON response") } diff --git a/backend/internal/api/routes/routes.go b/backend/internal/api/routes/routes.go index 49fa3b66..cdd964ba 100644 --- a/backend/internal/api/routes/routes.go +++ b/backend/internal/api/routes/routes.go @@ -230,9 +230,16 @@ func RegisterWithDeps(router *gin.Engine, db *gorm.DB, cfg config.Config, caddyM dataRoot := filepath.Dir(cfg.DatabasePath) - // Security Notification Settings - securityNotificationService := services.NewSecurityNotificationService(db) - securityNotificationHandler := handlers.NewSecurityNotificationHandlerWithDeps(securityNotificationService, securityService, dataRoot) + // Security Notification Settings - Enhanced service with compatibility layer + enhancedSecurityNotificationService := services.NewEnhancedSecurityNotificationService(db) + + // Blocker 3: Invoke migration marker flow at boot with checksum rerun/no-op logic + if err := enhancedSecurityNotificationService.MigrateFromLegacyConfig(); err != nil { + logger.Log().WithError(err).Warn("Security notification migration: non-fatal error during boot-time reconciliation") + // Non-blocking: migration failures are logged but don't prevent startup + } + + securityNotificationHandler := handlers.NewSecurityNotificationHandlerWithDeps(enhancedSecurityNotificationService, securityService, dataRoot) protected.GET("/security/notifications/settings", securityNotificationHandler.DeprecatedGetSettings) protected.PUT("/security/notifications/settings", securityNotificationHandler.DeprecatedUpdateSettings) protected.GET("/notifications/settings/security", securityNotificationHandler.GetSettings) diff --git a/backend/internal/cerberus/cerberus.go b/backend/internal/cerberus/cerberus.go index 415086dd..94f92a58 100644 --- a/backend/internal/cerberus/cerberus.go +++ b/backend/internal/cerberus/cerberus.go @@ -26,6 +26,7 @@ type Cerberus struct { db *gorm.DB accessSvc *services.AccessListService securityNotifySvc *services.SecurityNotificationService + enhancedNotifySvc *services.EnhancedSecurityNotificationService // Settings cache for performance - avoids DB queries on every request settingsCache map[string]string @@ -41,6 +42,7 @@ func New(cfg config.SecurityConfig, db *gorm.DB) *Cerberus { db: db, accessSvc: services.NewAccessListService(db), securityNotifySvc: services.NewSecurityNotificationService(db), + enhancedNotifySvc: services.NewEnhancedSecurityNotificationService(db), settingsCache: make(map[string]string), settingsCacheTTL: 60 * time.Second, } @@ -204,8 +206,8 @@ func (c *Cerberus) Middleware() gin.HandlerFunc { activeCount++ allowed, _, err := c.accessSvc.TestIP(acl.ID, clientIP) if err == nil && !allowed { - // Send security notification - _ = c.securityNotifySvc.Send(context.Background(), models.SecurityEvent{ + // Send security notification via appropriate dispatch path + _ = c.sendSecurityNotification(context.Background(), models.SecurityEvent{ EventType: "acl_deny", Severity: "warn", Message: "Access control list blocked request", @@ -288,3 +290,23 @@ func (c *Cerberus) adminWhitelistStatus(clientIP string) (bool, bool) { return securitypkg.IsIPInCIDRList(clientIP, sc.AdminWhitelist), true } + +// sendSecurityNotification dispatches a security event notification. +// Blocker 1: Wires runtime dispatch to provider-event authority under feature flag semantics. +func (c *Cerberus) sendSecurityNotification(ctx context.Context, event models.SecurityEvent) error { + if c.db == nil { + return nil + } + + // Check feature flag + var setting models.Setting + err := c.db.Where("key = ?", "feature.notifications.security_provider_events.enabled").First(&setting).Error + + // If feature flag is enabled, use provider-based dispatch + if err == nil && strings.EqualFold(setting.Value, "true") { + return c.enhancedNotifySvc.SendViaProviders(ctx, event) + } + + // Feature flag disabled or not found: use legacy dispatch (fail-closed) + return c.securityNotifySvc.Send(ctx, event) +} diff --git a/backend/internal/models/notification_config.go b/backend/internal/models/notification_config.go index 9c3f0203..e49dada2 100644 --- a/backend/internal/models/notification_config.go +++ b/backend/internal/models/notification_config.go @@ -9,16 +9,25 @@ import ( // NotificationConfig stores configuration for security notifications. type NotificationConfig struct { - ID string `gorm:"primaryKey" json:"id"` - Enabled bool `json:"enabled"` - MinLogLevel string `json:"min_log_level"` // error, warn, info, debug - WebhookURL string `json:"webhook_url"` - NotifyWAFBlocks bool `json:"notify_waf_blocks"` - NotifyACLDenies bool `json:"notify_acl_denies"` - NotifyRateLimitHits bool `json:"notify_rate_limit_hits"` - EmailRecipients string `json:"email_recipients"` - CreatedAt time.Time `json:"created_at"` - UpdatedAt time.Time `json:"updated_at"` + ID string `gorm:"primaryKey" json:"id"` + Enabled bool `json:"enabled"` + MinLogLevel string `json:"min_log_level"` // error, warn, info, debug + WebhookURL string `json:"webhook_url"` + // Blocker 2 Fix: API surface uses security_* field names per spec (internal fields remain notify_*) + NotifyWAFBlocks bool `json:"security_waf_enabled"` + NotifyACLDenies bool `json:"security_acl_enabled"` + NotifyRateLimitHits bool `json:"security_rate_limit_enabled"` + EmailRecipients string `json:"email_recipients"` + + // Legacy destination fields (compatibility, not stored in DB) + DiscordWebhookURL string `gorm:"-" json:"discord_webhook_url,omitempty"` + SlackWebhookURL string `gorm:"-" json:"slack_webhook_url,omitempty"` + GotifyURL string `gorm:"-" json:"gotify_url,omitempty"` + GotifyToken string `gorm:"-" json:"gotify_token,omitempty"` + DestinationAmbiguous bool `gorm:"-" json:"destination_ambiguous,omitempty"` + + CreatedAt time.Time `json:"created_at"` + UpdatedAt time.Time `json:"updated_at"` } // BeforeCreate sets the ID if not already set. diff --git a/backend/internal/models/notification_provider.go b/backend/internal/models/notification_provider.go index 1aa4dd4c..c844bf3c 100644 --- a/backend/internal/models/notification_provider.go +++ b/backend/internal/models/notification_provider.go @@ -13,6 +13,7 @@ type NotificationProvider struct { Name string `json:"name" gorm:"index"` Type string `json:"type" gorm:"index"` // discord, slack, gotify, telegram, generic, webhook URL string `json:"url"` // The shoutrrr URL or webhook URL + Token string `json:"-"` // Auth token for providers (e.g., Gotify) - never exposed in API Engine string `json:"engine,omitempty" gorm:"index"` // legacy_shoutrrr | notify_v1 Config string `json:"config"` // JSON payload template for custom webhooks ServiceConfig string `json:"service_config,omitempty" gorm:"type:text"` // JSON blob for typed service config @@ -30,6 +31,14 @@ type NotificationProvider struct { NotifyCerts bool `json:"notify_certs" gorm:"default:true"` NotifyUptime bool `json:"notify_uptime" gorm:"default:true"` + // Security Event Notifications (Provider-based) + NotifySecurityWAFBlocks bool `json:"notify_security_waf_blocks" gorm:"default:false"` + NotifySecurityACLDenies bool `json:"notify_security_acl_denies" gorm:"default:false"` + NotifySecurityRateLimitHits bool `json:"notify_security_rate_limit_hits" gorm:"default:false"` + + // Managed Legacy Provider Marker + ManagedLegacySecurity bool `json:"managed_legacy_security" gorm:"index;default:false"` + CreatedAt time.Time `json:"created_at"` UpdatedAt time.Time `json:"updated_at"` } diff --git a/backend/internal/notifications/feature_flags.go b/backend/internal/notifications/feature_flags.go index 7ebd8543..336acf08 100644 --- a/backend/internal/notifications/feature_flags.go +++ b/backend/internal/notifications/feature_flags.go @@ -1,8 +1,9 @@ package notifications const ( - FlagNotifyEngineEnabled = "feature.notifications.engine.notify_v1.enabled" - FlagDiscordServiceEnabled = "feature.notifications.service.discord.enabled" - FlagGotifyServiceEnabled = "feature.notifications.service.gotify.enabled" - FlagLegacyFallbackEnabled = "feature.notifications.legacy_shoutrrr.fallback_enabled" + FlagNotifyEngineEnabled = "feature.notifications.engine.notify_v1.enabled" + FlagDiscordServiceEnabled = "feature.notifications.service.discord.enabled" + FlagGotifyServiceEnabled = "feature.notifications.service.gotify.enabled" + FlagLegacyFallbackEnabled = "feature.notifications.legacy_shoutrrr.fallback_enabled" + FlagSecurityProviderEventsEnabled = "feature.notifications.security_provider_events.enabled" ) diff --git a/backend/internal/services/enhanced_security_notification_service.go b/backend/internal/services/enhanced_security_notification_service.go new file mode 100644 index 00000000..ff4286b5 --- /dev/null +++ b/backend/internal/services/enhanced_security_notification_service.go @@ -0,0 +1,701 @@ +package services + +import ( + "bytes" + "context" + "crypto/sha256" + "encoding/hex" + "encoding/json" + "fmt" + "net/http" + "os" + "sort" + "time" + + "github.com/Wikid82/charon/backend/internal/logger" + "github.com/Wikid82/charon/backend/internal/models" + "github.com/Wikid82/charon/backend/internal/security" + "gorm.io/gorm" +) + +// EnhancedSecurityNotificationService provides provider-based security notifications with compatibility layer. +type EnhancedSecurityNotificationService struct { + db *gorm.DB +} + +// NewEnhancedSecurityNotificationService creates a new enhanced service instance. +func NewEnhancedSecurityNotificationService(db *gorm.DB) *EnhancedSecurityNotificationService { + return &EnhancedSecurityNotificationService{db: db} +} + +// CompatibilitySettings represents the compatibility GET/PUT structure. +type CompatibilitySettings struct { + SecurityWAFEnabled bool `json:"security_waf_enabled"` + SecurityACLEnabled bool `json:"security_acl_enabled"` + SecurityRateLimitEnabled bool `json:"security_rate_limit_enabled"` + Destination string `json:"destination,omitempty"` + DestinationAmbiguous bool `json:"destination_ambiguous,omitempty"` + WebhookURL string `json:"webhook_url,omitempty"` + DiscordWebhookURL string `json:"discord_webhook_url,omitempty"` + SlackWebhookURL string `json:"slack_webhook_url,omitempty"` + GotifyURL string `json:"gotify_url,omitempty"` + GotifyToken string `json:"gotify_token,omitempty"` +} + +// MigrationMarker represents the migration state stored in settings table. +type MigrationMarker struct { + Version string `json:"version"` + Checksum string `json:"checksum"` + LastCompletedAt string `json:"last_completed_at"` + Result string `json:"result"` // completed | completed_with_warnings +} + +// GetSettings retrieves compatibility settings via provider aggregation (Spec Section 2). +func (s *EnhancedSecurityNotificationService) GetSettings() (*models.NotificationConfig, error) { + // Check feature flag + enabled, err := s.isFeatureEnabled() + if err != nil { + return nil, fmt.Errorf("check feature flag: %w", err) + } + + if !enabled { + // Feature disabled: return legacy config + return s.getLegacyConfig() + } + + // Feature enabled: aggregate from providers + return s.getProviderAggregatedConfig() +} + +// getProviderAggregatedConfig aggregates settings from active providers using OR semantics. +// Blocker 2: Returns proper compatibility contract with security_* fields. +// Blocker 3: Filters enabled=true AND supported notify-only provider types. +func (s *EnhancedSecurityNotificationService) getProviderAggregatedConfig() (*models.NotificationConfig, error) { + var providers []models.NotificationProvider + err := s.db.Where("enabled = ?", true).Find(&providers).Error + if err != nil { + return nil, fmt.Errorf("query providers: %w", err) + } + + // Blocker 3: Filter for supported notify-only provider types (PR-1 scope) + supportedTypes := map[string]bool{ + "webhook": true, + "discord": true, + "slack": true, + "gotify": true, + } + filteredProviders := []models.NotificationProvider{} + for _, p := range providers { + if supportedTypes[p.Type] { + filteredProviders = append(filteredProviders, p) + } + } + + // OR aggregation: if ANY provider has true, result is true + config := &models.NotificationConfig{ + NotifyWAFBlocks: false, + NotifyACLDenies: false, + NotifyRateLimitHits: false, + } + + for _, p := range filteredProviders { + if p.NotifySecurityWAFBlocks { + config.NotifyWAFBlocks = true + } + if p.NotifySecurityACLDenies { + config.NotifyACLDenies = true + } + if p.NotifySecurityRateLimitHits { + config.NotifyRateLimitHits = true + } + } + + // Destination reporting: only if exactly one managed provider exists + managedProviders := []models.NotificationProvider{} + for _, p := range filteredProviders { + if p.ManagedLegacySecurity { + managedProviders = append(managedProviders, p) + } + } + + if len(managedProviders) == 1 { + // Exactly one managed provider - report destination based on type + p := managedProviders[0] + switch p.Type { + case "webhook": + config.WebhookURL = p.URL + case "discord": + config.DiscordWebhookURL = p.URL + case "slack": + config.SlackWebhookURL = p.URL + case "gotify": + config.GotifyURL = p.URL + // Blocker 2: Never expose gotify token in compatibility GET responses + // Token remains in DB but is not returned to client + } + config.DestinationAmbiguous = false + } else { + // Zero or multiple managed providers = ambiguous + config.DestinationAmbiguous = true + } + + return config, nil +} + +// getLegacyConfig retrieves settings from the legacy notification_configs table. +func (s *EnhancedSecurityNotificationService) getLegacyConfig() (*models.NotificationConfig, error) { + var config models.NotificationConfig + err := s.db.First(&config).Error + if err == gorm.ErrRecordNotFound { + return &models.NotificationConfig{ + NotifyWAFBlocks: true, + NotifyACLDenies: true, + NotifyRateLimitHits: true, + }, nil + } + return &config, err +} + +// UpdateSettings updates security notification settings via managed provider set (Spec Section 3). +func (s *EnhancedSecurityNotificationService) UpdateSettings(req *models.NotificationConfig) error { + // Check feature flag + enabled, err := s.isFeatureEnabled() + if err != nil { + return fmt.Errorf("check feature flag: %w", err) + } + + if !enabled { + // Feature disabled: update legacy config + return s.updateLegacyConfig(req) + } + + // Feature enabled: update via managed provider set + return s.updateManagedProviders(req) +} + +// updateManagedProviders updates the managed provider set with replace semantics. +// Blocker 4: Complete gotify validation - requires both URL and token, reject incomplete with 422. +func (s *EnhancedSecurityNotificationService) updateManagedProviders(req *models.NotificationConfig) error { + // Validate destination mapping (Spec Section 5: fail-safe handling) + destCount := 0 + var destType string + + if req.WebhookURL != "" { + destCount++ + destType = "webhook" + } + if req.DiscordWebhookURL != "" { + destCount++ + destType = "discord" + } + if req.SlackWebhookURL != "" { + destCount++ + destType = "slack" + } + // Blocker 4: Validate gotify requires BOTH url and token + if req.GotifyURL != "" || req.GotifyToken != "" { + destCount++ + destType = "gotify" + // Reject incomplete gotify payload with 422 and no mutation + if req.GotifyURL == "" || req.GotifyToken == "" { + return fmt.Errorf("incomplete gotify configuration: both gotify_url and gotify_token are required") + } + } + + if destCount > 1 { + return fmt.Errorf("ambiguous destination: multiple destination types provided") + } + + // Resolve deterministic target set (Spec Section 3: deterministic conflict behavior) + return s.db.Transaction(func(tx *gorm.DB) error { + var managedProviders []models.NotificationProvider + err := tx.Where("managed_legacy_security = ?", true).Find(&managedProviders).Error + if err != nil { + return fmt.Errorf("query managed providers: %w", err) + } + + // Blocker 4: Deterministic target set allows one-or-more managed providers + // Update full managed set; only 409 on true non-resolvable identity corruption + // Multiple managed providers ARE the valid target set (not corruption) + + if len(managedProviders) == 0 { + // Create managed provider + provider := &models.NotificationProvider{ + Name: "Migrated Security Notifications (Legacy)", + Type: destType, + Enabled: true, + ManagedLegacySecurity: true, + NotifySecurityWAFBlocks: req.NotifyWAFBlocks, + NotifySecurityACLDenies: req.NotifyACLDenies, + NotifySecurityRateLimitHits: req.NotifyRateLimitHits, + URL: s.extractDestinationURL(req), + Token: s.extractDestinationToken(req), + } + return tx.Create(provider).Error + } + + // Blocker 3: Enforce PUT idempotency - only save if values actually changed + // Update all managed providers with replace semantics + for i := range managedProviders { + changed := false + + // Check if security event flags changed + if managedProviders[i].NotifySecurityWAFBlocks != req.NotifyWAFBlocks { + managedProviders[i].NotifySecurityWAFBlocks = req.NotifyWAFBlocks + changed = true + } + if managedProviders[i].NotifySecurityACLDenies != req.NotifyACLDenies { + managedProviders[i].NotifySecurityACLDenies = req.NotifyACLDenies + changed = true + } + if managedProviders[i].NotifySecurityRateLimitHits != req.NotifyRateLimitHits { + managedProviders[i].NotifySecurityRateLimitHits = req.NotifyRateLimitHits + changed = true + } + + // Update destination if provided + if destURL := s.extractDestinationURL(req); destURL != "" { + if managedProviders[i].URL != destURL { + managedProviders[i].URL = destURL + changed = true + } + if managedProviders[i].Type != destType { + managedProviders[i].Type = destType + changed = true + } + if managedProviders[i].Token != s.extractDestinationToken(req) { + managedProviders[i].Token = s.extractDestinationToken(req) + changed = true + } + } + + // Blocker 3: Only save (update timestamps) if values actually changed + if changed { + if err := tx.Save(&managedProviders[i]).Error; err != nil { + return fmt.Errorf("update provider %s: %w", managedProviders[i].ID, err) + } + } + } + + return nil + }) +} + +// extractDestinationURL extracts the destination URL from the request. +func (s *EnhancedSecurityNotificationService) extractDestinationURL(req *models.NotificationConfig) string { + if req.WebhookURL != "" { + return req.WebhookURL + } + if req.DiscordWebhookURL != "" { + return req.DiscordWebhookURL + } + if req.SlackWebhookURL != "" { + return req.SlackWebhookURL + } + if req.GotifyURL != "" { + return req.GotifyURL + } + return "" +} + +// extractDestinationToken extracts the auth token from the request (currently only gotify). +func (s *EnhancedSecurityNotificationService) extractDestinationToken(req *models.NotificationConfig) string { + if req.GotifyToken != "" { + return req.GotifyToken + } + return "" +} + +// updateLegacyConfig updates the legacy notification_configs table. +func (s *EnhancedSecurityNotificationService) updateLegacyConfig(req *models.NotificationConfig) error { + var existing models.NotificationConfig + err := s.db.First(&existing).Error + + if err == gorm.ErrRecordNotFound { + return s.db.Create(req).Error + } + + if err != nil { + return fmt.Errorf("fetch existing config: %w", err) + } + + req.ID = existing.ID + return s.db.Save(req).Error +} + +// MigrateFromLegacyConfig performs deterministic migration from legacy config to managed provider (Spec Section 4). +// Blocker 2: Respects feature flag - does NOT mutate providers when flag=false. +func (s *EnhancedSecurityNotificationService) MigrateFromLegacyConfig() error { + // Check feature flag first + enabled, err := s.isFeatureEnabled() + if err != nil { + return fmt.Errorf("check feature flag: %w", err) + } + + // Read legacy config + var legacyConfig models.NotificationConfig + err = s.db.First(&legacyConfig).Error + if err == gorm.ErrRecordNotFound { + // No legacy config to migrate + return nil + } + if err != nil { + return fmt.Errorf("read legacy config: %w", err) + } + + // Compute checksum + checksum := computeConfigChecksum(legacyConfig) + + // Read migration marker + var markerSetting models.Setting + err = s.db.Where("key = ?", "notifications.security_provider_events.migration.v1").First(&markerSetting).Error + + if err == nil { + // Marker exists - check if checksum matches + var marker MigrationMarker + if err := json.Unmarshal([]byte(markerSetting.Value), &marker); err != nil { + logger.Log().WithError(err).Warn("Failed to unmarshal migration marker") + } else if marker.Checksum == checksum { + // Checksum matches - no-op + return nil + } + } + + // If feature flag is disabled, perform dry-evaluate only (no mutation) + if !enabled { + logger.Log().Info("Feature flag disabled - migration runs in read-only mode (no provider mutation)") + return nil + } + + // Perform migration in transaction + return s.db.Transaction(func(tx *gorm.DB) error { + // Upsert managed provider + var provider models.NotificationProvider + err := tx.Where("managed_legacy_security = ?", true).First(&provider).Error + + if err == gorm.ErrRecordNotFound { + // Create new managed provider + provider = models.NotificationProvider{ + Name: "Migrated Security Notifications (Legacy)", + Type: "webhook", + Enabled: true, + ManagedLegacySecurity: true, + NotifySecurityWAFBlocks: legacyConfig.NotifyWAFBlocks, + NotifySecurityACLDenies: legacyConfig.NotifyACLDenies, + NotifySecurityRateLimitHits: legacyConfig.NotifyRateLimitHits, + URL: legacyConfig.WebhookURL, + } + if err := tx.Create(&provider).Error; err != nil { + return fmt.Errorf("create managed provider: %w", err) + } + } else if err != nil { + return fmt.Errorf("query managed provider: %w", err) + } else { + // Update existing managed provider + provider.NotifySecurityWAFBlocks = legacyConfig.NotifyWAFBlocks + provider.NotifySecurityACLDenies = legacyConfig.NotifyACLDenies + provider.NotifySecurityRateLimitHits = legacyConfig.NotifyRateLimitHits + provider.URL = legacyConfig.WebhookURL + if err := tx.Save(&provider).Error; err != nil { + return fmt.Errorf("update managed provider: %w", err) + } + } + + // Write migration marker + marker := MigrationMarker{ + Version: "v1", + Checksum: checksum, + LastCompletedAt: time.Now().UTC().Format(time.RFC3339), + Result: "completed", + } + markerJSON, err := json.Marshal(marker) + if err != nil { + return fmt.Errorf("marshal marker: %w", err) + } + + newMarkerSetting := models.Setting{ + Key: "notifications.security_provider_events.migration.v1", + Value: string(markerJSON), + Type: "json", + Category: "notifications", + } + + // Upsert marker + if err := tx.Where("key = ?", newMarkerSetting.Key).First(&markerSetting).Error; err == gorm.ErrRecordNotFound { + return tx.Create(&newMarkerSetting).Error + } + newMarkerSetting.ID = markerSetting.ID + return tx.Save(&newMarkerSetting).Error + }) +} + +// computeConfigChecksum computes a deterministic checksum from legacy config fields. +func computeConfigChecksum(config models.NotificationConfig) string { + // Create deterministic string representation + fields := []string{ + fmt.Sprintf("waf:%t", config.NotifyWAFBlocks), + fmt.Sprintf("acl:%t", config.NotifyACLDenies), + fmt.Sprintf("rate:%t", config.NotifyRateLimitHits), + fmt.Sprintf("url:%s", config.WebhookURL), + } + sort.Strings(fields) // Ensure field order doesn't affect checksum + + data := "" + for _, f := range fields { + data += f + "|" + } + + hash := sha256.Sum256([]byte(data)) + return hex.EncodeToString(hash[:]) +} + +// isFeatureEnabled checks the feature flag in settings table (Spec Section 6). +func (s *EnhancedSecurityNotificationService) isFeatureEnabled() (bool, error) { + var setting models.Setting + err := s.db.Where("key = ?", "feature.notifications.security_provider_events.enabled").First(&setting).Error + + if err == gorm.ErrRecordNotFound { + // Blocker 5: Implement feature flag defaults exactly as per spec + // Initialize based on environment detection + defaultValue := s.getDefaultFeatureFlagValue() + + // Create the setting with appropriate default + newSetting := models.Setting{ + Key: "feature.notifications.security_provider_events.enabled", + Value: defaultValue, + Type: "bool", + Category: "feature", + } + + if createErr := s.db.Create(&newSetting).Error; createErr != nil { + // If creation fails (e.g., race condition), re-query + if queryErr := s.db.Where("key = ?", newSetting.Key).First(&setting).Error; queryErr != nil { + return defaultValue == "true", fmt.Errorf("create and requery feature flag: %w", queryErr) + } + return setting.Value == "true", nil + } + + return defaultValue == "true", nil + } + + if err != nil { + return false, fmt.Errorf("query feature flag: %w", err) + } + + return setting.Value == "true", nil +} + +// SendViaProviders dispatches security events to active providers. +// When feature flag is enabled, this is the authoritative dispatch path. +// Blocker 3: Filters enabled=true AND supported notify-only provider types. +func (s *EnhancedSecurityNotificationService) SendViaProviders(ctx context.Context, event models.SecurityEvent) error { + // Query active providers that have the relevant event type enabled + var providers []models.NotificationProvider + err := s.db.Where("enabled = ?", true).Find(&providers).Error + if err != nil { + return fmt.Errorf("query providers: %w", err) + } + + // Blocker 3: Filter for supported notify-only provider types (PR-1 scope) + supportedTypes := map[string]bool{ + "webhook": true, + "discord": true, + "slack": true, + "gotify": true, + } + + // Filter providers based on event type AND supported type + var targetProviders []models.NotificationProvider + for _, p := range providers { + if !supportedTypes[p.Type] { + continue + } + shouldNotify := false + switch event.EventType { + case "waf_block": + shouldNotify = p.NotifySecurityWAFBlocks + case "acl_deny": + shouldNotify = p.NotifySecurityACLDenies + case "rate_limit": + shouldNotify = p.NotifySecurityRateLimitHits + } + if shouldNotify { + targetProviders = append(targetProviders, p) + } + } + + if len(targetProviders) == 0 { + // No providers configured for this event type - fail closed (no notification) + logger.Log().WithField("event_type", event.EventType).Debug("No providers configured for security event") + return nil + } + + // Dispatch to all target providers (best-effort, log failures but don't block) + for _, p := range targetProviders { + if err := s.dispatchToProvider(ctx, p, event); err != nil { + logger.Log().WithError(err).WithField("provider_id", p.ID).Error("Failed to dispatch to provider") + // Continue to next provider (best-effort) + } + } + + return nil +} + +// dispatchToProvider sends the event to a single provider. +func (s *EnhancedSecurityNotificationService) dispatchToProvider(ctx context.Context, provider models.NotificationProvider, event models.SecurityEvent) error { + // For now, only webhook-like providers are supported + // Future: extend with provider-specific dispatch logic (Discord, Slack formatting, etc.) + switch provider.Type { + case "webhook", "discord", "slack": + return s.sendWebhook(ctx, provider.URL, event) + case "gotify": + // Gotify requires token-based authentication + return s.sendGotify(ctx, provider.URL, provider.Token, event) + default: + return fmt.Errorf("unsupported provider type: %s", provider.Type) + } +} + +// sendWebhook sends a security event to a webhook URL (shared with legacy service). +// Blocker 4: SSRF-safe URL validation before outbound requests. +func (s *EnhancedSecurityNotificationService) sendWebhook(ctx context.Context, webhookURL string, event models.SecurityEvent) error { + // Blocker 4: Validate URL before making outbound request (SSRF protection) + validatedURL, err := security.ValidateExternalURL(webhookURL, + security.WithAllowHTTP(), // Allow HTTP for backwards compatibility + ) + if err != nil { + return fmt.Errorf("ssrf validation failed: %w", err) + } + + payload, err := json.Marshal(event) + if err != nil { + return fmt.Errorf("marshal event: %w", err) + } + + req, err := http.NewRequestWithContext(ctx, "POST", validatedURL, bytes.NewBuffer(payload)) + if err != nil { + return fmt.Errorf("create request: %w", err) + } + + req.Header.Set("Content-Type", "application/json") + req.Header.Set("User-Agent", "Charon-Cerberus/1.0") + + client := &http.Client{Timeout: 10 * time.Second} + resp, err := client.Do(req) + if err != nil { + return fmt.Errorf("execute request: %w", err) + } + defer func() { _ = resp.Body.Close() }() + + if resp.StatusCode < 200 || resp.StatusCode >= 300 { + return fmt.Errorf("webhook returned status %d", resp.StatusCode) + } + + return nil +} + +// sendGotify sends a security event to Gotify with token authentication. +// Blocker 4: SSRF-safe URL validation before outbound requests. +func (s *EnhancedSecurityNotificationService) sendGotify(ctx context.Context, gotifyURL, token string, event models.SecurityEvent) error { + // Blocker 4: Validate URL before making outbound request (SSRF protection) + validatedURL, err := security.ValidateExternalURL(gotifyURL, + security.WithAllowHTTP(), // Allow HTTP for backwards compatibility + ) + if err != nil { + return fmt.Errorf("ssrf validation failed: %w", err) + } + + // Gotify API format: POST /message with token param + type GotifyMessage struct { + Title string `json:"title"` + Message string `json:"message"` + Priority int `json:"priority"` + Extras map[string]interface{} `json:"extras,omitempty"` + } + + // Map severity to Gotify priority (0-10) + priority := 5 + switch event.Severity { + case "error": + priority = 8 + case "warn": + priority = 5 + case "info": + priority = 3 + case "debug": + priority = 1 + } + + msg := GotifyMessage{ + Title: fmt.Sprintf("Security Alert: %s", event.EventType), + Message: fmt.Sprintf("%s from %s at %s", event.Message, event.ClientIP, event.Path), + Priority: priority, + Extras: map[string]interface{}{ + "client_ip": event.ClientIP, + "path": event.Path, + "event_type": event.EventType, + "metadata": event.Metadata, + }, + } + + payload, err := json.Marshal(msg) + if err != nil { + return fmt.Errorf("marshal gotify message: %w", err) + } + + // Gotify expects token as query parameter + url := fmt.Sprintf("%s/message?token=%s", validatedURL, token) + req, err := http.NewRequestWithContext(ctx, "POST", url, bytes.NewBuffer(payload)) + if err != nil { + return fmt.Errorf("create gotify request: %w", err) + } + + req.Header.Set("Content-Type", "application/json") + req.Header.Set("User-Agent", "Charon-Cerberus/1.0") + + client := &http.Client{Timeout: 10 * time.Second} + resp, err := client.Do(req) + if err != nil { + return fmt.Errorf("execute gotify request: %w", err) + } + defer func() { _ = resp.Body.Close() }() + + if resp.StatusCode < 200 || resp.StatusCode >= 300 { + return fmt.Errorf("gotify returned status %d", resp.StatusCode) + } + + return nil +} + +// getDefaultFeatureFlagValue returns default based on environment (Spec Section 6). +// Blocker 1: Reliable prod=false, dev/test=true without fragile markers. +// Production detection: CHARON_ENV=production OR (CHARON_ENV unset AND GIN_MODE unset) +func (s *EnhancedSecurityNotificationService) getDefaultFeatureFlagValue() string { + // Explicit production declaration + charonEnv := os.Getenv("CHARON_ENV") + if charonEnv == "production" || charonEnv == "prod" { + return "false" // Production: default disabled + } + + // Check if we're in a test environment via test marker (inserted by test setup) + var testMarker models.Setting + err := s.db.Where("key = ?", "_test_mode_marker").First(&testMarker).Error + if err == nil && testMarker.Value == "true" { + return "true" // Test environment + } + + // Check GIN_MODE for dev/test detection + ginMode := os.Getenv("GIN_MODE") + if ginMode == "debug" || ginMode == "test" { + return "true" // Development/test + } + + // Blocker 1 Fix: When both CHARON_ENV and GIN_MODE are unset, assume production + // Production systems should be explicit with CHARON_ENV=production, but default to safe (disabled) + if charonEnv == "" && ginMode == "" { + return "false" // Unset env vars = production default + } + + // All other cases: enable for dev/test safety + return "true" +} diff --git a/backend/internal/services/manual_challenge_service_test.go b/backend/internal/services/manual_challenge_service_test.go index 8af0ebdf..68b938df 100644 --- a/backend/internal/services/manual_challenge_service_test.go +++ b/backend/internal/services/manual_challenge_service_test.go @@ -515,10 +515,10 @@ func TestChallengeStatusResponse_Fields(t *testing.T) { func TestVerifyResult_Fields(t *testing.T) { result := &VerifyResult{ - Success: true, - DNSFound: true, - Message: "DNS TXT record verified successfully", - Status: "verified", + Success: true, + DNSFound: true, + Message: "DNS TXT record verified successfully", + Status: "verified", } assert.True(t, result.Success)