feat(compliance): CCPA Do Not Sell middleware and opt-out endpoint

This commit is contained in:
senke 2026-02-25 19:49:25 +01:00
parent 470162ade8
commit 3f56e49791
6 changed files with 117 additions and 0 deletions

View file

@ -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 {

View file

@ -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,

View file

@ -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

View file

@ -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"})
}
}

View file

@ -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()
}
}

View file

@ -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"))
}