290 lines
7.7 KiB
Go
290 lines
7.7 KiB
Go
package middleware
|
|
|
|
import (
|
|
"context"
|
|
"encoding/json"
|
|
"strings"
|
|
|
|
"github.com/gogf/gf/v2/frame/g"
|
|
"github.com/gogf/gf/v2/net/ghttp"
|
|
"github.com/gogf/gf/v2/util/gconv"
|
|
|
|
lcommon "github.com/qianfree/team-api/internal/logic/common"
|
|
do "github.com/qianfree/team-api/internal/model/do"
|
|
)
|
|
|
|
// sensitiveFields are field names that should be masked in operation logs.
|
|
var sensitiveFields = map[string]bool{
|
|
"password": true,
|
|
"password_hash": true,
|
|
"old_password": true,
|
|
"new_password": true,
|
|
"token": true,
|
|
"access_token": true,
|
|
"refresh_token": true,
|
|
"secret": true,
|
|
"api_key": true,
|
|
"encrypted_key": true,
|
|
// 存储配置凭证(系统设置更新 / 存储连通性测试的请求体中携带)
|
|
"storage_access_key_id": true,
|
|
"storage_access_key_secret": true,
|
|
}
|
|
|
|
// resourcePluralToSingular maps URL path resource names to singular form.
|
|
var resourcePluralToSingular = map[string]string{
|
|
"channels": "channel",
|
|
"models": "model",
|
|
"tenants": "tenant",
|
|
"users": "user",
|
|
"keys": "key",
|
|
"abilities": "ability",
|
|
"plans": "plan",
|
|
"orders": "order",
|
|
"redemptions": "redemption",
|
|
"promo-codes": "promo_code",
|
|
"sessions": "session",
|
|
"members": "member",
|
|
"announcements": "announcement",
|
|
"tickets": "ticket",
|
|
"templates": "template",
|
|
"messages": "message",
|
|
"settings": "settings",
|
|
"permissions": "permissions",
|
|
"data-scopes": "data_scopes",
|
|
"wallet": "wallet",
|
|
"audit": "audit",
|
|
"auth": "auth",
|
|
"notifications": "notification",
|
|
"pricing-tiers": "pricing_tier",
|
|
"channel-scope": "channel_scope",
|
|
}
|
|
|
|
// actionVerbMap maps HTTP methods to action prefixes.
|
|
var actionVerbMap = map[string]string{
|
|
"POST": "create",
|
|
"PUT": "update",
|
|
"DELETE": "delete",
|
|
"PATCH": "update",
|
|
}
|
|
|
|
// OperationLog records admin operations for audit trail.
|
|
func OperationLog(r *ghttp.Request) {
|
|
// Only log write operations
|
|
method := r.Method
|
|
if method != "POST" && method != "PUT" && method != "DELETE" {
|
|
r.Middleware.Next()
|
|
return
|
|
}
|
|
|
|
// Skip body capture for multipart/form-data (file uploads)
|
|
// r.GetBodyString() consumes the body stream and breaks multipart parsing
|
|
var body string
|
|
if !strings.HasPrefix(r.Header.Get("Content-Type"), "multipart/") {
|
|
body = r.GetBodyString()
|
|
}
|
|
|
|
r.Middleware.Next()
|
|
|
|
// Only log successful operations
|
|
if r.Response.Status >= 400 {
|
|
return
|
|
}
|
|
|
|
// Get auth info;开放平台请求没有用户身份,以应用身份(open_app + 应用ID)留痕,
|
|
// 否则 /api/open 的建成员/建删 Key 等写操作在三张审计表里都不存在
|
|
userID := GetUserID(r.Context())
|
|
userType := GetUserType(r.Context())
|
|
tenantID := GetTenantID(r.Context())
|
|
if userID == 0 {
|
|
if appID := GetOpenAppID(r.Context()); appID > 0 {
|
|
userID = appID
|
|
userType = "open_app"
|
|
tenantID = GetOpenTenantID(r.Context())
|
|
} else {
|
|
return
|
|
}
|
|
}
|
|
|
|
// Parse resource info from URL path
|
|
resourceType, resourceID := parseResourceFromPath(r.URL.Path)
|
|
action := buildAction(method, r.URL.Path, resourceType)
|
|
|
|
// Mask sensitive fields in request body
|
|
maskedBody := maskSensitiveFields(body)
|
|
|
|
// Build detail JSONB
|
|
detail := map[string]any{
|
|
"path": r.URL.Path,
|
|
"method": method,
|
|
"status_code": r.Response.Status,
|
|
"user_agent": r.Header.Get("User-Agent"),
|
|
}
|
|
if maskedBody != "" {
|
|
detail["request_body"] = maskedBody
|
|
}
|
|
if requestID := r.GetCtxVar("RequestId"); requestID != nil {
|
|
detail["request_id"] = requestID.String()
|
|
}
|
|
detailJSON, _ := json.Marshal(detail)
|
|
|
|
// Build log data using DO struct
|
|
logData := do.AudOperationLogs{
|
|
UserId: userID,
|
|
UserType: userType,
|
|
Action: action,
|
|
ResourceType: resourceType,
|
|
IpAddress: r.GetClientIp(),
|
|
Detail: string(detailJSON),
|
|
}
|
|
|
|
if resourceID > 0 {
|
|
logData.ResourceId = resourceID
|
|
}
|
|
|
|
if tenantID > 0 {
|
|
logData.TenantId = tenantID
|
|
}
|
|
|
|
if maskedBody != "" {
|
|
var changesJSON map[string]any
|
|
if err := json.Unmarshal([]byte(maskedBody), &changesJSON); err == nil {
|
|
wrapper := map[string]any{
|
|
"updated_fields": changesJSON,
|
|
}
|
|
if wrapped, err := json.Marshal(wrapper); err == nil {
|
|
logData.ChangesJson = string(wrapped)
|
|
}
|
|
}
|
|
}
|
|
|
|
go func() {
|
|
ctx := context.Background()
|
|
_, err := lcommon.AuditModelCtx(ctx, "aud_operation_logs").Data(logData).Insert()
|
|
if err != nil {
|
|
g.Log().Errorf(ctx, "write operation log: %v", err)
|
|
}
|
|
}()
|
|
}
|
|
|
|
// parseResourceFromPath extracts resource type and ID from URL path.
|
|
// e.g. /api/admin/channels/123/keys → ("channel", 123)
|
|
// e.g. /api/admin/models → ("model", 0)
|
|
// e.g. /api/admin/auth/logout → ("auth", 0)
|
|
func parseResourceFromPath(path string) (resourceType string, resourceID int64) {
|
|
// Normalize: remove /api/ prefix and split
|
|
path = strings.TrimPrefix(path, "/api/")
|
|
parts := strings.Split(strings.Trim(path, "/"), "/")
|
|
|
|
// Skip prefix like "admin", "tenant" or "open"
|
|
idx := 0
|
|
if len(parts) > 0 && (parts[0] == "admin" || parts[0] == "tenant" || parts[0] == "open") {
|
|
idx = 1
|
|
}
|
|
|
|
// Look for known resource names
|
|
for i := idx; i < len(parts); i++ {
|
|
if singular, ok := resourcePluralToSingular[parts[i]]; ok {
|
|
resourceType = singular
|
|
// Check if next part is a numeric ID
|
|
if i+1 < len(parts) {
|
|
if id := gconv.Int64(parts[i+1]); id > 0 {
|
|
resourceID = id
|
|
}
|
|
}
|
|
return
|
|
}
|
|
}
|
|
|
|
// Fallback: use last meaningful path segment
|
|
if len(parts) > idx {
|
|
resourceType = parts[len(parts)-1]
|
|
// Check if the second-to-last is numeric → use the segment before it
|
|
if len(parts) >= 2 {
|
|
if gconv.Int64(parts[len(parts)-1]) > 0 {
|
|
resourceType = parts[len(parts)-2]
|
|
}
|
|
}
|
|
}
|
|
return
|
|
}
|
|
|
|
// buildAction creates a semantic action name from HTTP method and path.
|
|
// e.g. POST + /api/admin/channels → "create_channel"
|
|
// e.g. PUT + /api/admin/channels/123 → "update_channel"
|
|
// e.g. DELETE + /api/admin/channels/123 → "delete_channel"
|
|
func buildAction(method, path, resourceType string) string {
|
|
verb, ok := actionVerbMap[strings.ToUpper(method)]
|
|
if !ok {
|
|
verb = strings.ToLower(method)
|
|
}
|
|
|
|
if resourceType != "" {
|
|
// Special cases for non-CRUD endpoints
|
|
path = strings.TrimPrefix(path, "/api/")
|
|
parts := strings.Split(strings.Trim(path, "/"), "/")
|
|
|
|
// Check for special action suffixes
|
|
if len(parts) >= 2 {
|
|
last := parts[len(parts)-1]
|
|
secondLast := parts[len(parts)-2]
|
|
|
|
// If last part is not a number and not a resource name, it's a custom action
|
|
_, isResource := resourcePluralToSingular[last]
|
|
|
|
if gconv.Int64(last) == 0 && !isResource {
|
|
// Custom action: e.g. /channels/123/test → "test_channel"
|
|
// or /auth/logout → "logout_auth"
|
|
return last + "_" + resourceType
|
|
}
|
|
|
|
// If secondLast is a number and last is not a resource, custom action on specific resource
|
|
if gconv.Int64(secondLast) > 0 && !isResource {
|
|
return last + "_" + resourceType
|
|
}
|
|
}
|
|
|
|
return verb + "_" + resourceType
|
|
}
|
|
|
|
return strings.ToLower(method)
|
|
}
|
|
|
|
// maskSensitiveFields masks sensitive field values in JSON body.
|
|
func maskSensitiveFields(body string) string {
|
|
if body == "" {
|
|
return body
|
|
}
|
|
|
|
var data map[string]any
|
|
if err := json.Unmarshal([]byte(body), &data); err != nil {
|
|
return body // Not JSON, return as-is
|
|
}
|
|
|
|
maskSensitiveMap(data)
|
|
|
|
masked, err := json.Marshal(data)
|
|
if err != nil {
|
|
return body
|
|
}
|
|
return string(masked)
|
|
}
|
|
|
|
// maskSensitiveMap recursively masks sensitive fields in a map.
|
|
func maskSensitiveMap(data map[string]any) {
|
|
for key, val := range data {
|
|
if isSensitive(key) {
|
|
data[key] = "******"
|
|
continue
|
|
}
|
|
if nested, ok := val.(map[string]any); ok {
|
|
maskSensitiveMap(nested)
|
|
}
|
|
}
|
|
}
|
|
|
|
// isSensitive checks if a field name is sensitive.
|
|
func isSensitive(field string) bool {
|
|
field = strings.ToLower(field)
|
|
return sensitiveFields[field]
|
|
}
|