package handlers // gdpr.go — v0.34.0 CS2 // // GDPR delete endpoint: DELETE /api/v1/me // Allows a user to delete their own account and all associated data. import ( "crypto/sha256" "fmt" "log/slog" "net/http" "github.com/gin-gonic/gin" "golang.org/x/crypto/bcrypt" "chat-switchboard/models" "chat-switchboard/store" ) // GDPRHandler serves account deletion endpoints. type GDPRHandler struct { stores store.Stores } // NewGDPRHandler creates a new handler for GDPR operations. func NewGDPRHandler(s store.Stores) *GDPRHandler { return &GDPRHandler{stores: s} } // deleteAccountRequest is the body for DELETE /api/v1/me. type deleteAccountRequest struct { Confirm string `json:"confirm"` Password string `json:"password"` } // DeleteMyAccount permanently deletes the requesting user's account. // DELETE /api/v1/me func (h *GDPRHandler) DeleteMyAccount(c *gin.Context) { ctx := c.Request.Context() userID := c.GetString("user_id") var req deleteAccountRequest if err := c.ShouldBindJSON(&req); err != nil { c.JSON(http.StatusBadRequest, gin.H{"error": "invalid request body"}) return } // Require explicit confirmation if req.Confirm != "DELETE" { c.JSON(http.StatusBadRequest, gin.H{"error": "confirm field must be \"DELETE\""}) return } // Fetch user user, err := h.stores.Users.GetByID(ctx, userID) if err != nil || user == nil { c.JSON(http.StatusNotFound, gin.H{"error": "user not found"}) return } // Verify password if err := bcrypt.CompareHashAndPassword([]byte(user.PasswordHash), []byte(req.Password)); err != nil { c.JSON(http.StatusUnauthorized, gin.H{"error": "invalid password"}) return } // Prevent deleting last admin if user.Role == "admin" { adminCount, err := h.stores.Export.CountActiveAdmins(ctx) if err != nil { slog.Error("gdpr: count admins", "error", err) c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to check admin count"}) return } if adminCount <= 1 { c.JSON(http.StatusForbidden, gin.H{"error": "cannot delete the last admin account"}) return } } // Generate anonymized hash from user ID hash := sha256.Sum256([]byte(userID)) anonHash := fmt.Sprintf("%x", hash[:6]) // 12 hex chars // Step 1: Soft-delete/hard-delete user data counts, err := h.stores.Export.SoftDeleteUserData(ctx, userID) if err != nil { slog.Error("gdpr: delete user data", "error", err, "user_id", userID) c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to delete user data"}) return } // Step 2: Revoke tokens if err := h.stores.Export.DeleteUserTokens(ctx, userID); err != nil { slog.Error("gdpr: delete tokens", "error", err, "user_id", userID) // Continue — tokens will expire naturally } // Step 3: Delete personal provider configs if deleted, err := h.stores.Providers.DeletePersonalByOwner(ctx, userID); err != nil { slog.Error("gdpr: delete provider configs", "error", err) } else { counts["provider_configs"] = deleted } // Step 4: Anonymize user record if err := h.stores.Export.AnonymizeUser(ctx, userID, anonHash); err != nil { slog.Error("gdpr: anonymize user", "error", err, "user_id", userID) c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to anonymize user"}) return } // Audit log (before the user record is fully anonymized) anonUsername := "deleted-user-" + anonHash h.stores.Audit.Log(ctx, &models.AuditEntry{ ActorID: &userID, Action: "user.gdpr_delete", ResourceType: "user", ResourceID: userID, Metadata: models.JSONMap{"anonymized_as": anonUsername, "deleted_counts": counts}, }) slog.Info("gdpr: account deleted", "user_id", userID, "anonymized_as", anonUsername) c.JSON(http.StatusOK, gin.H{ "message": "account deleted", "anonymized_as": anonUsername, }) }