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] }