Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,7 @@ const (
OperationPathKey = Wso2MetadataPrefix + "operation-path"
APIKindKey = Wso2MetadataPrefix + "api-kind"
ProjectIDKey = Wso2MetadataPrefix + "project-id"
UserIDKey = Wso2MetadataPrefix + "user-id"
)

// convertToStructValue converts a value to structpb.Value, handling complex types like map[string][]string
Expand Down Expand Up @@ -96,6 +97,10 @@ func buildAnalyticsStruct(analyticsData map[string]any, execCtx *PolicyExecution
if sharedCtx.ProjectID != "" {
fields[ProjectIDKey] = structpb.NewStringValue(sharedCtx.ProjectID)
}
// Extract UserID from AuthContext if available
if sharedCtx.AuthContext != nil && sharedCtx.AuthContext.UserID != "" {
fields[UserIDKey] = structpb.NewStringValue(sharedCtx.AuthContext.UserID)
}
}

return &structpb.Struct{Fields: fields}, nil
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -359,7 +359,10 @@ func (ec *PolicyExecutionContext) buildRequestContext(headers *extprocv3.HttpHea
APIContext: routeMetadata.Context,
OperationPath: routeMetadata.OperationPath,
Metadata: make(map[string]interface{}),
AuthContext: make(map[string]string),
AuthContext: &policy.AuthContext{
Properties: make(map[string]string),
Scopes: make(map[string]bool),
},
}
// Add template handle to metadata for LLM provider/proxy scenarios
if routeMetadata.TemplateHandle != "" {
Expand Down
61 changes: 25 additions & 36 deletions gateway/system-policies/analytics/analytics.go
Original file line number Diff line number Diff line change
Expand Up @@ -31,9 +31,6 @@ const (
AIProviderNameMetadataKey = "ai:providername"
AIProviderDisplayNameMetadataKey = "ai:providerdisplayname"

// AuthContext key for user ID (used for analytics)
AuthContextKeyUserID = "x-wso2-user-id"

// Lazy resource type for LLM provider templates
lazyResourceTypeLLMProviderTemplate = "LlmProviderTemplate"
// Lazy resource type for provider-to-template mapping
Expand All @@ -59,22 +56,22 @@ var (
type AnalyticsPolicy struct{}

type McpRequestAnalyticsProperties struct {
JsonRpcMethod string `json:"jsonRpcMethod,omitempty"`
Capability string `json:"capability,omitempty"`
CapabilityName string `json:"capabilityName,omitempty"`
ClientInfo *McpClientInfo `json:"clientInfo,omitempty"`
JsonRpcMethod string `json:"jsonRpcMethod,omitempty"`
Capability string `json:"capability,omitempty"`
CapabilityName string `json:"capabilityName,omitempty"`
ClientInfo *McpClientInfo `json:"clientInfo,omitempty"`
ServerInfo *McpServerInfo `json:"serverInfo,omitempty"`
}

type McpClientInfo struct {
RequestedProtocolVersion string `json:"requestedProtocolVersion"`
Name string `json:"name"`
Version string `json:"version"`
RequestedProtocolVersion string `json:"requestedProtocolVersion"`
Name string `json:"name"`
Version string `json:"version"`
}

type McpServerInfo struct {
ProtocolVersion string `json:"protocolVersion,omitempty"`
ServerInfo *McpServerInfoDetails `json:"serverInfo,omitempty"`
ProtocolVersion string `json:"protocolVersion,omitempty"`
ServerInfo *McpServerInfoDetails `json:"serverInfo,omitempty"`
}

type McpServerInfoDetails struct {
Expand All @@ -83,8 +80,8 @@ type McpServerInfoDetails struct {
}

type McpResponseAnalyticsProperties struct {
IsError bool `json:"isError,omitempty"`
ErrorCode int `json:"errorCode,omitempty"`
IsError bool `json:"isError,omitempty"`
ErrorCode int `json:"errorCode,omitempty"`
}

// LLMTokenInfo holds extracted token-related information from LLM provider responses
Expand Down Expand Up @@ -123,7 +120,7 @@ func (a *AnalyticsPolicy) Mode() policy.ProcessingMode {
func (a *AnalyticsPolicy) OnRequest(ctx *policy.RequestContext, params map[string]interface{}) policy.RequestAction {
slog.Debug("Analytics system policy: OnRequest called")
allowPayloads := getAllowPayloadsFlag(params)
// Store tokenInfo in analytics metadata for publishing
// Store tokenInfo in analytics metadata for publishing
analyticsMetadata := make(map[string]any)

// When allow_payloads is enabled, capture the raw request body into analytics metadata.
Expand All @@ -132,7 +129,6 @@ func (a *AnalyticsPolicy) OnRequest(ctx *policy.RequestContext, params map[strin
analyticsMetadata["request_payload"] = string(ctx.Body.Content)
}


// Extract common analytics data from the request
// Based on the API kind, collect the analytics data
apiKind := ctx.SharedContext.APIKind
Expand All @@ -147,7 +143,7 @@ func (a *AnalyticsPolicy) OnRequest(ctx *policy.RequestContext, params map[strin
// Currently no data is collected
case KindMCP:
// Collect analytics data specific for MCP scenario from request
if ctx.Headers != nil && len(ctx.Headers.GetAll()) > 0 {
if ctx.Headers != nil && len(ctx.Headers.GetAll()) > 0 {
// Need to get the mcp-session-id from headers
sessionIDs := ctx.Headers.Get("mcp-session-id")
if len(sessionIDs) > 0 {
Expand Down Expand Up @@ -184,9 +180,9 @@ func (a *AnalyticsPolicy) OnRequest(ctx *policy.RequestContext, params map[strin

// Populate client info
clientInfo := McpClientInfo{
RequestedProtocolVersion: extractStringFromJsonpath(mcpPayload,ProtocolVersionJsonPath),
Name: extractStringFromJsonpath(mcpPayload,ClientNameJsonPath),
Version: extractStringFromJsonpath(mcpPayload,ClientVersionJsonPath),
RequestedProtocolVersion: extractStringFromJsonpath(mcpPayload, ProtocolVersionJsonPath),
Name: extractStringFromJsonpath(mcpPayload, ClientNameJsonPath),
Version: extractStringFromJsonpath(mcpPayload, ClientVersionJsonPath),
}
// Only set ClientInfo pointer if at least one field is non-empty so that omitempty can exclude it from JSON
if clientInfo.RequestedProtocolVersion != "" || clientInfo.Name != "" || clientInfo.Version != "" {
Expand Down Expand Up @@ -243,15 +239,8 @@ func (p *AnalyticsPolicy) OnResponse(ctx *policy.ResponseContext, params map[str
// Store tokenInfo in analytics metadata for publishing
analyticsMetadata := make(map[string]any)

// Extract user ID from AuthContext if available (set by jwt-auth policy)
if ctx.SharedContext.AuthContext != nil {
if userID, ok := ctx.SharedContext.AuthContext[AuthContextKeyUserID]; ok && userID != "" {
analyticsMetadata[AuthContextKeyUserID] = userID
slog.Debug("Analytics system policy: User ID extracted from AuthContext",
"userID", userID,
)
}
}
// Note: UserID from AuthContext is directly extracted by the policy engine's analytics filter
// No need to copy it to analyticsMetadata here

// Based on the API kind, collect the analytics data
apiKind := ctx.SharedContext.APIKind
Expand Down Expand Up @@ -315,7 +304,7 @@ func (p *AnalyticsPolicy) OnResponse(ctx *policy.ResponseContext, params map[str
}
case KindMCP:
// Collect the analytics data specific for MCP specific scenario
if ctx.ResponseHeaders != nil && len(ctx.ResponseHeaders.GetAll()) > 0 {
if ctx.ResponseHeaders != nil && len(ctx.ResponseHeaders.GetAll()) > 0 {
if analyticsMetadata["mcp_session_id"] == nil {
sessionIDs := ctx.ResponseHeaders.Get("mcp-session-id")
if len(sessionIDs) > 0 {
Expand All @@ -328,7 +317,7 @@ func (p *AnalyticsPolicy) OnResponse(ctx *policy.ResponseContext, params map[str
if ctx != nil && ctx.ResponseBody != nil && len(ctx.ResponseBody.Content) > 0 {
var mcpResponsePayload map[string]interface{}
responseContent := ctx.ResponseBody.Content

// Check if response is in SSE format by inspecting content-type or content structure
isSSE := false
if ctx.ResponseHeaders != nil {
Expand All @@ -337,12 +326,12 @@ func (p *AnalyticsPolicy) OnResponse(ctx *policy.ResponseContext, params map[str
isSSE = true
}
}

// Also check content structure if header check didn't confirm SSE
if !isSSE && (strings.HasPrefix(string(responseContent), "event:") || strings.Contains(string(responseContent), "\ndata:")) {
isSSE = true
}

// Parse SSE format if detected
if isSSE {
jsonData, err := parseSSEResponse(responseContent)
Expand All @@ -352,7 +341,7 @@ func (p *AnalyticsPolicy) OnResponse(ctx *policy.ResponseContext, params map[str
responseContent = jsonData
}
}

// Unmarshal the JSON (either from SSE data field or direct response)
if err := json.Unmarshal(responseContent, &mcpResponsePayload); err != nil {
slog.Error("Failed to unmarshal MCP response body for server info analytics", "error", err)
Expand All @@ -362,12 +351,12 @@ func (p *AnalyticsPolicy) OnResponse(ctx *policy.ResponseContext, params map[str
Name: extractStringFromJsonpath(mcpResponsePayload, ServerInfoNameJsonPath),
Version: extractStringFromJsonpath(mcpResponsePayload, ServerInfoVersionJsonPath),
}

// Populate server info
serverInfo := McpServerInfo{
ProtocolVersion: extractStringFromJsonpath(mcpResponsePayload, ServerProtocolVersionJsonPath),
}

// Only set ServerInfo pointer if at least one field is non-empty
if serverInfoDetails.Name != "" || serverInfoDetails.Version != "" {
serverInfo.ServerInfo = &serverInfoDetails
Expand Down
62 changes: 60 additions & 2 deletions sdk/gateway/policy/v1alpha/context.go
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,64 @@ type Body struct {
Present bool
}

// JWTAuthDetails holds fields specific to JWT/OAuth2 token-based auth.
type JWTAuthDetails struct {
// Subject is the "sub" claim from the token.
Subject string

// Issuer is the token issuer ("iss" claim, IdP URL).
Issuer string

// Audience is the intended audience ("aud" claim). Can be multiple values.
Audience []string

// Claims holds additional token claims that don't fit the typed fields.
Claims map[string]string
}

// APIKeyAuthDetails holds fields specific to API key authentication.
// Placeholder for future auth-type-specific fields.
type APIKeyAuthDetails struct {
// Todo: API key tier, rate limit info, etc.
}

// BasicAuthDetails holds fields specific to basic authentication.
// Placeholder for future auth-type-specific fields.
type BasicAuthDetails struct {
// Todo: Basic auth specific metadata
}

// AuthContext holds authentication data produced by auth policies (jwt, oauth2, apikey, basic-auth)
// and consumed by downstream policies (analytics, rate limiting, etc.).
type AuthContext struct {
// Authenticated indicates whether the request passed authentication.
Authenticated bool

// AuthType identifies the mechanism that authenticated the request.
// Values: "jwt", "oauth2", "apikey", "basic", or empty if unauthenticated.
AuthType string

// UserID is the user identifier extracted from the authentication source.
UserID string

// AppID is the application identifier associated with the request.
// e.g., client_id for OAuth2, application owning the API key, etc.
AppID string

// Scopes contains granted scopes as a set for O(1) lookup.
// Applicable for OAuth2/JWT and potentially API key auth.
Scopes map[string]bool

// Auth-type-specific details. Only the relevant one is non-nil.
JWT *JWTAuthDetails
APIKey *APIKeyAuthDetails
Basic *BasicAuthDetails

// Properties holds additional key-value data for inter-policy communication
// that does not fit the typed fields above.
Properties map[string]string
}

// SharedContext contains data shared across request and response phases
type SharedContext struct {
// ProjectID is the project ID which the API is associated with
Expand Down Expand Up @@ -55,8 +113,8 @@ type SharedContext struct {
OperationPath string

// AuthContext stores authentication-related information
// Policies can read/write this map to share auth data (e.g., user ID)
AuthContext map[string]string
// Populated by auth policies, consumed by downstream policies
AuthContext *AuthContext
}

// RequestContext is mutable context for request phase containing current request state
Expand Down
Loading