feat: Implement enhanced security notification service with compatibility layer
- Introduced EnhancedSecurityNotificationService for provider-based notifications. - Added migration logic from legacy notification configuration to managed providers. - Updated NotificationConfig model to reflect API surface changes and maintain legacy fields. - Enhanced Cerberus middleware to dispatch security events based on feature flags. - Updated routes to utilize the new enhanced service and handle migration at startup. - Added feature flag for security provider events to control behavior in production. - Updated tests to cover new functionality and ensure compatibility with existing behavior.
This commit is contained in:
@@ -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
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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"`
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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"`
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
)
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user