package audit import ( "bytes" "io" "strings" "github.com/atlasos/calypso/internal/common/database" "github.com/atlasos/calypso/internal/common/logger" "github.com/gin-gonic/gin" ) // Middleware provides audit logging functionality type Middleware struct { db *database.DB logger *logger.Logger } // NewMiddleware creates a new audit middleware func NewMiddleware(db *database.DB, log *logger.Logger) *Middleware { return &Middleware{ db: db, logger: log, } } // LogRequest creates middleware that logs all mutating requests func (m *Middleware) LogRequest() gin.HandlerFunc { return func(c *gin.Context) { // Only log mutating methods method := c.Request.Method if method == "GET" || method == "HEAD" || method == "OPTIONS" { c.Next() return } // Capture request body var bodyBytes []byte if c.Request.Body != nil { bodyBytes, _ = io.ReadAll(c.Request.Body) c.Request.Body = io.NopCloser(bytes.NewBuffer(bodyBytes)) } // Process request c.Next() // Get user information userID, _ := c.Get("user_id") username, _ := c.Get("username") // Capture response status status := c.Writer.Status() // Log to database go m.logAuditEvent( userID, username, method, c.Request.URL.Path, c.ClientIP(), c.GetHeader("User-Agent"), bodyBytes, status, ) } } // logAuditEvent logs an audit event to the database func (m *Middleware) logAuditEvent( userID interface{}, username interface{}, method, path, ipAddress, userAgent string, requestBody []byte, responseStatus int, ) { var userIDStr, usernameStr string if userID != nil { userIDStr, _ = userID.(string) } if username != nil { usernameStr, _ = username.(string) } // Determine action and resource from path action, resourceType, resourceID := parsePath(path) // Override action with HTTP method action = strings.ToLower(method) // Truncate request body if too large bodyJSON := string(requestBody) if len(bodyJSON) > 10000 { bodyJSON = bodyJSON[:10000] + "... (truncated)" } query := ` INSERT INTO audit_log ( user_id, username, action, resource_type, resource_id, method, path, ip_address, user_agent, request_body, response_status, created_at ) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, NOW()) ` var bodyJSONPtr *string if len(bodyJSON) > 0 { bodyJSONPtr = &bodyJSON } _, err := m.db.Exec(query, userIDStr, usernameStr, action, resourceType, resourceID, method, path, ipAddress, userAgent, bodyJSONPtr, responseStatus, ) if err != nil { m.logger.Error("Failed to log audit event", "error", err) } } // parsePath extracts action, resource type, and resource ID from a path func parsePath(path string) (action, resourceType, resourceID string) { // Example: /api/v1/iam/users/123 -> action=update, resourceType=user, resourceID=123 if len(path) < 8 || path[:8] != "/api/v1/" { return "unknown", "unknown", "" } remaining := path[8:] parts := strings.Split(remaining, "/") if len(parts) == 0 { return "unknown", "unknown", "" } // First part is usually the resource type (e.g., "iam", "tasks") resourceType = parts[0] // Determine action from HTTP method (will be set by caller) action = "unknown" // Last part might be resource ID if it's a UUID or number if len(parts) > 1 { lastPart := parts[len(parts)-1] // Check if it looks like a UUID or ID if len(lastPart) > 10 { resourceID = lastPart } } return action, resourceType, resourceID }