This commit is contained in:
2026-08-16 09:02:39 +08:00
parent c554152f4f
commit f7f6db8695
105 changed files with 2535 additions and 948 deletions
+1 -5
View File
@@ -7,12 +7,11 @@ import (
"kra/internal/conf"
"kra/internal/server/httpx"
"kra/internal/service"
"kra/internal/service/dto"
"github.com/gin-gonic/gin"
)
func AccessControl(runtime *conf.Runtime, access *service.AccessControlService, audit *service.AuditRecorder) gin.HandlerFunc {
func AccessControl(runtime *conf.Runtime, access *service.AccessControlService) gin.HandlerFunc {
return func(c *gin.Context) {
claims := Claims(c)
if claims == nil {
@@ -29,9 +28,6 @@ func AccessControl(runtime *conf.Runtime, access *service.AccessControlService,
}
allowed, err := access.Authorize(c.Request.Context(), claims.AuthorityID, policyPath, c.Request.Method)
if err != nil || !allowed {
requestID, _ := c.Get("request_id")
requestIDText, _ := requestID.(string)
_ = audit.RecordDataAccessRequest(c.Request.Context(), &dto.DataAccessRecordRequest{EventType: "blocked_access", Operation: c.Request.Method, UserID: claims.ID, AuthorityID: claims.AuthorityID, RequestID: requestIDText, Method: c.Request.Method, Path: path, Detail: "Casbin policy denied the request"})
httpx.Write(c, httpx.CodeError, gin.H{}, "权限不足")
c.Abort()
return
+86 -8
View File
@@ -2,7 +2,11 @@ package middleware
import (
"bytes"
"encoding/json"
"io"
"net/http"
"net/url"
"strconv"
"strings"
"time"
@@ -21,11 +25,13 @@ func OperationAudit(runtime *conf.Runtime, service *service.AuditRecorder) gin.H
return
}
var requestBody []byte
maxBytes := 32768
maxBytes := 1024
if config := runtime.Admin(); config != nil && config.Zap != nil && config.Zap.AccessLogMaxBytes > 0 {
maxBytes = int(config.Zap.AccessLogMaxBytes)
}
if c.Request.Body != nil {
if c.Request.Method == http.MethodGet {
requestBody = operationQueryBody(c.Request.URL.RawQuery)
} else if c.Request.Body != nil {
if strings.Contains(c.GetHeader("Content-Type"), "multipart/form-data") {
requestBody = []byte("[文件]")
} else {
@@ -33,23 +39,95 @@ func OperationAudit(runtime *conf.Runtime, service *service.AuditRecorder) gin.H
c.Request.Body = io.NopCloser(bytes.NewReader(requestBody))
}
}
writer := &captureWriter{ResponseWriter: c.Writer, maxBytes: maxBytes}
// The reference middleware captures up to the global 1 MiB response
// safety limit and only applies the configured operation-log limit when a
// download response is recorded. Using maxBytes here would silently
// truncate ordinary JSON responses before that decision is possible.
writer := &captureWriter{ResponseWriter: c.Writer, maxBytes: 1 << 20}
c.Writer = writer
started := time.Now()
c.Next()
userID := uint(0)
if claims := Claims(c); claims != nil {
userID = claims.ID
} else if value, err := strconv.ParseUint(c.GetHeader("x-user-id"), 10, 64); err == nil {
userID = uint(value)
}
requestID, _ := c.Get("request_id")
status, errorMessage := c.Writer.Status(), ""
if status >= 400 {
errorMessage = writer.body.String()
status := c.Writer.Status()
responseBody := writer.body.String()
if isDownloadResponse(c) && len(responseBody) > maxBytes {
responseBody = "[超出记录长度]"
}
_ = 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: redactJSONLimit(requestBody, maxBytes), Response: writer.body.String(), UserID: userID, RequestID: stringValue(requestID), TraceID: stringValueFromContext(c, "trace_id"), DeviceID: c.GetHeader("X-Device-Id")})
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")})
}
}
func operationQueryBody(raw string) []byte {
query, _ := url.QueryUnescape(raw)
values := make(map[string]string)
for _, item := range strings.Split(query, "&") {
parts := strings.Split(item, "=")
if len(parts) == 2 {
values[parts[0]] = parts[1]
}
}
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 {
normalized := strings.ToLower(strings.ReplaceAll(strings.ReplaceAll(key, "_", ""), "-", ""))
if normalized == "password" || normalized == "newpassword" || normalized == "oldpassword" || normalized == "confirmpassword" || normalized == "passwd" || normalized == "pwd" || normalized == "token" || normalized == "accesstoken" || normalized == "refreshtoken" || normalized == "secret" || normalized == "clientsecret" || normalized == "apikey" || normalized == "privatekey" || normalized == "idcard" {
current[key] = "***"
continue
}
maskOperationBody(item)
}
case []any:
for _, item := range current {
maskOperationBody(item)
}
}
}
func isDownloadResponse(c *gin.Context) bool {
header := c.Writer.Header()
return strings.Contains(header.Get("Pragma"), "public") ||
strings.Contains(header.Get("Expires"), "0") ||
strings.Contains(header.Get("Cache-Control"), "must-revalidate, post-check=0, pre-check=0") ||
strings.Contains(header.Get("Content-Type"), "application/force-download") ||
strings.Contains(header.Get("Content-Type"), "application/octet-stream") ||
strings.Contains(header.Get("Content-Type"), "application/vnd.ms-excel") ||
strings.Contains(header.Get("Content-Type"), "application/download") ||
strings.Contains(header.Get("Content-Disposition"), "attachment") ||
strings.Contains(header.Get("Content-Transfer-Encoding"), "binary")
}
// recordsOperation mirrors the routes on which operation records are enabled.
// Matching by suffix keeps the behavior stable when router-prefix is configured.
func recordsOperation(method, path string) bool {
@@ -82,7 +160,7 @@ var operationRoutes = func() map[string]struct{} {
"DELETE /sysVersion/deleteSysVersion", "DELETE /sysVersion/deleteSysVersionByIds", "POST /sysVersion/exportVersion", "POST /sysVersion/importVersion",
"POST /sysExportTemplate/createSysExportTemplate", "DELETE /sysExportTemplate/deleteSysExportTemplate", "DELETE /sysExportTemplate/deleteSysExportTemplateByIds", "PUT /sysExportTemplate/updateSysExportTemplate", "POST /sysExportTemplate/importExcel",
"DELETE /sysError/deleteSysError", "DELETE /sysError/deleteSysErrorByIds", "PUT /sysError/updateSysError",
"DELETE /sysLoginLog/deleteLoginLog", "DELETE /sysLoginLog/deleteLoginLogByIds", "DELETE /sysOperationRecord/deleteSysOperationRecord", "DELETE /sysOperationRecord/deleteSysOperationRecordByIds", "DELETE /dataAccessLog/deleteDataAccessLogByIds",
"DELETE /sysLoginLog/deleteLoginLog", "DELETE /sysLoginLog/deleteLoginLogByIds", "DELETE /dataAccessLog/deleteDataAccessLogByIds",
"POST /timedTask/createTimedTask", "PUT /timedTask/updateTimedTask", "DELETE /timedTask/deleteTimedTask", "POST /timedTask/toggleTimedTask", "POST /timedTask/triggerTimedTask",
"POST /info/createInfo", "DELETE /info/deleteInfo", "DELETE /info/deleteInfoByIds", "PUT /info/updateInfo", "POST /email/emailTest", "POST /email/sendEmail",
}
+1 -1
View File
@@ -27,7 +27,7 @@ func ErrorAudit(audit *service.AuditRecorder) gin.HandlerFunc {
return
}
requestID, _ := c.Get("request_id")
_ = audit.CreateErrorRequest(c.Request.Context(), &dto.ErrorRecordRequest{Form: c.Request.URL.Path, Info: response.Msg, Level: "error", RequestID: stringValue(requestID), TraceID: stringValueFromContext(c, "trace_id"), Status: "未解决"})
_ = audit.CreateErrorRequest(c.Request.Context(), &dto.ErrorRecordRequest{Form: c.Request.URL.Path, Info: response.Msg, Level: "error", RequestID: stringValue(requestID), TraceID: stringValueFromContext(c, "trace_id")})
}
}
+1 -1
View File
@@ -36,7 +36,7 @@ func Recovery(audit *service.AuditRecorder, logger *slog.Logger) gin.HandlerFunc
logger.ErrorContext(c.Request.Context(), "recovery from panic", "error", panicValue, "request", string(request), "stack", string(debug.Stack()))
}
requestID, _ := c.Get("request_id")
_ = audit.CreateErrorRequest(c.Request.Context(), &dto.ErrorRecordRequest{Form: c.Request.URL.Path, Info: info, Level: "error", RequestID: stringValue(requestID), TraceID: stringValueFromContext(c, "trace_id"), Status: "未解决"})
_ = audit.CreateErrorRequest(c.Request.Context(), &dto.ErrorRecordRequest{Form: c.Request.URL.Path, Info: info, Level: "error", RequestID: stringValue(requestID), TraceID: stringValueFromContext(c, "trace_id")})
if brokenPipe {
if err, ok := panicValue.(error); ok {
_ = c.Error(err)