package middleware import ( "net/http" "strings" "time" "github.com/gin-gonic/gin" "lokistack/models" "lokistack/services" ) // AuditInfo 由 controller 主动补充的业务语义。 // // 中间件已经能自动记录 method/path/status/耗时/IP/用户, // 但「改了哪个部署」「保留天数 7 → 3」这类信息只有 handler 自己知道, // 所以提供 SetAudit 让 handler 补一刀,不补也能记全(只是 Detail 为空)。 type AuditInfo struct { Module string Action string Resource string ResourceID string Detail string Before string After string // Skip 置 true 表示这条请求不记(例如纯查询用了 POST 的场景) Skip bool } const auditInfoKey = "audit.info" // SetAudit 由 controller 调用,补充审计语义。 func SetAudit(c *gin.Context, info AuditInfo) { c.Set(auditInfoKey, info) } // auditSkipPaths 这些路径不记审计: // - 健康检查 / metrics:高频噪音 // - 日志查询:GET 本来就不记,但 /logs/query 之类用 POST 的也不记(只读) var auditSkipPrefixes = []string{ "/api/v1/health", "/api/v1/metrics", "/api/v1/logs/query", "/api/v1/logs/stats", "/api/v1/logs/histogram", "/api/v1/logs/labels", "/api/v1/logs/label", "/api/v1/logs/context", "/api/v1/logs/volume", "/api/v1/logs/my-scope", "/api/v1/deployments/", // 例外:其中 /deploy /stop /restart 要记,见下方白名单 } // auditAlwaysRecord 匹配到的路径即使在上面的 skip 前缀里也要记。 var auditAlwaysRecordSuffixes = []string{ "/deploy", "/stop", "/restart", "/install", "/uninstall", "/test", "/validate", "/preview", "/rollback", "/reset-password", "/backup", "/restore", } // AuditLog 审计中间件:自动记录所有写操作(POST / PUT / PATCH / DELETE)。 // // 为什么用中间件而不是每个 controller 各写一段: // - LokiStack 有 60+ 个写接口,逐个埋点必然漏,漏掉的那个恰好最危险(删集群、改存储) // - 中间件能拿到统一的 status / 耗时 / IP / UA // - handler 需要更细的语义时再 SetAudit 补,不补也有基础记录 // // 不含请求体(读 body 会影响后续 binding),敏感字段由 handler 通过 SetAudit // 传入已脱敏的 Before/After。 func AuditLog() gin.HandlerFunc { return func(c *gin.Context) { method := c.Request.Method // 只读请求不记(GET/HEAD/OPTIONS) if method == http.MethodGet || method == http.MethodHead || method == http.MethodOptions { c.Next() return } path := c.Request.URL.Path if !shouldAudit(path) { c.Next() return } start := time.Now() c.Next() elapsed := time.Since(start).Milliseconds() info, _ := c.Get(auditInfoKey) ai, _ := info.(AuditInfo) if ai.Skip { return } status := c.Writer.Status() userID, _ := c.Get("userId") username, _ := c.Get("username") role, _ := c.Get("role") entry := models.AuditLog{ IP: c.ClientIP(), UserAgent: c.Request.UserAgent(), Method: method, Path: path, Status: status, DurationMs: elapsed, Detail: ai.Detail, Before: ai.Before, After: ai.After, CreatedAt: time.Now(), } if v, ok := userID.(uint); ok { entry.UserID = v } if v, ok := username.(string); ok { entry.Username = v } if v, ok := role.(string); ok { entry.Role = v } // 失败时把错误信息带上(gin 的 Error 里有 handler 写入的错误) if status >= 400 { if len(c.Errors) > 0 { entry.Error = c.Errors.String() } } // handler 没补语义时按路径推断,保证「谁删了什么」至少能看出模块 module, action, resource, resourceID := inferAuditFields(method, path) if ai.Module != "" { module = ai.Module } if ai.Action != "" { action = ai.Action } if ai.Resource != "" { resource = ai.Resource } if ai.ResourceID != "" { resourceID = ai.ResourceID } entry.Module, entry.Action, entry.Resource, entry.ResourceID = module, action, resource, resourceID services.RecordAudit(entry) } } func shouldAudit(path string) bool { for _, suffix := range auditAlwaysRecordSuffixes { if strings.HasSuffix(path, suffix) { return true } } for _, p := range auditSkipPrefixes { if strings.HasPrefix(path, p) { return false } } return true } // inferAuditFields 从 method + path 推断模块与动作(handler 未补充时的兜底)。 func inferAuditFields(method, path string) (module, action, resource, resourceID string) { action = map[string]string{ http.MethodPost: "create", http.MethodPut: "update", http.MethodPatch: "update", http.MethodDelete: "delete", }[method] segments := strings.Split(strings.Trim(path, "/"), "/") // /api/v1/// if len(segments) >= 3 { module = segments[2] } if len(segments) >= 4 { resource = segments[3] resourceID = segments[4] } // 语义化覆盖 switch { case strings.HasSuffix(path, "/deploy"): action, resource = "deploy", "部署" case strings.HasSuffix(path, "/stop"): action, resource = "stop", "部署" case strings.HasSuffix(path, "/restart"): action, resource = "restart", "部署" case strings.HasSuffix(path, "/install"): action, resource = "install", "采集器" case strings.HasSuffix(path, "/uninstall"): action, resource = "uninstall", "采集器" case strings.HasSuffix(path, "/test"): action = "test" case strings.HasSuffix(path, "/validate"): action = "validate" case strings.HasSuffix(path, "/reset-password"): action, resource = "reset-password", "用户" case strings.HasSuffix(path, "/login"): module, action, resource = "auth", "login", "登录" case strings.HasSuffix(path, "/password"): module, action, resource = "auth", "change-password", "登录" } return module, action, resource, resourceID }