kra-new/internal/server/middleware/audit.go

164 lines
5.0 KiB
Go

package middleware
import (
"bytes"
"encoding/json"
"errors"
"fmt"
"io"
"net/http"
"net/url"
"strings"
"time"
"kra/internal/config"
"kra/internal/routecatalog"
"kra/internal/service/dto"
systemservice "kra/internal/service/system"
"github.com/gin-gonic/gin"
)
const ctxOperationAuditPersistFailedKey = "operation_audit_persist_failed"
func OperationAudit(runtime *config.Store, recorder *systemservice.AuditRecorder) gin.HandlerFunc {
return func(c *gin.Context) {
if runtime == nil || recorder == nil {
c.Next()
return
}
routePath := c.FullPath()
if routePath == "" {
routePath = c.Request.URL.Path
}
path := c.Request.URL.Path
if !routecatalog.ShouldAudit(c.Request.Method, routePath) {
c.Next()
return
}
var requestBody []byte
maxBytes := 1024
if snapshot := runtime.Snapshot(); snapshot != nil && snapshot.Admin != nil && snapshot.Admin.Zap != nil && snapshot.Admin.Zap.AccessLogMaxBytes > 0 {
maxBytes = snapshot.Admin.Zap.AccessLogMaxBytes
}
if c.Request.Method == http.MethodGet {
requestBody = operationQueryBody(c.Request.URL.RawQuery)
} else if _, ok := c.Get(ctxReqBodyKey); ok {
} else if c.Request.Body != nil {
// Fallback for tests or custom middleware chains that omit AccessLog.
if strings.Contains(c.GetHeader("Content-Type"), "multipart/form-data") {
requestBody = []byte("[文件]")
} else {
limited := http.MaxBytesReader(c.Writer, c.Request.Body, defaultRequestBodyLimit)
var err error
requestBody, err = io.ReadAll(limited)
if err != nil {
var tooLarge *http.MaxBytesError
if errors.As(err, &tooLarge) {
c.AbortWithStatus(http.StatusRequestEntityTooLarge)
} else {
c.AbortWithStatus(http.StatusBadRequest)
}
return
}
c.Request.Body = io.NopCloser(bytes.NewReader(requestBody))
}
}
started := time.Now()
c.Next()
userID := uint(0)
if claims := Claims(c); claims != nil {
userID = claims.ID
}
requestID, _ := c.Get("request_id")
status := c.Writer.Status()
responseBody := ""
if value, ok := c.Get(ctxRespBufferKey); ok {
if body, bok := value.(*bytes.Buffer); bok {
responseBody = redactJSON(body.Bytes(), c.Writer.Header().Get("Content-Type"), maxBytes)
}
}
if truncated, _ := c.Get(ctxRespTruncatedKey); truncated == true {
responseBody = "[超出记录长度]"
}
errorMessage := c.Errors.ByType(gin.ErrorTypePrivate).String()
operationBody := ""
capturedBody := false
if c.Request.Method != "GET" {
if value, ok := c.Get(ctxReqBodyKey); ok {
// AccessLog has already captured, redacted, summarized, and bounded the
// request body. Treat that value as final so audit does not process it a
// second time.
operationBody = stringValue(value)
capturedBody = true
} else {
operationBody = operationRequestBody(requestBody, c.GetHeader("Content-Type"), maxBytes)
}
} else {
operationBody = operationRequestBody(requestBody, c.GetHeader("Content-Type"), maxBytes)
}
if !capturedBody && routecatalog.BodyPolicyFor(c.Request.Method, path) == routecatalog.BodyPolicyPaymentConfig {
operationBody = paymentConfigSummary(requestBody)
}
if err := recorder.RecordOperationRequest(c.Request.Context(), &dto.OperationRecordRequest{IP: c.ClientIP(), Method: c.Request.Method, Path: path, Status: status, LatencyMS: time.Since(started).Milliseconds(), Agent: c.Request.UserAgent(), ErrorMessage: errorMessage, Body: operationBody, Response: responseBody, UserID: userID, RequestID: stringValue(requestID), TraceID: stringValueFromContext(c, "trace_id"), DeviceID: c.GetHeader("X-Device-Id")}); err != nil {
// Preserve the business response, but expose audit persistence failures
// to the global access/error logging pipeline.
c.Set(ctxOperationAuditPersistFailedKey, true)
_ = c.Error(fmt.Errorf("operation audit persist: %w", err))
}
}
}
func operationQueryBody(raw string) []byte {
parsed, _ := url.ParseQuery(raw)
values := make(map[string]string, len(parsed))
for key, items := range parsed {
if len(items) > 0 {
value := items[len(items)-1]
if isSensitivePayloadKey(key) {
value = redactedValue
}
values[key] = value
}
}
body, _ := json.Marshal(&values)
return body
}
func operationRequestBody(raw []byte, contentType string, limit int) string {
if strings.Contains(contentType, "multipart/form-data") {
return "[文件]"
}
text := string(raw)
if strings.Contains(strings.ToLower(contentType), "json") && text != "" {
var value any
if json.Unmarshal(raw, &value) == nil {
maskOperationBody(value)
if encoded, err := json.Marshal(value); err == nil {
text = string(encoded)
}
}
}
if len(text) > limit {
return "[超出记录长度]"
}
return text
}
func maskOperationBody(value any) {
switch current := value.(type) {
case map[string]any:
for key, item := range current {
if isSensitivePayloadKey(key) {
current[key] = redactedValue
continue
}
maskOperationBody(item)
}
case []any:
for _, item := range current {
maskOperationBody(item)
}
}
}