veza/veza-backend-api/internal/workers/hard_delete_worker.go

133 lines
3.7 KiB
Go
Raw Normal View History

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)
fix(v0.12.6.1): remediate 2 CRITICAL + 10 HIGH + 1 MEDIUM pentest findings Security fixes implemented: CRITICAL: - CRIT-001: IDOR on chat rooms — added IsRoomMember check before returning room data or message history (returns 404, not 403) - CRIT-002: play_count/like_count exposed publicly — changed JSON tags to "-" so they are never serialized in API responses HIGH: - HIGH-001: TOCTOU race on marketplace downloads — transaction + SELECT FOR UPDATE on GetDownloadURL - HIGH-002: HS256 in production docker-compose — replaced JWT_SECRET with JWT_PRIVATE_KEY_PATH / JWT_PUBLIC_KEY_PATH (RS256) - HIGH-003: context.Background() bypass in user repository — full context propagation from handlers → services → repository (29 files) - HIGH-004: Race condition on promo codes — SELECT FOR UPDATE - HIGH-005: Race condition on exclusive licenses — SELECT FOR UPDATE - HIGH-006: Rate limiter IP spoofing — SetTrustedProxies(nil) default - HIGH-007: RGPD hard delete incomplete — added cleanup for sessions, settings, follows, notifications, audit_logs anonymization - HIGH-008: RTMP callback auth weak — fail-closed when unconfigured, header-only (no query param), constant-time compare - HIGH-009: Co-listening host hijack — UpdateHostState now takes *Conn and verifies IsHost before processing - HIGH-010: Moderator self-strike — added issuedBy != userID check MEDIUM: - MEDIUM-001: Recovery codes used math/rand — replaced with crypto/rand - MEDIUM-005: Stream token forgeable — resolved by HIGH-002 (RS256) Updated REMEDIATION_MATRIX: 14 findings marked ✅ CORRIGÉ. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
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.
logger.Info("Hard delete completed", zap.String("user_id", id.String()))
}
}