audit_log.go 8.8 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258
  1. package service
  2. import (
  3. "context"
  4. "encoding/json"
  5. "strconv"
  6. "strings"
  7. "time"
  8. infraerrors "github.com/Wei-Shaw/sub2api/internal/pkg/errors"
  9. "github.com/Wei-Shaw/sub2api/internal/util/logredact"
  10. )
  11. // ErrAuditLogNotFound 审计日志不存在。
  12. var ErrAuditLogNotFound = infraerrors.NotFound("AUDIT_LOG_NOT_FOUND", "audit log not found")
  13. // 审计日志相关常量。
  14. const (
  15. // AuditAuthMethodJWT / AuditAuthMethodAdminAPIKey 与 auth 中间件写入的 auth_method 对齐。
  16. AuditAuthMethodJWT = "jwt"
  17. AuditAuthMethodAdminAPIKey = "admin_api_key"
  18. AuditAuthMethodPasskey = "passkey"
  19. // auditRequestBodyMaxBytes 请求体脱敏后入库的最大长度(字节),超出截断。
  20. auditRequestBodyMaxBytes = 16 * 1024
  21. // AuditRequestBodyCaptureLimit 请求体参与脱敏解析的原始大小上限(字节)。
  22. // 审计中间件按此上限截断读取,超出的请求体仅记录占位符不解析。
  23. AuditRequestBodyCaptureLimit = 256 * 1024
  24. )
  25. // 内置审计动作名(认证/安全事件与特殊操作使用固定值,普通请求由路由自动推导)。
  26. const (
  27. AuditActionLogin = "auth.login"
  28. AuditActionLogin2FA = "auth.login.2fa"
  29. AuditActionRegister = "auth.register"
  30. AuditActionTokenRefresh = "auth.token.refresh"
  31. AuditActionSessionBindingMismatch = "auth.session_binding.mismatch"
  32. AuditActionStepUpVerify = "auth.step_up.verify"
  33. AuditActionAuditLogClear = "admin.audit_log.clear"
  34. )
  35. // AuditLog 一条管理面操作审计记录。
  36. type AuditLog struct {
  37. ID int64 `json:"id"`
  38. CreatedAt time.Time `json:"created_at"`
  39. ActorUserID *int64 `json:"actor_user_id,omitempty"`
  40. ActorEmail string `json:"actor_email"`
  41. ActorRole string `json:"actor_role"`
  42. AuthMethod string `json:"auth_method"`
  43. CredentialMasked string `json:"credential_masked"`
  44. Action string `json:"action"`
  45. Method string `json:"method"`
  46. Path string `json:"path"`
  47. RequestID string `json:"request_id"`
  48. ClientIP string `json:"client_ip"`
  49. UserAgent string `json:"user_agent"`
  50. RequestBody string `json:"request_body,omitempty"`
  51. StatusCode int `json:"status_code"`
  52. LatencyMs int64 `json:"latency_ms"`
  53. Extra map[string]any `json:"extra,omitempty"`
  54. }
  55. // AuditLogFilter 审计日志列表查询条件。
  56. type AuditLogFilter struct {
  57. Page int
  58. PageSize int
  59. StartTime *time.Time
  60. EndTime *time.Time
  61. ActorUserID *int64
  62. ActorEmail string
  63. AuthMethod string
  64. Action string
  65. Method string
  66. ClientIP string
  67. // Success: nil 全部;true 仅 2xx/3xx;false 仅 >=400。
  68. Success *bool
  69. // Query 对 path / action / actor_email 做模糊匹配。
  70. Query string
  71. }
  72. // AuditLogList 分页结果。
  73. type AuditLogList struct {
  74. Logs []*AuditLog
  75. Total int
  76. Page int
  77. PageSize int
  78. }
  79. // AuditLogRepository 审计日志持久化端口。
  80. // 注意:接口刻意不提供单条删除能力——审计日志只允许追加与全量清空。
  81. type AuditLogRepository interface {
  82. BatchInsert(ctx context.Context, logs []*AuditLog) (int64, error)
  83. // Insert 同步写入单条(用于清空留痕等必须落库的记录)。
  84. Insert(ctx context.Context, log *AuditLog) error
  85. List(ctx context.Context, filter *AuditLogFilter) (*AuditLogList, error)
  86. GetByID(ctx context.Context, id int64) (*AuditLog, error)
  87. Count(ctx context.Context) (int64, error)
  88. // TruncateAll 全量清空(TRUNCATE),返回前需调用方自行 Count 记录行数。
  89. TruncateAll(ctx context.Context) error
  90. // DeleteBefore 按保留期批量删除,返回本批删除行数(幂等,可多实例并发)。
  91. DeleteBefore(ctx context.Context, cutoff time.Time, batchSize int) (int64, error)
  92. }
  93. // auditNormalizeBodyKey 归一化键名:小写并去除分隔符,
  94. // 使 private_key / privateKey / privatekey / api-v3-key 等写法共享同一判定,
  95. // 避免子串清单假设 snake_case 而漏掉支付渠道等无分隔符风格的密钥字段。
  96. func auditNormalizeBodyKey(key string) string {
  97. var b strings.Builder
  98. b.Grow(len(key))
  99. for _, r := range strings.ToLower(strings.TrimSpace(key)) {
  100. switch r {
  101. case '_', '-', '.', ' ':
  102. continue
  103. default:
  104. _, _ = b.WriteRune(r)
  105. }
  106. }
  107. return b.String()
  108. }
  109. // auditBodySensitiveExactKeys 请求体脱敏的精确匹配键(归一化后)。
  110. // 除内置清单外,程序化并入两份权威敏感表以防清单漂移:
  111. // - SensitiveCredentialKeys:账号 credentials 的敏感子键(session_key / service_account_json 等)
  112. // - providerSensitiveConfigFields:支付渠道密钥字段(pkey / privatekey / apiv3key 等)
  113. var auditBodySensitiveExactKeys = func() map[string]struct{} {
  114. builtin := []string{
  115. "code", "codes", "pin", "cvv",
  116. "authorization", "cookie", "x-api-key",
  117. "key",
  118. // 字符串值内嵌完整凭证的字段:
  119. // proxy_key 为 protocol|host|port|username|password 拼接,
  120. // custom_key 为用户自设的平台 API Key 明文,
  121. // session 为 Ollama Cloud 用量的浏览器会话 Cookie 明文。
  122. "proxy_key", "custom_key", "session",
  123. }
  124. set := make(map[string]struct{}, len(builtin)+len(SensitiveCredentialKeys)+16)
  125. for _, k := range builtin {
  126. set[auditNormalizeBodyKey(k)] = struct{}{}
  127. }
  128. for _, k := range SensitiveCredentialKeys {
  129. set[auditNormalizeBodyKey(k)] = struct{}{}
  130. }
  131. for _, fields := range providerSensitiveConfigFields {
  132. for k := range fields {
  133. set[auditNormalizeBodyKey(k)] = struct{}{}
  134. }
  135. }
  136. return set
  137. }()
  138. // auditBodySensitiveSubstrings 请求体脱敏的包含匹配子串(对归一化后的键名比对)。
  139. // 命中任一子串即整体擦除该键的值(例如 new_password / secret_access_key / temp_token)。
  140. var auditBodySensitiveSubstrings = []string{
  141. "password", "passwd", "secret", "token",
  142. "apikey", "accesskey", "privatekey",
  143. "otp", "credentialvalue",
  144. "sessionkey", "serviceaccount",
  145. }
  146. func isAuditSensitiveBodyKey(key string) bool {
  147. k := auditNormalizeBodyKey(key)
  148. if _, ok := auditBodySensitiveExactKeys[k]; ok {
  149. return true
  150. }
  151. for _, sub := range auditBodySensitiveSubstrings {
  152. if strings.Contains(k, sub) {
  153. return true
  154. }
  155. }
  156. return false
  157. }
  158. const auditRedactedPlaceholder = "***"
  159. // RedactAuditBody 对请求体做审计入库前的脱敏:
  160. // - JSON:递归擦除敏感键的值(保留结构,base_url 等非敏感字段可见以便追责)
  161. // - 非 JSON:返回占位说明
  162. // - 超长:截断并附截断标记
  163. func RedactAuditBody(raw []byte, contentType string) string {
  164. if len(raw) == 0 {
  165. return ""
  166. }
  167. if len(raw) > AuditRequestBodyCaptureLimit {
  168. // raw 可能已被中间件按上限截断,实际请求体只会更大,不报具体字节数。
  169. return "<body omitted: exceeds " + strconv.Itoa(AuditRequestBodyCaptureLimit) + " bytes>"
  170. }
  171. ct := strings.ToLower(contentType)
  172. if !strings.Contains(ct, "json") || !json.Valid(raw) {
  173. // 表单等非 JSON 内容走文本兜底脱敏后仍可能含敏感信息,直接不入库。
  174. return "<non-json body omitted: " + strconv.Itoa(len(raw)) + " bytes, content-type=" + strings.TrimSpace(contentType) + ">"
  175. }
  176. var value any
  177. if err := json.Unmarshal(raw, &value); err != nil {
  178. return "<unparsable body omitted>"
  179. }
  180. redacted := redactAuditValue(value, 0)
  181. encoded, err := json.Marshal(redacted)
  182. if err != nil {
  183. return "<redacted>"
  184. }
  185. out := string(encoded)
  186. if len(out) > auditRequestBodyMaxBytes {
  187. out = out[:auditRequestBodyMaxBytes] + "...<truncated>"
  188. }
  189. return out
  190. }
  191. const auditRedactMaxDepth = 24
  192. func redactAuditValue(value any, depth int) any {
  193. if depth > auditRedactMaxDepth {
  194. return "<depth limit exceeded>"
  195. }
  196. switch v := value.(type) {
  197. case map[string]any:
  198. out := make(map[string]any, len(v))
  199. for k, item := range v {
  200. if isAuditSensitiveBodyKey(k) {
  201. out[k] = auditRedactedPlaceholder
  202. continue
  203. }
  204. out[k] = redactAuditValue(item, depth+1)
  205. }
  206. return out
  207. case []any:
  208. out := make([]any, len(v))
  209. for i, item := range v {
  210. out[i] = redactAuditValue(item, depth+1)
  211. }
  212. return out
  213. default:
  214. return value
  215. }
  216. }
  217. // MaskAuditCredential 对请求头中的凭证做首尾保留掩码:
  218. // 保留前 6 位与后 4 位,中间以 **** 表示;过短的凭证整体掩码。
  219. func MaskAuditCredential(credential string) string {
  220. credential = strings.TrimSpace(credential)
  221. if credential == "" {
  222. return ""
  223. }
  224. if len(credential) <= 14 {
  225. return "****"
  226. }
  227. return credential[:6] + "****" + credential[len(credential)-4:]
  228. }
  229. // RedactAuditQuery 对 URL query 做轻量脱敏后返回。
  230. func RedactAuditQuery(rawQuery string) string {
  231. rawQuery = strings.TrimSpace(rawQuery)
  232. if rawQuery == "" {
  233. return ""
  234. }
  235. return logredact.RedactText(rawQuery, "api_key", "apikey", "token", "secret", "key")
  236. }