veza/veza-backend-api/internal/middleware/audit.go

123 lines
2.9 KiB
Go

package middleware
import (
"context"
"strings"
"github.com/gin-gonic/gin"
"github.com/google/uuid"
"go.uber.org/zap"
"veza-backend-api/internal/services"
)
// skipAuditPaths are paths that should not be audited (health, metrics, swagger, etc.)
var skipAuditPaths = []string{
"/health", "/healthz", "/readyz", "/health/deep",
"/metrics", "/metrics/aggregated", "/system/metrics",
"/swagger", "/docs",
"/api/versions",
}
// AuditMiddleware returns a Gin middleware that logs POST/PUT/DELETE requests to the audit service.
// Logging happens asynchronously after the request completes.
func AuditMiddleware(auditService *services.AuditService, logger *zap.Logger) gin.HandlerFunc {
if auditService == nil || logger == nil {
return func(c *gin.Context) { c.Next() }
}
return func(c *gin.Context) {
c.Next()
method := c.Request.Method
if method != "POST" && method != "PUT" && method != "DELETE" && method != "PATCH" {
return
}
reqPath := c.Request.URL.Path
if shouldSkipAudit(reqPath) {
return
}
var userID *uuid.UUID
if uid, exists := c.Get("user_id"); exists {
if parsed, ok := uid.(uuid.UUID); ok && parsed != uuid.Nil {
userID = &parsed
}
}
action := mapMethodToAction(method)
resource := extractResourceFromPath(reqPath)
req := &services.AuditLogCreateRequest{
UserID: userID,
Action: action,
Resource: resource,
IPAddress: c.ClientIP(),
UserAgent: c.Request.UserAgent(),
Metadata: map[string]interface{}{
"method": method,
"path": reqPath,
},
}
go func() {
ctx := context.Background()
if err := auditService.LogAction(ctx, req); err != nil {
logger.Warn("Audit middleware failed to log action",
zap.Error(err),
zap.String("action", action),
zap.String("resource", resource),
)
}
}()
}
}
func shouldSkipAudit(reqPath string) bool {
reqPath = strings.TrimSuffix(reqPath, "/")
for _, skip := range skipAuditPaths {
if reqPath == skip || strings.HasPrefix(reqPath, skip+"/") {
return true
}
}
// Also skip /api/v1/health
if strings.Contains(reqPath, "/health") {
return true
}
return false
}
func mapMethodToAction(method string) string {
switch method {
case "POST":
return "create"
case "PUT", "PATCH":
return "update"
case "DELETE":
return "delete"
default:
return strings.ToLower(method)
}
}
// extractResourceFromPath derives a resource type from the API path.
// e.g. /api/v1/tracks -> track, /api/v1/users/me -> user
func extractResourceFromPath(reqPath string) string {
// Remove /api/v1 prefix
p := strings.TrimPrefix(reqPath, "/api/v1")
p = strings.TrimPrefix(p, "/api")
p = strings.Trim(p, "/")
parts := strings.Split(p, "/")
if len(parts) == 0 {
return "unknown"
}
resource := parts[0]
// Singularize common plurals
if strings.HasSuffix(resource, "s") && len(resource) > 1 {
resource = strings.TrimSuffix(resource, "s")
}
return resource
}