Ctrl K
ComunidadGuías…

server/internal/handlers/audit.go

Rama main · réplica de lectura del repositorio original.

Archivo original ↗Preguntar a KiBot
package handlers

import (
	"encoding/json"
	"log"
	"net"
	"net/http"
	"os"
	"strconv"
	"strings"
	"sync/atomic"
	"time"

	"blackbox/server/internal/auth"
	"blackbox/server/internal/models"
	"github.com/oklog/ulid/v2"
	"gorm.io/gorm"
)

const auditLogRetention = 10_000

var auditPruneInFlight atomic.Bool

func extractClientIP(r *http.Request) string {
	remoteIP, _, _ := net.SplitHostPort(r.RemoteAddr)
	if isTrustedProxy(remoteIP) {
		if xff := r.Header.Get("X-Forwarded-For"); xff != "" {
			if idx := strings.Index(xff, ","); idx >= 0 {
				return strings.TrimSpace(xff[:idx])
			}
			return strings.TrimSpace(xff)
		}
	}
	return remoteIP
}

func isTrustedProxy(ip string) bool {
	// Allow an explicit override for reverse proxies on different hosts
	if trusted := os.Getenv("TRUSTED_PROXY_IP"); trusted != "" && ip == trusted {
		return true
	}
	parsed := net.ParseIP(ip)
	if parsed == nil {
		return false
	}
	return parsed.IsLoopback()
}

func pruneAuditLogs(db *gorm.DB) {
	if !auditPruneInFlight.CompareAndSwap(false, true) {
		return
	}
	go func() {
		defer auditPruneInFlight.Store(false)
		if err := db.Exec(
			"DELETE FROM audit_logs WHERE id NOT IN (SELECT id FROM audit_logs ORDER BY created_at DESC LIMIT ?)",
			auditLogRetention,
		).Error; err != nil {
			log.Printf("WriteAuditLog prune failed: %v", err)
		}
	}()
}

func WriteAuditLog(
	db *gorm.DB,
	r *http.Request,
	claims *auth.Claims,
	action, targetType, targetID string,
	meta map[string]interface{},
) {
	if claims == nil {
		return
	}

	actorEmail := claims.Email
	if actorEmail == "" {
		var user models.User
		if err := db.Select("email").First(&user, "id = ?", claims.UserID).Error; err == nil {
			actorEmail = user.Email
		}
	}

	metaBytes, err := json.Marshal(meta)
	if err != nil {
		metaBytes = []byte("{}")
	}

	entry := models.AuditLog{
		ID:          ulid.Make().String(),
		ActorUserID: claims.UserID,
		ActorEmail:  actorEmail,
		Action:      action,
		TargetType:  targetType,
		TargetID:    targetID,
		Metadata:    string(metaBytes),
		IPAddress:   extractClientIP(r),
		CreatedAt:   time.Now().UTC(),
	}

	if err := db.Create(&entry).Error; err != nil {
		log.Printf("WriteAuditLog insert failed: %v", err)
		return
	}

	pruneAuditLogs(db)
}

func ListAuditLogs(db *gorm.DB) http.HandlerFunc {
	return func(w http.ResponseWriter, r *http.Request) {
		if !requireAdminRequest(w, r) {
			return
		}

		page, perPage, ok := parsePaginationParams(w, r)
		if !ok {
			return
		}

		q := db.Model(&models.AuditLog{})
		if action := r.URL.Query().Get("action"); action != "" {
			q = q.Where("action = ?", action)
		}
		if actorID := r.URL.Query().Get("actor_user_id"); actorID != "" {
			q = q.Where("actor_user_id = ?", actorID)
		}

		var total int64
		if err := q.Count(&total).Error; err != nil {
			writeError(w, http.StatusInternalServerError, "failed to count audit logs")
			return
		}

		var rows []models.AuditLog
		offset := (page - 1) * perPage
		if err := q.Order("created_at DESC").Offset(offset).Limit(perPage).Find(&rows).Error; err != nil {
			writeError(w, http.StatusInternalServerError, "failed to list audit logs")
			return
		}

		items := make([]auditLogItem, len(rows))
		for i, row := range rows {
			var meta map[string]interface{}
			if err := json.Unmarshal([]byte(row.Metadata), &meta); err != nil {
				meta = map[string]interface{}{}
			}
			items[i] = auditLogItem{
				ID:          row.ID,
				ActorUserID: row.ActorUserID,
				ActorEmail:  row.ActorEmail,
				Action:      row.Action,
				TargetType:  row.TargetType,
				TargetID:    row.TargetID,
				Metadata:    meta,
				IPAddress:   row.IPAddress,
				CreatedAt:   row.CreatedAt,
			}
		}

		w.Header().Set("Content-Type", "application/json")
		if err := json.NewEncoder(w).Encode(map[string]interface{}{
			"total":    total,
			"page":     page,
			"per_page": perPage,
			"items":    items,
		}); err != nil {
			log.Printf("ListAuditLogs encode: %v", err)
		}
	}
}

type auditLogItem struct {
	ID          string                 `json:"id"`
	ActorUserID string                 `json:"actor_user_id"`
	ActorEmail  string                 `json:"actor_email"`
	Action      string                 `json:"action"`
	TargetType  string                 `json:"target_type"`
	TargetID    string                 `json:"target_id"`
	Metadata    map[string]interface{} `json:"metadata"`
	IPAddress   string                 `json:"ip_address"`
	CreatedAt   time.Time              `json:"created_at"`
}

func parsePaginationParams(w http.ResponseWriter, r *http.Request) (int, int, bool) {
	page := 1
	perPage := 50

	if v := r.URL.Query().Get("page"); v != "" {
		n, err := strconv.Atoi(v)
		if err != nil || n < 1 {
			writeError(w, http.StatusBadRequest, "invalid page")
			return 0, 0, false
		}
		page = n
	}
	if v := r.URL.Query().Get("per_page"); v != "" {
		n, err := strconv.Atoi(v)
		if err != nil || n < 1 || n > 200 {
			writeError(w, http.StatusBadRequest, "per_page must be between 1 and 200")
			return 0, 0, false
		}
		perPage = n
	}
	return page, perPage, true
}