diff --git a/veza-backend-api/internal/api/router.go b/veza-backend-api/internal/api/router.go index e3798d0ea..585b62d60 100644 --- a/veza-backend-api/internal/api/router.go +++ b/veza-backend-api/internal/api/router.go @@ -198,6 +198,7 @@ func (r *APIRouter) Setup(router *gin.Engine) error { router.Use(middleware.Metrics()) // Prometheus Metrics router.Use(middleware.SentryRecover(r.logger)) // Sentry error tracking router.Use(middleware.SecurityHeaders()) // MOD-P2-005: Security headers (HSTS, CSP, etc.) + router.Use(middleware.CCPA()) // v0.803 SEC2-06: CCPA Do Not Sell (Sec-GPC) // v0.803 SEC2-03: HTTP audit middleware for auto-logging POST/PUT/DELETE if r.config != nil && r.config.AuditService != nil { diff --git a/veza-backend-api/internal/api/routes_users.go b/veza-backend-api/internal/api/routes_users.go index c4c1cab92..70a677784 100644 --- a/veza-backend-api/internal/api/routes_users.go +++ b/veza-backend-api/internal/api/routes_users.go @@ -141,6 +141,9 @@ func (r *APIRouter) setupUserRoutes(router *gin.RouterGroup) { } protected.GET("/me/export", exportHandler) + // POST /me/privacy/opt-out: CCPA Do Not Sell (v0.803 SEC2-06) + protected.POST("/me/privacy/opt-out", handlers.PrivacyOptOut(r.db.GormDB)) + // POST /me/export: async GDPR export (ZIP to S3, notification when ready) gdprExportService := services.NewGDPRExportService( r.db.GormDB, dataExportService, r.config.S3StorageService, r.notificationService, r.logger, diff --git a/veza-backend-api/internal/api/user/types.go b/veza-backend-api/internal/api/user/types.go index 393226f06..4199bd0a5 100644 --- a/veza-backend-api/internal/api/user/types.go +++ b/veza-backend-api/internal/api/user/types.go @@ -114,6 +114,7 @@ type PrivacySettings struct { AllowDM bool `json:"allow_dm"` TrackVisibility string `json:"track_visibility"` // public, followers, private ProfileVisibility string `json:"profile_visibility"` // public, registered, private + DoNotSell bool `json:"do_not_sell"` // v0.803: CCPA Do Not Sell opt-out } // AudioSettings paramètres audio diff --git a/veza-backend-api/internal/handlers/privacy_handler.go b/veza-backend-api/internal/handlers/privacy_handler.go new file mode 100644 index 000000000..a182f7851 --- /dev/null +++ b/veza-backend-api/internal/handlers/privacy_handler.go @@ -0,0 +1,38 @@ +package handlers + +import ( + "net/http" + + "github.com/gin-gonic/gin" + "github.com/google/uuid" + "gorm.io/gorm" +) + +// PrivacyOptOut sets the CCPA "Do Not Sell" preference for the authenticated user. +// v0.803 SEC2-06: CCPA compliance - honors user opt-out request. +func PrivacyOptOut(db *gorm.DB) gin.HandlerFunc { + return func(c *gin.Context) { + userID, ok := GetUserIDUUID(c) + if !ok || userID == uuid.Nil { + c.JSON(http.StatusUnauthorized, gin.H{"error": "Authentication required"}) + return + } + + // Upsert user_preferences with do_not_sell in privacy JSONB + // Uses PostgreSQL jsonb_set to merge do_not_sell: true into existing privacy + result := db.Exec(` + INSERT INTO user_preferences (user_id, theme, language, timezone, notifications, privacy, audio, contrast, density, accent_hue, font_size, updated_at) + VALUES ($1, 'light', 'en', 'UTC', '{}', '{"do_not_sell": true}', '{}', 'normal', 'comfortable', 220, 16, NOW()) + ON CONFLICT (user_id) DO UPDATE SET + privacy = COALESCE(user_preferences.privacy, '{}')::jsonb || '{"do_not_sell": true}'::jsonb, + updated_at = NOW() + `, userID) + + if result.Error != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to save privacy preference"}) + return + } + + RespondSuccess(c, http.StatusOK, gin.H{"message": "Do Not Sell preference saved"}) + } +} diff --git a/veza-backend-api/internal/middleware/ccpa.go b/veza-backend-api/internal/middleware/ccpa.go new file mode 100644 index 000000000..2d3e0724e --- /dev/null +++ b/veza-backend-api/internal/middleware/ccpa.go @@ -0,0 +1,21 @@ +package middleware + +import ( + "github.com/gin-gonic/gin" +) + +// CCPAContextKey is the key used to store "Do Not Sell" preference in context +const CCPAContextKey = "do_not_sell" + +// CCPA middleware reads the Sec-GPC (Global Privacy Control) header. +// When Sec-GPC: 1 is present, it sets do_not_sell=true in context and adds GPC: 1 to the response. +// This honors the CCPA "Do Not Sell" signal per Global Privacy Control specification. +func CCPA() gin.HandlerFunc { + return func(c *gin.Context) { + if c.GetHeader("Sec-GPC") == "1" { + c.Set(CCPAContextKey, true) + c.Header("GPC", "1") + } + c.Next() + } +} diff --git a/veza-backend-api/internal/middleware/ccpa_test.go b/veza-backend-api/internal/middleware/ccpa_test.go new file mode 100644 index 000000000..41fab658a --- /dev/null +++ b/veza-backend-api/internal/middleware/ccpa_test.go @@ -0,0 +1,53 @@ +package middleware + +import ( + "net/http" + "net/http/httptest" + "testing" + + "github.com/gin-gonic/gin" + "github.com/stretchr/testify/assert" +) + +func TestCCPA_SetsContextAndHeader(t *testing.T) { + gin.SetMode(gin.TestMode) + router := gin.New() + router.Use(CCPA()) + router.GET("/test", func(c *gin.Context) { + val, exists := c.Get(CCPAContextKey) + if exists && val.(bool) { + c.Status(http.StatusOK) + } else { + c.Status(http.StatusNoContent) + } + }) + + req := httptest.NewRequest("GET", "/test", nil) + req.Header.Set("Sec-GPC", "1") + w := httptest.NewRecorder() + router.ServeHTTP(w, req) + + assert.Equal(t, http.StatusOK, w.Code) + assert.Equal(t, "1", w.Header().Get("GPC")) +} + +func TestCCPA_NoHeaderWhenAbsent(t *testing.T) { + gin.SetMode(gin.TestMode) + router := gin.New() + router.Use(CCPA()) + router.GET("/test", func(c *gin.Context) { + _, exists := c.Get(CCPAContextKey) + if !exists { + c.Status(http.StatusOK) + } else { + c.Status(http.StatusNoContent) + } + }) + + req := httptest.NewRequest("GET", "/test", nil) + w := httptest.NewRecorder() + router.ServeHTTP(w, req) + + assert.Equal(t, http.StatusOK, w.Code) + assert.Empty(t, w.Header().Get("GPC")) +}