audit_log.go 13 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354
  1. package middleware
  2. import (
  3. "bytes"
  4. "io"
  5. "strings"
  6. "time"
  7. "github.com/Wei-Shaw/sub2api/internal/pkg/ctxkey"
  8. "github.com/Wei-Shaw/sub2api/internal/service"
  9. "github.com/gin-gonic/gin"
  10. )
  11. // AuditLogMiddleware 管理面操作审计中间件类型(用于 wire 注入区分)。
  12. type AuditLogMiddleware gin.HandlerFunc
  13. // 审计相关 gin context 覆写键:handler / 认证中间件可通过这些键补充审计信息。
  14. const (
  15. auditCtxKeyAction = "audit_action"
  16. auditCtxKeyActorID = "audit_actor_id"
  17. auditCtxKeyActorEmail = "audit_actor_email"
  18. auditCtxKeySkip = "audit_skip"
  19. auditCtxKeyExtra = "audit_extra"
  20. // ContextKeyAuthEmail 认证中间件写入的用户邮箱(审计用)。
  21. ContextKeyAuthEmail = "auth_email"
  22. // ContextKeySessionID 认证中间件写入的会话 ID(refresh token family)。
  23. ContextKeySessionID = "session_id"
  24. )
  25. // SetAuditAction 允许 handler / 中间件为当前请求指定审计动作名(覆盖自动推导)。
  26. func SetAuditAction(c *gin.Context, action string) {
  27. c.Set(auditCtxKeyAction, action)
  28. }
  29. // SetAuditActor 允许 handler 在认证上下文缺失时(如登录接口)补充操作者身份。
  30. func SetAuditActor(c *gin.Context, userID int64, email string) {
  31. if userID > 0 {
  32. c.Set(auditCtxKeyActorID, userID)
  33. }
  34. if email != "" {
  35. c.Set(auditCtxKeyActorEmail, email)
  36. }
  37. }
  38. // SkipAudit 跳过当前请求的审计记录。
  39. func SkipAudit(c *gin.Context) {
  40. c.Set(auditCtxKeySkip, true)
  41. }
  42. // auditExtraAllowedKeys is deliberately narrow: handlers may only attach
  43. // scalar, non-secret operation summaries. Request bodies and arbitrary maps
  44. // are never accepted through this channel.
  45. var auditExtraAllowedKeys = map[string]struct{}{
  46. "result": {}, "error_code": {}, "enabled": {}, "blocking_enabled": {},
  47. "config_version": {}, "endpoint_count": {}, "scanner_count": {},
  48. "all_groups": {}, "group_count": {}, "guard_endpoint_id": {},
  49. "http_status": {}, "latency_ms": {}, "token_applied": {}, "retryable": {},
  50. "event_id": {}, "requested_count": {}, "deleted_events": {}, "deleted_jobs": {},
  51. "matched_count": {}, "snapshot_max_id": {}, "filter_hash": {}, "confirm": {},
  52. }
  53. // SetAuditExtra adds allowlisted, scalar details to the current audit entry.
  54. // It is safe to call more than once; later values replace earlier ones.
  55. func SetAuditExtra(c *gin.Context, fields map[string]any) {
  56. if c == nil || len(fields) == 0 {
  57. return
  58. }
  59. current := map[string]any{}
  60. if value, ok := c.Get(auditCtxKeyExtra); ok {
  61. if existing, ok := value.(map[string]any); ok {
  62. for key, item := range existing {
  63. current[key] = item
  64. }
  65. }
  66. }
  67. for key, value := range fields {
  68. if _, ok := auditExtraAllowedKeys[key]; !ok || !isAuditExtraScalar(value) {
  69. continue
  70. }
  71. if text, ok := value.(string); ok {
  72. value = truncateAuditExtraString(text, 128)
  73. }
  74. current[key] = value
  75. }
  76. c.Set(auditCtxKeyExtra, current)
  77. }
  78. func isAuditExtraScalar(value any) bool {
  79. switch value.(type) {
  80. case string, bool,
  81. int, int8, int16, int32, int64,
  82. uint, uint8, uint16, uint32, uint64,
  83. float32, float64:
  84. return true
  85. default:
  86. return false
  87. }
  88. }
  89. func truncateAuditExtraString(value string, limit int) string {
  90. value = strings.TrimSpace(value)
  91. runes := []rune(value)
  92. if len(runes) <= limit {
  93. return value
  94. }
  95. return string(runes[:limit])
  96. }
  97. // auditSensitiveReads 需要审计的敏感 GET 读取(method+FullPath → 动作名)。
  98. var auditSensitiveReads = map[string]string{
  99. "GET /api/v1/admin/accounts/data": "admin.accounts.export",
  100. "GET /api/v1/admin/proxies/data": "admin.proxies.export",
  101. "GET /api/v1/admin/redeem-codes/export": "admin.redeem_codes.export",
  102. "GET /api/v1/admin/backups/:id/download-url": "admin.backups.download",
  103. "GET /api/v1/admin/settings/admin-api-key": "admin.admin_api_key.read",
  104. "GET /api/v1/admin/users/:id/api-keys": "admin.users.api_keys.read",
  105. "GET /api/v1/admin/groups/:id/api-keys": "admin.groups.api_keys.read",
  106. "GET /api/v1/admin/backups/s3-config": "admin.backups.s3_config.read",
  107. "GET /api/v1/admin/data-management/s3/config": "admin.data_management.s3_config.read",
  108. }
  109. // auditActionOverrides 变更类请求的动作名精确映射(未命中时自动推导)。
  110. var auditActionOverrides = map[string]string{
  111. "POST /api/v1/auth/login": service.AuditActionLogin,
  112. "POST /api/v1/auth/login/2fa": service.AuditActionLogin2FA,
  113. "POST /api/v1/auth/passkey/login/finish": service.AuditActionLogin,
  114. "POST /api/v1/auth/register": service.AuditActionRegister,
  115. "POST /api/v1/auth/refresh": service.AuditActionTokenRefresh,
  116. "POST /api/v1/user/totp/step-up": service.AuditActionStepUpVerify,
  117. "POST /api/v1/admin/audit-logs/clear": service.AuditActionAuditLogClear,
  118. "POST /api/v1/admin/accounts/data": "admin.accounts.import",
  119. "POST /api/v1/admin/backups": "admin.backups.create",
  120. "POST /api/v1/admin/backups/:id/restore": "admin.backups.restore",
  121. "DELETE /api/v1/admin/backups/:id": "admin.backups.delete",
  122. "PUT /api/v1/admin/backups/s3-config": "admin.backups.s3_config.update",
  123. "POST /api/v1/admin/settings/admin-api-key/regenerate": "admin.admin_api_key.regenerate",
  124. "DELETE /api/v1/admin/settings/admin-api-key": "admin.admin_api_key.delete",
  125. "PUT /api/v1/admin/prompt-audit/config": "admin.prompt_audit.config.update",
  126. "POST /api/v1/admin/prompt-audit/endpoints/probe": "admin.prompt_audit.endpoint.probe",
  127. "DELETE /api/v1/admin/prompt-audit/events/:id": "admin.prompt_audit.event.delete",
  128. "POST /api/v1/admin/prompt-audit/events/batch-delete": "admin.prompt_audit.events.batch_delete",
  129. "POST /api/v1/admin/prompt-audit/events/delete-preview": "admin.prompt_audit.events.delete_preview",
  130. "POST /api/v1/admin/prompt-audit/events/delete-by-filter": "admin.prompt_audit.events.filter_delete",
  131. }
  132. // auditBodyOmittedRoutes 请求体几乎整体由凭证构成的路由(如整块粘贴 auth JSON 的导入接口)。
  133. // 这类 body 的凭证内嵌在普通字符串值里,键级脱敏无法覆盖,整体不入库。
  134. var auditBodyOmittedRoutes = map[string]struct{}{
  135. "POST /api/v1/auth/passkey/login/finish": {},
  136. "POST /api/v1/user/passkeys/register/finish": {},
  137. "POST /api/v1/admin/accounts/import/codex-session": {},
  138. "PUT /api/v1/admin/accounts/:id/ollama-cloud-usage/session": {},
  139. "PUT /api/v1/admin/prompt-audit/config": {},
  140. "POST /api/v1/admin/prompt-audit/endpoints/probe": {},
  141. "DELETE /api/v1/admin/prompt-audit/events/:id": {},
  142. "POST /api/v1/admin/prompt-audit/events/batch-delete": {},
  143. "POST /api/v1/admin/prompt-audit/events/delete-preview": {},
  144. "POST /api/v1/admin/prompt-audit/events/delete-by-filter": {},
  145. }
  146. // NewAuditLogMiddleware 创建审计中间件。
  147. // 记录范围:变更类请求(POST/PUT/PATCH/DELETE)+ 白名单内的敏感 GET 读取。
  148. // 挂载位置:admin / user / admin-payment 组挂在各自认证中间件之后(只审计已认证请求,
  149. // 未过认证的 401/403 不入库);auth 组(登录/注册/刷新)无前置认证,天然记录失败尝试。
  150. func NewAuditLogMiddleware(auditService *service.AuditLogService) AuditLogMiddleware {
  151. return AuditLogMiddleware(func(c *gin.Context) {
  152. routeKey := c.Request.Method + " " + c.FullPath()
  153. record := false
  154. action := ""
  155. switch c.Request.Method {
  156. case "POST", "PUT", "PATCH", "DELETE":
  157. record = true
  158. if v, ok := auditActionOverrides[routeKey]; ok {
  159. action = v
  160. }
  161. case "GET":
  162. if v, ok := auditSensitiveReads[routeKey]; ok {
  163. record = true
  164. action = v
  165. }
  166. }
  167. if !record {
  168. c.Next()
  169. return
  170. }
  171. // 捕获请求体(读出后回填,避免影响后续 ShouldBindJSON)。
  172. // 只读取脱敏解析上限内的字节,超出部分与已读部分拼接回填,
  173. // 避免大体积导入请求被完整复制进内存两次。
  174. var bodyRedacted string
  175. if _, omit := auditBodyOmittedRoutes[routeKey]; omit {
  176. bodyRedacted = "<credential-bearing body omitted>"
  177. } else if c.Request.Body != nil && c.Request.Method != "GET" {
  178. orig := c.Request.Body
  179. raw, err := io.ReadAll(io.LimitReader(orig, service.AuditRequestBodyCaptureLimit+1))
  180. if err == nil {
  181. c.Request.Body = &restoredBody{
  182. Reader: io.MultiReader(bytes.NewReader(raw), orig),
  183. closer: orig,
  184. }
  185. bodyRedacted = service.RedactAuditBody(raw, c.GetHeader("Content-Type"))
  186. }
  187. }
  188. start := time.Now()
  189. c.Next()
  190. if c.GetBool(auditCtxKeySkip) {
  191. return
  192. }
  193. status := c.Writer.Status()
  194. // token 刷新成功属于高频常规操作,只记录失败(潜在攻击信号)。
  195. if routeKey == "POST /api/v1/auth/refresh" && status < 400 {
  196. return
  197. }
  198. entry := &service.AuditLog{
  199. CreatedAt: time.Now().UTC(),
  200. Action: action,
  201. Method: c.Request.Method,
  202. Path: c.FullPath(),
  203. ClientIP: SecurityClientIP(c),
  204. UserAgent: c.Request.UserAgent(),
  205. RequestBody: bodyRedacted,
  206. StatusCode: status,
  207. LatencyMs: time.Since(start).Milliseconds(),
  208. }
  209. if entry.Path == "" {
  210. entry.Path = c.Request.URL.Path
  211. }
  212. if entry.Action == "" {
  213. entry.Action = deriveAuditAction(c.Request.Method, entry.Path)
  214. }
  215. if v, ok := c.Get(auditCtxKeyAction); ok {
  216. if s, ok := v.(string); ok && s != "" {
  217. entry.Action = s
  218. }
  219. }
  220. if requestID, ok := c.Request.Context().Value(ctxkey.RequestID).(string); ok {
  221. entry.RequestID = requestID
  222. }
  223. // 操作者身份:优先取认证中间件写入的上下文,其次取 handler 覆写(登录等场景)。
  224. if subject, ok := GetAuthSubjectFromContext(c); ok && subject.UserID > 0 {
  225. uid := subject.UserID
  226. entry.ActorUserID = &uid
  227. }
  228. if role, ok := GetUserRoleFromContext(c); ok {
  229. entry.ActorRole = role
  230. }
  231. entry.ActorEmail = c.GetString(ContextKeyAuthEmail)
  232. entry.AuthMethod = c.GetString("auth_method")
  233. if entry.AuthMethod == "" && entry.ActorUserID != nil {
  234. entry.AuthMethod = service.AuditAuthMethodJWT
  235. }
  236. if v, ok := c.Get(auditCtxKeyActorID); ok {
  237. if id, ok := v.(int64); ok && id > 0 {
  238. entry.ActorUserID = &id
  239. }
  240. }
  241. if v, ok := c.Get(auditCtxKeyActorEmail); ok {
  242. if s, ok := v.(string); ok && s != "" {
  243. entry.ActorEmail = s
  244. }
  245. }
  246. // 请求头凭证掩码(仅保留首尾)。
  247. entry.CredentialMasked = MaskedRequestCredential(c)
  248. extra := map[string]any{}
  249. if value, ok := c.Get(auditCtxKeyExtra); ok {
  250. if details, ok := value.(map[string]any); ok {
  251. for key, item := range details {
  252. extra[key] = item
  253. }
  254. }
  255. }
  256. if len(c.Params) > 0 {
  257. params := make(map[string]string, len(c.Params))
  258. for _, p := range c.Params {
  259. params[p.Key] = p.Value
  260. }
  261. extra["params"] = params
  262. }
  263. if q := service.RedactAuditQuery(c.Request.URL.RawQuery); q != "" {
  264. extra["query"] = q
  265. }
  266. if len(extra) > 0 {
  267. entry.Extra = extra
  268. }
  269. auditService.Record(entry)
  270. })
  271. }
  272. // restoredBody 把审计中间件按上限读出的前缀与未读完的原始 body 拼接回填,
  273. // 保证 handler 读到完整请求体;Close 委托给原始 body。
  274. type restoredBody struct {
  275. io.Reader
  276. closer io.Closer
  277. }
  278. func (b *restoredBody) Close() error { return b.closer.Close() }
  279. // MaskedRequestCredential 提取请求头中的凭证并做首尾掩码。
  280. func MaskedRequestCredential(c *gin.Context) string {
  281. if apiKey := strings.TrimSpace(c.GetHeader("x-api-key")); apiKey != "" {
  282. return "x-api-key " + service.MaskAuditCredential(apiKey)
  283. }
  284. authHeader := strings.TrimSpace(c.GetHeader("Authorization"))
  285. if authHeader == "" {
  286. return ""
  287. }
  288. parts := strings.SplitN(authHeader, " ", 2)
  289. if len(parts) == 2 {
  290. return parts[0] + " " + service.MaskAuditCredential(strings.TrimSpace(parts[1]))
  291. }
  292. return service.MaskAuditCredential(authHeader)
  293. }
  294. // deriveAuditAction 由 method + 路由模板自动推导动作名,
  295. // 例:PUT /api/v1/admin/accounts/:id → admin.accounts.update
  296. func deriveAuditAction(method, fullPath string) string {
  297. path := strings.TrimPrefix(fullPath, "/api/v1/")
  298. path = strings.Trim(path, "/")
  299. segs := strings.Split(path, "/")
  300. parts := make([]string, 0, len(segs))
  301. for _, seg := range segs {
  302. if seg == "" || strings.HasPrefix(seg, ":") || strings.HasPrefix(seg, "*") {
  303. continue
  304. }
  305. parts = append(parts, strings.ReplaceAll(seg, "-", "_"))
  306. }
  307. verb := ""
  308. switch method {
  309. case "POST":
  310. verb = "create"
  311. case "PUT", "PATCH":
  312. verb = "update"
  313. case "DELETE":
  314. verb = "delete"
  315. case "GET":
  316. verb = "read"
  317. default:
  318. verb = strings.ToLower(method)
  319. }
  320. if len(parts) == 0 {
  321. return verb
  322. }
  323. return strings.Join(parts, ".") + "." + verb
  324. }