package service import ( "context" "encoding/json" "strconv" "strings" "time" infraerrors "github.com/Wei-Shaw/sub2api/internal/pkg/errors" "github.com/Wei-Shaw/sub2api/internal/util/logredact" ) // ErrAuditLogNotFound 审计日志不存在。 var ErrAuditLogNotFound = infraerrors.NotFound("AUDIT_LOG_NOT_FOUND", "audit log not found") // 审计日志相关常量。 const ( // AuditAuthMethodJWT / AuditAuthMethodAdminAPIKey 与 auth 中间件写入的 auth_method 对齐。 AuditAuthMethodJWT = "jwt" AuditAuthMethodAdminAPIKey = "admin_api_key" AuditAuthMethodPasskey = "passkey" // auditRequestBodyMaxBytes 请求体脱敏后入库的最大长度(字节),超出截断。 auditRequestBodyMaxBytes = 16 * 1024 // AuditRequestBodyCaptureLimit 请求体参与脱敏解析的原始大小上限(字节)。 // 审计中间件按此上限截断读取,超出的请求体仅记录占位符不解析。 AuditRequestBodyCaptureLimit = 256 * 1024 ) // 内置审计动作名(认证/安全事件与特殊操作使用固定值,普通请求由路由自动推导)。 const ( AuditActionLogin = "auth.login" AuditActionLogin2FA = "auth.login.2fa" AuditActionRegister = "auth.register" AuditActionTokenRefresh = "auth.token.refresh" AuditActionSessionBindingMismatch = "auth.session_binding.mismatch" AuditActionStepUpVerify = "auth.step_up.verify" AuditActionAuditLogClear = "admin.audit_log.clear" ) // AuditLog 一条管理面操作审计记录。 type AuditLog struct { ID int64 `json:"id"` CreatedAt time.Time `json:"created_at"` ActorUserID *int64 `json:"actor_user_id,omitempty"` ActorEmail string `json:"actor_email"` ActorRole string `json:"actor_role"` AuthMethod string `json:"auth_method"` CredentialMasked string `json:"credential_masked"` Action string `json:"action"` Method string `json:"method"` Path string `json:"path"` RequestID string `json:"request_id"` ClientIP string `json:"client_ip"` UserAgent string `json:"user_agent"` RequestBody string `json:"request_body,omitempty"` StatusCode int `json:"status_code"` LatencyMs int64 `json:"latency_ms"` Extra map[string]any `json:"extra,omitempty"` } // AuditLogFilter 审计日志列表查询条件。 type AuditLogFilter struct { Page int PageSize int StartTime *time.Time EndTime *time.Time ActorUserID *int64 ActorEmail string AuthMethod string Action string Method string ClientIP string // Success: nil 全部;true 仅 2xx/3xx;false 仅 >=400。 Success *bool // Query 对 path / action / actor_email 做模糊匹配。 Query string } // AuditLogList 分页结果。 type AuditLogList struct { Logs []*AuditLog Total int Page int PageSize int } // AuditLogRepository 审计日志持久化端口。 // 注意:接口刻意不提供单条删除能力——审计日志只允许追加与全量清空。 type AuditLogRepository interface { BatchInsert(ctx context.Context, logs []*AuditLog) (int64, error) // Insert 同步写入单条(用于清空留痕等必须落库的记录)。 Insert(ctx context.Context, log *AuditLog) error List(ctx context.Context, filter *AuditLogFilter) (*AuditLogList, error) GetByID(ctx context.Context, id int64) (*AuditLog, error) Count(ctx context.Context) (int64, error) // TruncateAll 全量清空(TRUNCATE),返回前需调用方自行 Count 记录行数。 TruncateAll(ctx context.Context) error // DeleteBefore 按保留期批量删除,返回本批删除行数(幂等,可多实例并发)。 DeleteBefore(ctx context.Context, cutoff time.Time, batchSize int) (int64, error) } // auditNormalizeBodyKey 归一化键名:小写并去除分隔符, // 使 private_key / privateKey / privatekey / api-v3-key 等写法共享同一判定, // 避免子串清单假设 snake_case 而漏掉支付渠道等无分隔符风格的密钥字段。 func auditNormalizeBodyKey(key string) string { var b strings.Builder b.Grow(len(key)) for _, r := range strings.ToLower(strings.TrimSpace(key)) { switch r { case '_', '-', '.', ' ': continue default: _, _ = b.WriteRune(r) } } return b.String() } // auditBodySensitiveExactKeys 请求体脱敏的精确匹配键(归一化后)。 // 除内置清单外,程序化并入两份权威敏感表以防清单漂移: // - SensitiveCredentialKeys:账号 credentials 的敏感子键(session_key / service_account_json 等) // - providerSensitiveConfigFields:支付渠道密钥字段(pkey / privatekey / apiv3key 等) var auditBodySensitiveExactKeys = func() map[string]struct{} { builtin := []string{ "code", "codes", "pin", "cvv", "authorization", "cookie", "x-api-key", "key", // 字符串值内嵌完整凭证的字段: // proxy_key 为 protocol|host|port|username|password 拼接, // custom_key 为用户自设的平台 API Key 明文, // session 为 Ollama Cloud 用量的浏览器会话 Cookie 明文。 "proxy_key", "custom_key", "session", } set := make(map[string]struct{}, len(builtin)+len(SensitiveCredentialKeys)+16) for _, k := range builtin { set[auditNormalizeBodyKey(k)] = struct{}{} } for _, k := range SensitiveCredentialKeys { set[auditNormalizeBodyKey(k)] = struct{}{} } for _, fields := range providerSensitiveConfigFields { for k := range fields { set[auditNormalizeBodyKey(k)] = struct{}{} } } return set }() // auditBodySensitiveSubstrings 请求体脱敏的包含匹配子串(对归一化后的键名比对)。 // 命中任一子串即整体擦除该键的值(例如 new_password / secret_access_key / temp_token)。 var auditBodySensitiveSubstrings = []string{ "password", "passwd", "secret", "token", "apikey", "accesskey", "privatekey", "otp", "credentialvalue", "sessionkey", "serviceaccount", } func isAuditSensitiveBodyKey(key string) bool { k := auditNormalizeBodyKey(key) if _, ok := auditBodySensitiveExactKeys[k]; ok { return true } for _, sub := range auditBodySensitiveSubstrings { if strings.Contains(k, sub) { return true } } return false } const auditRedactedPlaceholder = "***" // RedactAuditBody 对请求体做审计入库前的脱敏: // - JSON:递归擦除敏感键的值(保留结构,base_url 等非敏感字段可见以便追责) // - 非 JSON:返回占位说明 // - 超长:截断并附截断标记 func RedactAuditBody(raw []byte, contentType string) string { if len(raw) == 0 { return "" } if len(raw) > AuditRequestBodyCaptureLimit { // raw 可能已被中间件按上限截断,实际请求体只会更大,不报具体字节数。 return "
" } ct := strings.ToLower(contentType) if !strings.Contains(ct, "json") || !json.Valid(raw) { // 表单等非 JSON 内容走文本兜底脱敏后仍可能含敏感信息,直接不入库。 return "