2026-03-10 12:57:04 +00:00
|
|
|
package workers
|
|
|
|
|
|
|
|
|
|
import (
|
|
|
|
|
"context"
|
|
|
|
|
"time"
|
|
|
|
|
|
|
|
|
|
"github.com/google/uuid"
|
|
|
|
|
"go.uber.org/zap"
|
|
|
|
|
"gorm.io/gorm"
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
// HardDeleteWorker performs final GDPR cleanup for users past recovery deadline (v0.10.8 F065)
|
|
|
|
|
// Runs periodically to irreversibly anonymize accounts that were soft-deleted 30+ days ago
|
|
|
|
|
type HardDeleteWorker struct {
|
|
|
|
|
db *gorm.DB
|
|
|
|
|
logger *zap.Logger
|
|
|
|
|
interval time.Duration
|
|
|
|
|
stopChan chan struct{}
|
|
|
|
|
running bool
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// NewHardDeleteWorker creates a new hard delete worker
|
|
|
|
|
func NewHardDeleteWorker(db *gorm.DB, logger *zap.Logger, interval time.Duration) *HardDeleteWorker {
|
|
|
|
|
if logger == nil {
|
|
|
|
|
logger = zap.NewNop()
|
|
|
|
|
}
|
|
|
|
|
if interval <= 0 {
|
|
|
|
|
interval = 24 * time.Hour
|
|
|
|
|
}
|
|
|
|
|
return &HardDeleteWorker{
|
|
|
|
|
db: db,
|
|
|
|
|
logger: logger,
|
|
|
|
|
interval: interval,
|
|
|
|
|
stopChan: make(chan struct{}),
|
|
|
|
|
running: false,
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Start runs the worker periodically
|
|
|
|
|
func (w *HardDeleteWorker) Start(ctx context.Context) {
|
|
|
|
|
if w.running {
|
|
|
|
|
w.logger.Warn("Hard delete worker is already running")
|
|
|
|
|
return
|
|
|
|
|
}
|
|
|
|
|
w.running = true
|
|
|
|
|
w.logger.Info("Starting hard delete worker", zap.Duration("interval", w.interval))
|
|
|
|
|
|
|
|
|
|
go w.runOnce(ctx)
|
|
|
|
|
|
|
|
|
|
ticker := time.NewTicker(w.interval)
|
|
|
|
|
defer ticker.Stop()
|
|
|
|
|
|
|
|
|
|
for {
|
|
|
|
|
select {
|
|
|
|
|
case <-ctx.Done():
|
|
|
|
|
w.logger.Info("Stopping hard delete worker")
|
|
|
|
|
w.running = false
|
|
|
|
|
return
|
|
|
|
|
case <-w.stopChan:
|
|
|
|
|
w.logger.Info("Stopping hard delete worker (stop requested)")
|
|
|
|
|
w.running = false
|
|
|
|
|
return
|
|
|
|
|
case <-ticker.C:
|
|
|
|
|
go w.runOnce(ctx)
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Stop stops the worker
|
|
|
|
|
func (w *HardDeleteWorker) Stop() {
|
|
|
|
|
if !w.running {
|
|
|
|
|
return
|
|
|
|
|
}
|
|
|
|
|
close(w.stopChan)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// runOnce executes one pass of hard delete / final anonymization
|
|
|
|
|
func (w *HardDeleteWorker) runOnce(ctx context.Context) {
|
|
|
|
|
logger := w.logger.With(zap.String("worker", "hard_delete"))
|
|
|
|
|
runCtx, cancel := context.WithTimeout(ctx, 30*time.Minute)
|
|
|
|
|
defer cancel()
|
|
|
|
|
|
|
|
|
|
var userIDs []uuid.UUID
|
|
|
|
|
if err := w.db.WithContext(runCtx).Raw(`
|
|
|
|
|
SELECT id FROM users
|
|
|
|
|
WHERE deleted_at IS NOT NULL AND recovery_deadline IS NOT NULL AND recovery_deadline < NOW()
|
|
|
|
|
`).Scan(&userIDs).Error; err != nil {
|
|
|
|
|
logger.Error("Failed to query users for hard delete", zap.Error(err))
|
|
|
|
|
return
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if len(userIDs) == 0 {
|
|
|
|
|
logger.Debug("No users eligible for hard delete")
|
|
|
|
|
return
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
logger.Info("Processing hard delete", zap.Int("count", len(userIDs)))
|
|
|
|
|
|
|
|
|
|
for _, id := range userIDs {
|
|
|
|
|
// Final anonymization pass (ORIGIN §19.3) - ensure no PII remains
|
|
|
|
|
res := w.db.WithContext(runCtx).Exec(`
|
|
|
|
|
UPDATE users SET
|
|
|
|
|
password_hash = '',
|
|
|
|
|
first_name = NULL,
|
|
|
|
|
last_name = NULL,
|
|
|
|
|
bio = '',
|
|
|
|
|
location = '',
|
|
|
|
|
avatar = '',
|
|
|
|
|
banner_url = '',
|
|
|
|
|
recovery_deadline = NULL,
|
|
|
|
|
updated_at = NOW()
|
|
|
|
|
WHERE id = ? AND deleted_at IS NOT NULL
|
|
|
|
|
`, id)
|
|
|
|
|
if res.Error != nil {
|
|
|
|
|
logger.Error("Failed to anonymize user", zap.Error(res.Error), zap.String("user_id", id.String()))
|
|
|
|
|
continue
|
|
|
|
|
}
|
|
|
|
|
// Delete user_profiles (may contain PII)
|
|
|
|
|
w.db.WithContext(runCtx).Exec("DELETE FROM user_profiles WHERE user_id = ?", id)
|
2026-03-12 04:40:53 +00:00
|
|
|
|
|
|
|
|
// SECURITY(HIGH-007): RGPD — clean additional PII-containing tables
|
|
|
|
|
w.db.WithContext(runCtx).Exec("DELETE FROM user_sessions WHERE user_id = ?", id)
|
|
|
|
|
w.db.WithContext(runCtx).Exec("DELETE FROM user_settings WHERE user_id = ?", id)
|
|
|
|
|
w.db.WithContext(runCtx).Exec("DELETE FROM user_follows WHERE follower_id = ? OR following_id = ?", id, id)
|
|
|
|
|
w.db.WithContext(runCtx).Exec("DELETE FROM notifications WHERE user_id = ? OR actor_id = ?", id, id)
|
|
|
|
|
w.db.WithContext(runCtx).Exec("UPDATE audit_logs SET user_id = NULL, ip_address = NULL WHERE user_id = ?", id)
|
|
|
|
|
// TODO(HIGH-007): Clean Redis cache keys (user:{id}:*) and Elasticsearch user documents.
|
|
|
|
|
// Requires injecting Redis/ES clients into HardDeleteWorker.
|
|
|
|
|
|
2026-03-10 12:57:04 +00:00
|
|
|
logger.Info("Hard delete completed", zap.String("user_id", id.String()))
|
|
|
|
|
}
|
|
|
|
|
}
|