Files
team-api/internal/middleware/operation_log.go
T
2026-05-10 12:21:27 +08:00

269 lines
6.8 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"
)
// 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,
}
// 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
}
// Capture request body (before Next() reads it)
body := r.GetBodyString()
r.Middleware.Next()
// Only log successful operations
if r.Response.Status >= 400 {
return
}
// Get auth info
userID := GetUserID(r.Context())
userType := GetUserType(r.Context())
if userID == 0 {
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 matching actual table columns
logData := g.Map{
"user_id": userID,
"user_type": userType,
"action": action,
"resource_type": resourceType,
"ip_address": r.GetClientIp(),
"detail": string(detailJSON),
}
// Set resource_id if available
if resourceID > 0 {
logData["resource_id"] = resourceID
}
// Set tenant_id for tenant operations
tenantID := GetTenantID(r.Context())
if tenantID > 0 {
logData["tenant_id"] = tenantID
}
// Build changes_json with structured request data
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["changes_json"] = string(wrapped)
}
}
}
// Async write to audit log
go func() {
ctx := context.Background()
_, err := g.DB().Model("aud_operation_logs").Ctx(ctx).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" or "tenant"
idx := 0
if len(parts) > 0 && (parts[0] == "admin" || parts[0] == "tenant") {
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
}
for key := range data {
if isSensitive(key) {
data[key] = "******"
}
}
masked, err := json.Marshal(data)
if err != nil {
return body
}
return string(masked)
}
// isSensitive checks if a field name is sensitive.
func isSensitive(field string) bool {
field = strings.ToLower(field)
return sensitiveFields[field]
}