diff --git a/gateway/gateway-controller/cmd/controller/main.go b/gateway/gateway-controller/cmd/controller/main.go index 84f9ca684..201550411 100644 --- a/gateway/gateway-controller/cmd/controller/main.go +++ b/gateway/gateway-controller/cmd/controller/main.go @@ -438,7 +438,7 @@ func derivePolicyFromAPIConfig(cfg *models.StoredConfig, routerConfig *config.Ro apiPolicies := make(map[string]policyenginev1.PolicyInstance) if apiData.Policies != nil { for _, p := range *apiData.Policies { - apiPolicies[p.Name] = convertAPIPolicyToModel(p) + apiPolicies[p.Name] = convertAPIPolicyToModel(p, "api") } } @@ -453,7 +453,7 @@ func derivePolicyFromAPIConfig(cfg *models.StoredConfig, routerConfig *config.Ro addedNames := make(map[string]struct{}) for _, opPolicy := range *op.Policies { - finalPolicies = append(finalPolicies, convertAPIPolicyToModel(opPolicy)) + finalPolicies = append(finalPolicies, convertAPIPolicyToModel(opPolicy, "route")) addedNames[opPolicy.Name] = struct{}{} } @@ -529,13 +529,19 @@ func derivePolicyFromAPIConfig(cfg *models.StoredConfig, routerConfig *config.Ro } // convertAPIPolicyToModel converts generated api.Policy to policyenginev1.PolicyInstance -func convertAPIPolicyToModel(p api.Policy) policyenginev1.PolicyInstance { +func convertAPIPolicyToModel(p api.Policy, attachedTo string) policyenginev1.PolicyInstance { paramsMap := make(map[string]interface{}) if p.Params != nil { for k, v := range *p.Params { paramsMap[k] = v } } + + // Add attachedTo metadata to parameters + if attachedTo != "" { + paramsMap["attachedTo"] = attachedTo + } + return policyenginev1.PolicyInstance{ Name: p.Name, Version: p.Version, diff --git a/gateway/gateway-controller/pkg/api/handlers/handlers.go b/gateway/gateway-controller/pkg/api/handlers/handlers.go index 0634bd4ab..64a31e5a0 100644 --- a/gateway/gateway-controller/pkg/api/handlers/handlers.go +++ b/gateway/gateway-controller/pkg/api/handlers/handlers.go @@ -1607,7 +1607,7 @@ func (s *APIServer) buildStoredPolicyFromAPI(cfg *models.StoredConfig) *models.S apiPolicies := make(map[string]policyenginev1.PolicyInstance) // name -> policy if cfg.GetPolicies() != nil { for _, p := range *cfg.GetPolicies() { - apiPolicies[p.Name] = convertAPIPolicy(p) + apiPolicies[p.Name] = convertAPIPolicy(p, "api") } } @@ -1630,7 +1630,7 @@ func (s *APIServer) buildStoredPolicyFromAPI(cfg *models.StoredConfig) *models.S addedNames := make(map[string]struct{}) for _, opPolicy := range *ch.Policies { - finalPolicies = append(finalPolicies, convertAPIPolicy(opPolicy)) + finalPolicies = append(finalPolicies, convertAPIPolicy(opPolicy, "route")) addedNames[opPolicy.Name] = struct{}{} } @@ -1675,7 +1675,7 @@ func (s *APIServer) buildStoredPolicyFromAPI(cfg *models.StoredConfig) *models.S addedNames := make(map[string]struct{}) for _, opPolicy := range *op.Policies { - finalPolicies = append(finalPolicies, convertAPIPolicy(opPolicy)) + finalPolicies = append(finalPolicies, convertAPIPolicy(opPolicy, "route")) addedNames[opPolicy.Name] = struct{}{} } @@ -1753,13 +1753,19 @@ func (s *APIServer) buildStoredPolicyFromAPI(cfg *models.StoredConfig) *models.S } // convertAPIPolicy converts generated api.Policy to policyenginev1.PolicyInstance -func convertAPIPolicy(p api.Policy) policyenginev1.PolicyInstance { +func convertAPIPolicy(p api.Policy, attachedTo string) policyenginev1.PolicyInstance { paramsMap := make(map[string]interface{}) if p.Params != nil { for k, v := range *p.Params { paramsMap[k] = v } } + + // Add attachedTo metadata to parameters + if attachedTo != "" { + paramsMap["attachedTo"] = attachedTo + } + return policyenginev1.PolicyInstance{ Name: p.Name, Version: p.Version, diff --git a/gateway/it/features/basic-ratelimit.feature b/gateway/it/features/basic-ratelimit.feature index 8c2952f9c..c90bf8840 100644 --- a/gateway/it/features/basic-ratelimit.feature +++ b/gateway/it/features/basic-ratelimit.feature @@ -156,20 +156,27 @@ Feature: Basic Rate Limiting upstream: main: url: http://sample-backend:9080/api/v1 - policies: - - name: basic-ratelimit - version: v0.1.0 - params: - limits: - - limit: 3 - duration: "1h" operations: - method: GET path: /health - method: GET path: /route1 + policies: + - name: basic-ratelimit + version: v0.1.0 + params: + limits: + - limit: 3 + duration: "1h" - method: GET path: /route2 + policies: + - name: basic-ratelimit + version: v0.1.0 + params: + limits: + - limit: 3 + duration: "1h" """ Then the response should be successful And I wait for the endpoint "http://localhost:8080/basic-ratelimit-per-route/v1.0/health" to be ready @@ -230,3 +237,82 @@ Feature: Basic Rate Limiting When I send a GET request to "http://localhost:8080/basic-ratelimit-retry/v1.0/resource" Then the response status code should be 429 And the response header "Retry-After" should exist + + Scenario: Rate limit scope based on policy attachment level + Given I authenticate using basic auth as "admin" + When I deploy this API configuration: + """ + apiVersion: gateway.api-platform.wso2.com/v1alpha1 + kind: RestApi + metadata: + name: basic-ratelimit-scope-api + spec: + displayName: Basic RateLimit Scope API + version: v1.0 + context: /basic-ratelimit-scope/$version + upstream: + main: + url: http://sample-backend:9080/api/v1 + policies: + - name: basic-ratelimit + version: v0.1.0 + params: + limits: + - limit: 5 + duration: "1h" + operations: + - method: GET + path: /health + policies: + - name: basic-ratelimit + version: v0.1.0 + params: + limits: + - limit: 100 + duration: "1h" + - method: GET + path: /resource-a + - method: GET + path: /resource-b + policies: + - name: basic-ratelimit + version: v0.1.0 + params: + limits: + - limit: 3 + duration: "1h" + - method: GET + path: /resource-c + """ + Then the response should be successful + And I wait for the endpoint "http://localhost:8080/basic-ratelimit-scope/v1.0/health" to be ready + + # Resource B has its own route-level policy (Limit: 3) + # Send 3 requests to B -> Should succeed + When I send 3 GET requests to "http://localhost:8080/basic-ratelimit-scope/v1.0/resource-b" + Then the response status code should be 200 + + # 4th request to B -> Should fail (Limit 3 exhausted) + When I send a GET request to "http://localhost:8080/basic-ratelimit-scope/v1.0/resource-b" + Then the response status code should be 429 + + # Resource A and C fall back to API-level policy (Limit: 5, Shared) + # Send 2 requests to A -> Should succeed + When I send 2 GET requests to "http://localhost:8080/basic-ratelimit-scope/v1.0/resource-a" + Then the response status code should be 200 + + # Send 2 requests to C -> Should succeed (Total 4/5) + When I send 2 GET requests to "http://localhost:8080/basic-ratelimit-scope/v1.0/resource-c" + Then the response status code should be 200 + + # Send 1 request to A -> Should succeed (Total 5/5) + When I send a GET request to "http://localhost:8080/basic-ratelimit-scope/v1.0/resource-a" + Then the response status code should be 200 + + # Send 1 request to C -> Should fail (Total 6/5, Limit 5 exhausted) + When I send a GET request to "http://localhost:8080/basic-ratelimit-scope/v1.0/resource-c" + Then the response status code should be 429 + + # Verify B is still rate limited (independent of A/C bucket) + When I send a GET request to "http://localhost:8080/basic-ratelimit-scope/v1.0/resource-b" + Then the response status code should be 429 diff --git a/gateway/it/features/ratelimit.feature b/gateway/it/features/ratelimit.feature index 8b5074051..140c476fc 100644 --- a/gateway/it/features/ratelimit.feature +++ b/gateway/it/features/ratelimit.feature @@ -1304,4 +1304,3 @@ Feature: Rate Limiting # Double-check API-A is still rate limited (not affected by API-B usage) When I send a GET request to "http://localhost:8080/ratelimit-apiname-isolation-a/v1.0/resource" Then the response status code should be 429 - diff --git a/gateway/policies/basic-ratelimit/v0.1.0/basic_ratelimit.go b/gateway/policies/basic-ratelimit/v0.1.0/basic_ratelimit.go index 43c479f34..8a52f2e2e 100644 --- a/gateway/policies/basic-ratelimit/v0.1.0/basic_ratelimit.go +++ b/gateway/policies/basic-ratelimit/v0.1.0/basic_ratelimit.go @@ -38,7 +38,7 @@ func GetPolicy( params map[string]interface{}, ) (policy.Policy, error) { // Transform simple limits to full ratelimit config - rlParams := transformToRatelimitParams(params) + rlParams := transformToRatelimitParams(params, metadata) // Create the delegate ratelimit policy delegate, err := ratelimit.GetPolicy(metadata, rlParams) @@ -52,9 +52,14 @@ func GetPolicy( // transformToRatelimitParams converts the simple limits array to a full ratelimit // quota configuration with routename key extraction, and passes through system // parameters (algorithm, backend, redis, memory). -func transformToRatelimitParams(params map[string]interface{}) map[string]interface{} { +func transformToRatelimitParams(params map[string]interface{}, metadata policy.PolicyMetadata) map[string]interface{} { limits, _ := params["limits"].([]interface{}) + keyExtractorType := "routename" + if metadata.AttachedTo == "api" { + keyExtractorType = "apiname" + } + rlParams := map[string]interface{}{ "quotas": []interface{}{ map[string]interface{}{ @@ -62,7 +67,7 @@ func transformToRatelimitParams(params map[string]interface{}) map[string]interf "limits": limits, "keyExtraction": []interface{}{ map[string]interface{}{ - "type": "routename", + "type": keyExtractorType, }, }, }, diff --git a/gateway/policy-engine/internal/xdsclient/handler.go b/gateway/policy-engine/internal/xdsclient/handler.go index 24cef1761..5f316339d 100644 --- a/gateway/policy-engine/internal/xdsclient/handler.go +++ b/gateway/policy-engine/internal/xdsclient/handler.go @@ -233,6 +233,13 @@ func (h *ResourceHandler) buildPolicyChain(routeKey string, config *policyengine APIVersion: apiMetadata.Version, } + // Check if attachedTo is present in parameters and set it in metadata + if val, ok := policyConfig.Parameters["attachedTo"]; ok { + if attachedTo, ok := val.(string); ok { + metadata.AttachedTo = attachedTo + } + } + // Create instance using factory with metadata and params // CreateInstance returns the policy and merged params (initParams + runtime params) impl, mergedParams, err := h.registry.CreateInstance(policyConfig.Name, policyConfig.Version, metadata, policyConfig.Parameters) diff --git a/sdk/gateway/policy/v1alpha/interface.go b/sdk/gateway/policy/v1alpha/interface.go index bbdbae606..ec3106068 100644 --- a/sdk/gateway/policy/v1alpha/interface.go +++ b/sdk/gateway/policy/v1alpha/interface.go @@ -14,6 +14,9 @@ type PolicyMetadata struct { // APIVersion is the version of the API this policy belongs to APIVersion string + + // AttachedTo indicates where the policy is attached (e.g., "api", "route") + AttachedTo string } // Policy is the base interface that all policies must implement