优化
This commit is contained in:
@@ -1,8 +1,6 @@
|
||||
package middleware
|
||||
|
||||
import (
|
||||
"strings"
|
||||
|
||||
"kra/internal/biz"
|
||||
"kra/internal/conf"
|
||||
"kra/internal/server/httpx"
|
||||
@@ -20,11 +18,8 @@ func AccessControl(runtime *conf.Runtime, access *service.AccessControlService)
|
||||
}
|
||||
path := c.Request.URL.Path
|
||||
policyPath := path
|
||||
if config := runtime.Admin(); config != nil && config.RouterPrefix != "" {
|
||||
policyPath = strings.TrimPrefix(policyPath, strings.TrimSuffix(config.RouterPrefix, "/"))
|
||||
if policyPath == "" {
|
||||
policyPath = "/"
|
||||
}
|
||||
if config := runtime.Admin(); config != nil {
|
||||
policyPath = service.NormalizeRoutePath(policyPath, config.RouterPrefix)
|
||||
}
|
||||
allowed, err := access.Authorize(c.Request.Context(), claims.AuthorityID, policyPath, c.Request.Method)
|
||||
if err != nil || !allowed {
|
||||
|
||||
@@ -3,6 +3,7 @@ package middleware
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/url"
|
||||
@@ -17,6 +18,8 @@ import (
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
const ctxOperationAuditPersistFailedKey = "operation_audit_persist_failed"
|
||||
|
||||
func OperationAudit(runtime *conf.Runtime, service *service.AuditRecorder) gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
path := c.Request.URL.Path
|
||||
@@ -66,7 +69,12 @@ func OperationAudit(runtime *conf.Runtime, service *service.AuditRecorder) gin.H
|
||||
responseBody = "[超出记录长度]"
|
||||
}
|
||||
errorMessage := c.Errors.ByType(gin.ErrorTypePrivate).String()
|
||||
_ = service.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: operationRequestBody(requestBody, c.GetHeader("Content-Type"), maxBytes), Response: responseBody, UserID: userID, RequestID: stringValue(requestID), TraceID: stringValueFromContext(c, "trace_id"), DeviceID: c.GetHeader("X-Device-Id")})
|
||||
if err := service.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: operationRequestBody(requestBody, c.GetHeader("Content-Type"), maxBytes), 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))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -26,15 +26,18 @@ func CORS(runtime *conf.Runtime) gin.HandlerFunc {
|
||||
}
|
||||
mode := strings.TrimSpace(config.Cors.Mode)
|
||||
origin := c.GetHeader("Origin")
|
||||
corsHandled := false
|
||||
if mode == "allow-all" {
|
||||
setCORSHeaders(c, origin, defaultCORSHeaders, defaultCORSMethods, defaultCORSExpose, true)
|
||||
corsHandled = true
|
||||
} else if rule := matchingCORSRule(config.Cors.Whitelist, origin); rule != nil {
|
||||
setCORSHeaders(c, rule.AllowOrigin, rule.AllowHeaders, rule.AllowMethods, rule.ExposeHeaders, rule.AllowCredentials)
|
||||
corsHandled = true
|
||||
} else if mode == "strict-whitelist" && !(c.Request.Method == http.MethodGet && c.Request.URL.Path == "/health") {
|
||||
c.AbortWithStatus(http.StatusForbidden)
|
||||
return
|
||||
}
|
||||
if c.Request.Method == http.MethodOptions {
|
||||
if corsHandled && c.Request.Method == http.MethodOptions {
|
||||
c.AbortWithStatus(http.StatusNoContent)
|
||||
return
|
||||
}
|
||||
|
||||
@@ -0,0 +1,39 @@
|
||||
package middleware
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
|
||||
"kra/internal/conf"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
func runCORSTest(t *testing.T, admin *conf.AdminBackend, method, origin string) *httptest.ResponseRecorder {
|
||||
t.Helper()
|
||||
engine := gin.New()
|
||||
engine.Use(CORS(conf.NewRuntime(nil, admin)))
|
||||
engine.Any("/test", func(c *gin.Context) { c.Status(http.StatusOK) })
|
||||
request := httptest.NewRequest(method, "/test", nil)
|
||||
if origin != "" {
|
||||
request.Header.Set("Origin", origin)
|
||||
}
|
||||
response := httptest.NewRecorder()
|
||||
engine.ServeHTTP(response, request)
|
||||
return response
|
||||
}
|
||||
|
||||
func TestCORSDoesNotConsumeUnmatchedWhitelistPreflight(t *testing.T) {
|
||||
response := runCORSTest(t, &conf.AdminBackend{Cors: &conf.AdminBackend_CORS{Mode: "whitelist"}}, http.MethodOptions, "https://unknown.example")
|
||||
if response.Code != http.StatusOK {
|
||||
t.Fatalf("unmatched whitelist preflight status = %d, want %d", response.Code, http.StatusOK)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCORSConsumesMatchedWhitelistPreflight(t *testing.T) {
|
||||
response := runCORSTest(t, &conf.AdminBackend{Cors: &conf.AdminBackend_CORS{Mode: "whitelist", Whitelist: []*conf.AdminBackend_CORSRule{{AllowOrigin: "https://admin.example"}}}}, http.MethodOptions, "https://admin.example")
|
||||
if response.Code != http.StatusNoContent {
|
||||
t.Fatalf("matched whitelist preflight status = %d, want %d", response.Code, http.StatusNoContent)
|
||||
}
|
||||
}
|
||||
@@ -17,11 +17,13 @@ import (
|
||||
func ErrorAudit(logger *slog.Logger) gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
c.Next()
|
||||
privateErrors := strings.TrimSpace(c.Errors.ByType(gin.ErrorTypePrivate).String())
|
||||
auditPersistFailed, _ := c.Get(ctxOperationAuditPersistFailedKey)
|
||||
// sysError writes must never audit themselves. Log-viewer failures are
|
||||
// already recorded by the handler with the underlying filesystem error;
|
||||
// emitting again from the response envelope would duplicate both the
|
||||
// classified error file and the sys_error row.
|
||||
if strings.Contains(c.Request.URL.Path, "/sysError/") || strings.Contains(c.Request.URL.Path, "/logViewer/") || c.Writer.Status() >= 300 && c.Writer.Status() < 500 {
|
||||
if auditPersistFailed != true && (strings.Contains(c.Request.URL.Path, "/sysError/") || strings.Contains(c.Request.URL.Path, "/logViewer/") || c.Writer.Status() >= 300 && c.Writer.Status() < 500) {
|
||||
return
|
||||
}
|
||||
var response httpx.Response
|
||||
@@ -31,12 +33,22 @@ func ErrorAudit(logger *slog.Logger) gin.HandlerFunc {
|
||||
body = buffer.Bytes()
|
||||
}
|
||||
}
|
||||
if json.Unmarshal(body, &response) != nil || response.Code == httpx.CodeSuccess || expectedClientFailure(response.Msg) {
|
||||
if json.Unmarshal(body, &response) != nil && privateErrors == "" {
|
||||
return
|
||||
}
|
||||
if response.Code == httpx.CodeSuccess && privateErrors == "" {
|
||||
return
|
||||
}
|
||||
if privateErrors == "" && expectedClientFailure(response.Msg) {
|
||||
return
|
||||
}
|
||||
errorMessage := response.Msg
|
||||
if privateErrors != "" {
|
||||
errorMessage = privateErrors
|
||||
}
|
||||
requestID, _ := c.Get("request_id")
|
||||
if logger != nil {
|
||||
logger.ErrorContext(c.Request.Context(), "请求处理失败", "mod", failureLogModule(c.Request.URL.Path), "path", c.Request.URL.Path, "method", c.Request.Method, "status", c.Writer.Status(), "error", response.Msg, "request_id", stringValue(requestID), "trace_id", stringValueFromContext(c, "trace_id"))
|
||||
logger.ErrorContext(c.Request.Context(), "请求处理失败", "mod", failureLogModule(c.Request.URL.Path), "path", c.Request.URL.Path, "method", c.Request.Method, "status", c.Writer.Status(), "error", errorMessage, "request_id", stringValue(requestID), "trace_id", stringValueFromContext(c, "trace_id"))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,6 +2,7 @@ package middleware
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"errors"
|
||||
"log/slog"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
@@ -73,3 +74,38 @@ func TestErrorAuditSkipsLogViewerFailureAlreadyLoggedByHandler(t *testing.T) {
|
||||
t.Fatalf("log viewer failure must not be emitted twice, got %s", output.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestErrorAuditEmitsPrivateMiddlewareErrorOnSuccessResponse(t *testing.T) {
|
||||
var output bytes.Buffer
|
||||
logger := slog.New(slog.NewJSONHandler(&output, nil))
|
||||
gin.SetMode(gin.TestMode)
|
||||
engine := gin.New()
|
||||
engine.Use(ErrorAudit(logger))
|
||||
engine.GET("/test", func(c *gin.Context) {
|
||||
c.Error(errors.New("operation audit persist: database unavailable"))
|
||||
c.JSON(http.StatusOK, gin.H{"code": 0, "data": gin.H{}, "msg": "成功"})
|
||||
})
|
||||
response := httptest.NewRecorder()
|
||||
engine.ServeHTTP(response, httptest.NewRequest(http.MethodGet, "/test", nil))
|
||||
if !strings.Contains(output.String(), "operation audit persist: database unavailable") {
|
||||
t.Fatalf("private middleware error was not logged: %s", output.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestErrorAuditEmitsOperationAuditFailureForSysErrorRoute(t *testing.T) {
|
||||
var output bytes.Buffer
|
||||
logger := slog.New(slog.NewJSONHandler(&output, nil))
|
||||
gin.SetMode(gin.TestMode)
|
||||
engine := gin.New()
|
||||
engine.Use(ErrorAudit(logger))
|
||||
engine.PUT("/sysError/updateSysError", func(c *gin.Context) {
|
||||
c.Set(ctxOperationAuditPersistFailedKey, true)
|
||||
c.Error(errors.New("operation audit persist: database unavailable"))
|
||||
c.JSON(http.StatusOK, gin.H{"code": 0, "data": gin.H{}, "msg": "成功"})
|
||||
})
|
||||
response := httptest.NewRecorder()
|
||||
engine.ServeHTTP(response, httptest.NewRequest(http.MethodPut, "/sysError/updateSysError", nil))
|
||||
if !strings.Contains(output.String(), "operation audit persist: database unavailable") {
|
||||
t.Fatalf("sysError operation audit failure was not logged: %s", output.String())
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user