Files
sub2api/backend/internal/service/audit_log.go
T

259 lines
8.8 KiB
Go
Raw Normal View History

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/3xxfalse 仅 >=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 "<body omitted: exceeds " + strconv.Itoa(AuditRequestBodyCaptureLimit) + " bytes>"
}
ct := strings.ToLower(contentType)
if !strings.Contains(ct, "json") || !json.Valid(raw) {
// 表单等非 JSON 内容走文本兜底脱敏后仍可能含敏感信息,直接不入库。
return "<non-json body omitted: " + strconv.Itoa(len(raw)) + " bytes, content-type=" + strings.TrimSpace(contentType) + ">"
}
var value any
if err := json.Unmarshal(raw, &value); err != nil {
return "<unparsable body omitted>"
}
redacted := redactAuditValue(value, 0)
encoded, err := json.Marshal(redacted)
if err != nil {
return "<redacted>"
}
out := string(encoded)
if len(out) > auditRequestBodyMaxBytes {
out = out[:auditRequestBodyMaxBytes] + "...<truncated>"
}
return out
}
const auditRedactMaxDepth = 24
func redactAuditValue(value any, depth int) any {
if depth > auditRedactMaxDepth {
return "<depth limit exceeded>"
}
switch v := value.(type) {
case map[string]any:
out := make(map[string]any, len(v))
for k, item := range v {
if isAuditSensitiveBodyKey(k) {
out[k] = auditRedactedPlaceholder
continue
}
out[k] = redactAuditValue(item, depth+1)
}
return out
case []any:
out := make([]any, len(v))
for i, item := range v {
out[i] = redactAuditValue(item, depth+1)
}
return out
default:
return value
}
}
// MaskAuditCredential 对请求头中的凭证做首尾保留掩码:
// 保留前 6 位与后 4 位,中间以 **** 表示;过短的凭证整体掩码。
func MaskAuditCredential(credential string) string {
credential = strings.TrimSpace(credential)
if credential == "" {
return ""
}
if len(credential) <= 14 {
return "****"
}
return credential[:6] + "****" + credential[len(credential)-4:]
}
// RedactAuditQuery 对 URL query 做轻量脱敏后返回。
func RedactAuditQuery(rawQuery string) string {
rawQuery = strings.TrimSpace(rawQuery)
if rawQuery == "" {
return ""
}
return logredact.RedactText(rawQuery, "api_key", "apikey", "token", "secret", "key")
}