veza/veza-backend-api/internal/handlers/account_deletion_handler.go
senke f2881ad865
Some checks failed
Backend API CI / test-unit (push) Failing after 1s
Frontend CI / test (push) Failing after 3s
Storybook Audit / Build & audit Storybook (push) Failing after 2s
Backend API CI / test-integration (push) Failing after 6s
feat(gdpr): v0.10.8 portabilité données - export ZIP async, suppression compte, hard delete cron
- Export: table data_exports, POST /me/export (202), GET /me/exports, messages+playback_history
- Notification email quand ZIP prêt, rate limit 3/jour
- Suppression: keep_public_tracks, anonymisation PII complète (users, user_profiles)
- HardDeleteWorker: final anonymization après 30 jours
- Frontend: POST export, checkbox keep_public_tracks
- MSW handlers pour Storybook
2026-03-10 13:57:04 +01:00

152 lines
5.3 KiB
Go

package handlers
import (
"context"
"net/http"
"time"
"github.com/gin-gonic/gin"
"github.com/google/uuid"
"go.uber.org/zap"
"gorm.io/gorm"
"veza-backend-api/internal/services"
"veza-backend-api/internal/utils"
)
// DeleteAccountRequest is the request body for account deletion (v0.10.8 F065)
type DeleteAccountRequest struct {
Password string `json:"password" binding:"required"`
Reason string `json:"reason"`
ConfirmText string `json:"confirm_text" binding:"required"`
KeepPublicTracks bool `json:"keep_public_tracks"` // If true, public tracks remain (attributed to deleted account)
}
// DeleteAccountHandler returns a handler for DELETE /users/me (v0.803 SEC2-05)
//
// @Summary Delete account
// @Description Permanently delete user account with anonymization, session revocation, audit log
// @Tags Users
// @Security BearerAuth
// @Param body body DeleteAccountRequest true "Password, reason, confirm_text (must be DELETE)"
// @Success 200 {object} map[string]interface{}
// @Failure 400 {object} map[string]interface{}
// @Failure 401 {object} map[string]interface{}
// @Failure 500 {object} map[string]interface{}
// @Router /users/me [delete]
func DeleteAccountHandler(
db *gorm.DB,
sessionService *services.SessionService,
auditService *services.AuditService,
s3Service *services.S3StorageService,
logger *zap.Logger,
) gin.HandlerFunc {
refreshTokenService := services.NewRefreshTokenService(db)
return func(c *gin.Context) {
userID, ok := GetUserIDUUID(c)
if !ok || userID == uuid.Nil {
c.JSON(http.StatusUnauthorized, gin.H{"error": "Authentication required"})
return
}
var req DeleteAccountRequest
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid request: password and confirm_text required"})
return
}
if req.ConfirmText != "DELETE" {
c.JSON(http.StatusBadRequest, gin.H{"error": "Type DELETE to confirm"})
return
}
// 1. Verify password
var passwordHash string
if err := db.WithContext(c.Request.Context()).Raw("SELECT password_hash FROM users WHERE id = ?", userID).Scan(&passwordHash).Error; err != nil || passwordHash == "" {
c.JSON(http.StatusUnauthorized, gin.H{"error": "Invalid password"})
return
}
if err := utils.CheckPasswordHash(req.Password, passwordHash); err != nil {
c.JSON(http.StatusUnauthorized, gin.H{"error": "Invalid password"})
return
}
ctx := c.Request.Context()
recoveryDeadline := time.Now().Add(30 * 24 * time.Hour)
anonUsername := "deleted-" + userID.String()
anonEmail := "deleted-" + userID.String() + "@veza.app"
// 2. Soft delete + full PII anonymization (v0.10.8 droit à l'oubli)
result := db.WithContext(ctx).Exec(`
UPDATE users SET
is_active = false,
deleted_at = NOW(),
deletion_reason = ?,
recovery_deadline = ?,
username = ?,
email = ?,
first_name = NULL,
last_name = NULL,
bio = '',
location = '',
avatar = '',
banner_url = '',
updated_at = NOW()
WHERE id = ?
`, req.Reason, recoveryDeadline, anonUsername, anonEmail, userID)
if result.Error != nil {
logger.Error("Failed to delete account", zap.Error(result.Error), zap.String("user_id", userID.String()))
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to delete account"})
return
}
// 2b. Anonymize user_profiles if exists (v0.10.8)
_ = db.WithContext(ctx).Exec("UPDATE user_profiles SET bio = NULL, tagline = NULL, location = NULL, website_url = NULL, avatar_url = NULL, banner_url = NULL, updated_at = NOW() WHERE user_id = ?", userID)
// 2c. Tracks: if !KeepPublicTracks, soft-delete all user tracks (v0.10.8)
if !req.KeepPublicTracks {
_ = db.WithContext(ctx).Exec("UPDATE tracks SET deleted_at = NOW() WHERE creator_id = ?", userID)
}
// 3. Revoke all sessions
if sessionService != nil {
if _, err := sessionService.RevokeAllUserSessions(ctx, userID); err != nil {
logger.Warn("Failed to revoke sessions during account deletion", zap.Error(err), zap.String("user_id", userID.String()))
}
}
// 4. Revoke all refresh tokens
if err := refreshTokenService.RevokeAll(userID); err != nil {
logger.Warn("Failed to revoke refresh tokens during account deletion", zap.Error(err), zap.String("user_id", userID.String()))
}
// 5. S3 cleanup (cloud files for user)
if s3Service != nil {
deleteUserS3Files(ctx, db, s3Service, userID, logger)
}
// 6. Audit log
if auditService != nil {
_ = auditService.LogDeletion(ctx, userID, "user", userID, c.ClientIP(), c.Request.UserAgent())
}
RespondSuccess(c, http.StatusOK, gin.H{"message": "Account deleted"})
}
}
// deleteUserS3Files deletes cloud files for a user from S3
func deleteUserS3Files(ctx context.Context, db *gorm.DB, s3Service *services.S3StorageService, userID uuid.UUID, logger *zap.Logger) {
var files []struct {
S3Key string
}
if err := db.WithContext(ctx).Raw("SELECT s3_key FROM user_files WHERE user_id = ?", userID).Scan(&files).Error; err != nil {
logger.Warn("Failed to list user files for S3 cleanup", zap.Error(err), zap.String("user_id", userID.String()))
return
}
for _, f := range files {
if err := s3Service.DeleteFile(ctx, f.S3Key); err != nil {
logger.Warn("Failed to delete S3 file during account deletion", zap.Error(err), zap.String("key", f.S3Key))
}
}
}