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
16 changes: 11 additions & 5 deletions common/apikey/api_key_hash_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -54,13 +54,19 @@ func TestAPIKeyHashedValidation(t *testing.T) {
}

// Test validation with correct plain text key
valid, err := store.ValidateAPIKey("api-123", "/test", "GET", plainAPIKey)
valid, apiKeyDetails, err := store.ValidateAPIKey("api-123", "/test", "GET", plainAPIKey)
if err != nil {
t.Fatalf("Validation failed with error: %v", err)
}
if !valid {
t.Error("Validation should succeed with correct plain text API key")
}
if apiKeyDetails == nil {
t.Error("Expected API key details to be returned")
}
if apiKeyDetails != nil && apiKeyDetails.CreatedBy != "test-user" {
t.Errorf("Expected CreatedBy to be 'test-user', got: %s", apiKeyDetails.CreatedBy)
}
}

func TestAPIKeyHashedValidationFailures(t *testing.T) {
Expand Down Expand Up @@ -92,7 +98,7 @@ func TestAPIKeyHashedValidationFailures(t *testing.T) {

// Test validation with wrong plain text key
wrongKey := "apip_wrong399ef29761f92f4f6d2dbd6dcd78399b3bcb8c53417cb23726e5780ac999"
valid, err := store.ValidateAPIKey("api-456", "/test", "GET", wrongKey)
valid, _, err := store.ValidateAPIKey("api-456", "/test", "GET", wrongKey)
if err != nil {
if err != ErrNotFound {
t.Fatalf("Expected ErrNotFound, got: %v", err)
Expand All @@ -103,7 +109,7 @@ func TestAPIKeyHashedValidationFailures(t *testing.T) {
}

// Test validation with non-existent API
valid, err = store.ValidateAPIKey("non-existent-api", "/test", "GET", plainAPIKey)
valid, _, err = store.ValidateAPIKey("non-existent-api", "/test", "GET", plainAPIKey)
if err == nil {
t.Error("Expected error for non-existent API")
}
Expand Down Expand Up @@ -140,7 +146,7 @@ func TestAPIKeyHashedRevocation(t *testing.T) {
}

// Verify key works before revocation
valid, err := store.ValidateAPIKey("api-789", "/test", "GET", plainAPIKey)
valid, _, err := store.ValidateAPIKey("api-789", "/test", "GET", plainAPIKey)
if err != nil {
t.Fatalf("Validation failed before revocation: %v", err)
}
Expand All @@ -155,7 +161,7 @@ func TestAPIKeyHashedRevocation(t *testing.T) {
}

// Verify key no longer works after revocation
valid, err = store.ValidateAPIKey("api-789", "/test", "GET", plainAPIKey)
valid, _, err = store.ValidateAPIKey("api-789", "/test", "GET", plainAPIKey)
if err != nil && err != ErrNotFound {
t.Fatalf("Unexpected error during validation after revocation: %v", err)
}
Expand Down
23 changes: 12 additions & 11 deletions common/apikey/store.go
Original file line number Diff line number Diff line change
Expand Up @@ -167,10 +167,11 @@ func (aks *APIkeyStore) StoreAPIKey(apiId string, apiKey *APIKey) error {
}

// ValidateAPIKey validates the provided API key against the internal APIkey store
// Supports both local and external keys using unified hash-based lookup
func (aks *APIkeyStore) ValidateAPIKey(apiId, apiOperation, operationMethod, providedAPIKey string) (bool, error) {
aks.mu.RLock()
defer aks.mu.RUnlock()
// Supports both local keys (with format: key_id) and external keys (any format)
// Returns: (isValid bool, apiKey *APIKey, error)
func (aks *APIkeyStore) ValidateAPIKey(apiId, apiOperation, operationMethod, providedAPIKey string) (bool, *APIKey, error) {
Comment on lines +202 to +203
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

Returning a pointer to the internal store entry leaks mutable state outside the lock.

targetAPIKey on lines 271/279 is the same *APIKey pointer held in apiKeysByAPI. Callers that mutate the returned object after ValidateAPIKey returns will race with any concurrent store operation (the RWMutex no longer protects it). Consider returning a shallow copy instead.

♻️ Suggested approach
+	// Return a copy so callers cannot mutate internal store state
+	keyCopy := *targetAPIKey
 	// Check if wildcard is present
 	for _, op := range operations {
 		if strings.TrimSpace(op) == "*" {
-			return true, targetAPIKey, nil
+			return true, &keyCopy, nil
 		}
 	}

 	// Check if the requested operation is in the allowed operations list
 	requestedOperation := fmt.Sprintf("%s %s", operationMethod, apiOperation)
 	for _, op := range operations {
 		if strings.TrimSpace(op) == requestedOperation {
-			return true, targetAPIKey, nil
+			return true, &keyCopy, nil
 		}
 	}

Note: A shallow copy is sufficient here since the mutable fields (Status, scalar strings, etc.) are value types. The only pointer field is ExpiresAt *time.Time; if callers might mutate it, a deep copy of that field would also be needed.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@common/apikey/store.go` around lines 202 - 203, The ValidateAPIKey method
currently returns the internal *APIKey stored in apiKeysByAPI (variable
targetAPIKey), which leaks mutable state outside the lock; change ValidateAPIKey
(in APIkeyStore) to return a shallow copy of the APIKey instead of the original
pointer: while still holding the lock, copy the struct fields into a new APIKey
value, deep-copy ExpiresAt if necessary (create a new time.Time and take its
address) and return a pointer to that new copy so callers cannot mutate the
store's internal object.

aks.mu.Lock()
defer aks.mu.Unlock()

// Normalize the provided API key
providedAPIKey = strings.TrimSpace(providedAPIKey)
Expand All @@ -192,45 +193,45 @@ func (aks *APIkeyStore) ValidateAPIKey(apiId, apiOperation, operationMethod, pro

// Check if the API key belongs to the specified API
if targetAPIKey.APIId != apiId {
return false, nil
return false, nil, nil
}

// Check if the API key is active
if targetAPIKey.Status != Active {
return false, nil
return false, nil, nil
}

// Check if the API key has expired
if targetAPIKey.Status == Expired || (targetAPIKey.ExpiresAt != nil && time.Now().After(*targetAPIKey.ExpiresAt)) {
targetAPIKey.Status = Expired
return false, nil
return false, nil, nil
}

// Check if the API key has access to the requested operation
// Operations is a JSON string array of allowed operations in format "METHOD path"
// Example: ["GET /{country_code}/{city}", "POST /data"], ["*"] for allow all operations
var operations []string
if err := json.Unmarshal([]byte(targetAPIKey.Operations), &operations); err != nil {
return false, fmt.Errorf("invalid operations format: %w", err)
return false, nil, fmt.Errorf("invalid operations format: %w", err)
}

// Check if wildcard is present
for _, op := range operations {
if strings.TrimSpace(op) == "*" {
return true, nil
return true, targetAPIKey, nil
}
}

// Check if the requested operation is in the allowed operations list
requestedOperation := fmt.Sprintf("%s %s", operationMethod, apiOperation)
for _, op := range operations {
if strings.TrimSpace(op) == requestedOperation {
return true, nil
return true, targetAPIKey, nil
}
}

// Operation not found in allowed list
return false, nil
return false, nil, nil
}

// RevokeAPIKey revokes a specific API key by plain text API key value
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -359,7 +359,7 @@ 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{},
}
// Add template handle to metadata for LLM provider/proxy scenarios
if routeMetadata.TemplateHandle != "" {
Expand Down
18 changes: 8 additions & 10 deletions gateway/system-policies/analytics/analytics.go
Original file line number Diff line number Diff line change
Expand Up @@ -31,8 +31,8 @@ const (
AIProviderNameMetadataKey = "ai:providername"
AIProviderDisplayNameMetadataKey = "ai:providerdisplayname"

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

// Lazy resource type for LLM provider templates
lazyResourceTypeLLMProviderTemplate = "LlmProviderTemplate"
Expand Down Expand Up @@ -243,14 +243,12 @@ 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,
)
}
// Extract user ID from AuthContext if available (set by auth policies)
if ctx.SharedContext.AuthContext != nil && ctx.SharedContext.AuthContext.UserID != "" {
analyticsMetadata[AuthContextKeyUserID] = ctx.SharedContext.AuthContext.UserID
slog.Debug("Analytics system policy: User ID extracted from AuthContext",
"userID", ctx.SharedContext.AuthContext.Properties[AuthContextKeyUserID],
)
}
Comment on lines +246 to 252
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor

Debug log reads from Properties map instead of the typed UserID field.

Line 248 correctly stores AuthContext.UserID in analytics metadata, but line 250 logs AuthContext.Properties[AuthContextKeyUserID], which may be empty or differ from the typed field. This makes the debug log misleading when troubleshooting.

🐛 Proposed fix
 	if ctx.SharedContext.AuthContext != nil && ctx.SharedContext.AuthContext.UserID != "" {
 		analyticsMetadata[AuthContextKeyUserID] = ctx.SharedContext.AuthContext.UserID
 		slog.Debug("Analytics system policy: User ID extracted from AuthContext",
-			"userID", ctx.SharedContext.AuthContext.Properties[AuthContextKeyUserID],
+			"userID", ctx.SharedContext.AuthContext.UserID,
 		)
 	}
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
// Extract user ID from AuthContext if available (set by auth policies)
if ctx.SharedContext.AuthContext != nil && ctx.SharedContext.AuthContext.UserID != "" {
analyticsMetadata[AuthContextKeyUserID] = ctx.SharedContext.AuthContext.UserID
slog.Debug("Analytics system policy: User ID extracted from AuthContext",
"userID", ctx.SharedContext.AuthContext.Properties[AuthContextKeyUserID],
)
}
// Extract user ID from AuthContext if available (set by auth policies)
if ctx.SharedContext.AuthContext != nil && ctx.SharedContext.AuthContext.UserID != "" {
analyticsMetadata[AuthContextKeyUserID] = ctx.SharedContext.AuthContext.UserID
slog.Debug("Analytics system policy: User ID extracted from AuthContext",
"userID", ctx.SharedContext.AuthContext.UserID,
)
}
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@gateway/system-policies/analytics/analytics.go` around lines 246 - 252, The
debug log incorrectly reads from AuthContext.Properties instead of the typed
UserID; update the slog.Debug call in the analytics policy block that fills
analyticsMetadata to log ctx.SharedContext.AuthContext.UserID (the UserID field)
rather than ctx.SharedContext.AuthContext.Properties[AuthContextKeyUserID],
keeping the existing context key AuthContextKeyUserID and leaving
analyticsMetadata assignment as-is so the log accurately reflects the stored
value.


// Based on the API kind, collect the analytics data
Expand Down
42 changes: 39 additions & 3 deletions sdk/gateway/policy/v1alpha/context.go
Original file line number Diff line number Diff line change
Expand Up @@ -54,9 +54,45 @@ type SharedContext struct {
// with resolved parameters (e.g., "/petstore/v1.0.0/pets/123")
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
// AuthContext stores typed authentication information set by auth policies
// Policies can read/write this to share auth data with downstream policies
AuthContext *AuthContext
}

// AuthContext holds typed authentication information for a single auth layer.
// For chained authentication (e.g., API Key + JWT), use the Next field to form a linked list.
type AuthContext struct {
// Authenticated indicates whether authentication succeeded
Authenticated bool

// AuthType identifies the authentication method (e.g., "jwt", "apikey", "basic")
AuthType string

// UserID is the authenticated user's identifier
UserID string

// Issuer is the token issuer (e.g., for JWT)
Issuer string

// Audience contains the intended recipients of the token
Audience []string

// Scopes holds granted permission scopes
Scopes map[string]bool

// CredentialID is the credential-specific identifier:
// - JWT: OAuth consumer key
// - API Key: key name
// - mTLS: certificate thumbprint
// - Basic Auth: username
CredentialID string

// Properties is an escape hatch for auth-type-specific metadata
// not covered by the typed fields above
Properties map[string]string

// Next links the next AuthContext in a chain for multi-layer authentication
Next *AuthContext
}

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