feat(compliance): CCPA Do Not Sell middleware and opt-out endpoint
This commit is contained in:
parent
470162ade8
commit
3f56e49791
6 changed files with 117 additions and 0 deletions
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
38
veza-backend-api/internal/handlers/privacy_handler.go
Normal file
38
veza-backend-api/internal/handlers/privacy_handler.go
Normal 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"})
|
||||
}
|
||||
}
|
||||
21
veza-backend-api/internal/middleware/ccpa.go
Normal file
21
veza-backend-api/internal/middleware/ccpa.go
Normal 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()
|
||||
}
|
||||
}
|
||||
53
veza-backend-api/internal/middleware/ccpa_test.go
Normal file
53
veza-backend-api/internal/middleware/ccpa_test.go
Normal 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"))
|
||||
}
|
||||
Loading…
Reference in a new issue