From 4f719e9ea95c0197144469b495955bd0f1c157ed Mon Sep 17 00:00:00 2001 From: Thushani Jayasekera Date: Thu, 29 Jan 2026 10:11:19 +0530 Subject: [PATCH 01/14] Add initial changes for API Key creation and deletion --- .../api_key.go => common/apikey/store.go | 110 +++-- gateway/gateway-builder/Dockerfile | 3 + gateway/gateway-builder/Makefile | 3 + gateway/gateway-controller/api/openapi.yaml | 40 +- .../gateway-controller/cmd/controller/main.go | 4 +- .../pkg/api/generated/generated.go | 408 ++++++++++-------- .../pkg/api/handlers/handlers.go | 19 +- .../pkg/apikeyxds/apikey_snapshot.go | 2 + .../pkg/controlplane/client.go | 167 +++++++ .../pkg/controlplane/events.go | 33 ++ .../gateway-controller/pkg/models/api_key.go | 4 + .../pkg/storage/gateway-controller-db.sql | 18 +- .../gateway-controller/pkg/storage/sqlite.go | 112 ++++- .../gateway-controller/pkg/utils/api_key.go | 401 +++++++++++++++-- gateway/policy-engine/Dockerfile | 4 + gateway/policy-engine/Makefile | 3 + gateway/policy-engine/go.mod | 3 + gateway/policy-engine/go.sum | 1 + .../internal/xdsclient/api_key_handler.go | 25 +- .../internal/xdsclient/handler.go | 3 +- platform-api/src/internal/constants/error.go | 10 + platform-api/src/internal/dto/apikey.go | 90 ++++ .../src/internal/handler/apikey_internal.go | 211 +++++++++ .../src/internal/model/apikey_event.go | 50 +++ platform-api/src/internal/server/server.go | 3 + platform-api/src/internal/service/apikey.go | 193 +++++++++ .../src/internal/service/gateway_events.go | 234 +++++++++- sdk/gateway/policy/v1alpha/api_key_compat.go | 74 ++++ sdk/gateway/policyengine/v1/api_key_xds.go | 3 + sdk/go.mod | 3 + 30 files changed, 1924 insertions(+), 310 deletions(-) rename sdk/gateway/policy/v1alpha/api_key.go => common/apikey/store.go (77%) create mode 100644 platform-api/src/internal/dto/apikey.go create mode 100644 platform-api/src/internal/handler/apikey_internal.go create mode 100644 platform-api/src/internal/model/apikey_event.go create mode 100644 platform-api/src/internal/service/apikey.go create mode 100644 sdk/gateway/policy/v1alpha/api_key_compat.go diff --git a/sdk/gateway/policy/v1alpha/api_key.go b/common/apikey/store.go similarity index 77% rename from sdk/gateway/policy/v1alpha/api_key.go rename to common/apikey/store.go index a055c9c40..8a3365ff7 100644 --- a/sdk/gateway/policy/v1alpha/api_key.go +++ b/common/apikey/store.go @@ -1,4 +1,22 @@ -package policyv1alpha +/* + * Copyright (c) 2026, WSO2 LLC. (https://www.wso2.com). + * + * WSO2 LLC. licenses this file to you under the Apache License, + * Version 2.0 (the "License"); you may not use this file except + * in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +package apikey import ( "crypto/sha256" @@ -8,12 +26,12 @@ import ( "encoding/json" "errors" "fmt" - "golang.org/x/crypto/bcrypt" "strings" "sync" "time" "golang.org/x/crypto/argon2" + "golang.org/x/crypto/bcrypt" ) type APIKey struct { @@ -37,6 +55,8 @@ type APIKey struct { UpdatedAt time.Time `json:"updated_at" yaml:"updated_at"` // ExpiresAt Expiration timestamp (null if no expiration) ExpiresAt *time.Time `json:"expires_at" yaml:"expires_at"` + // Source tracking for external key support ("local" | "external") + Source string `json:"source" yaml:"source"` } // APIKeyStatus Status of the API key @@ -77,12 +97,16 @@ type APIkeyStore struct { mu sync.RWMutex // Protects concurrent access // API Keys storage apiKeysByAPI map[string]map[string]*APIKey // Key: "API ID" → Value: map[API key ID]*APIKey + // Fast lookup index for external keys: Key: "API ID:SHA256(plain key)" → Value: API key ID + // This avoids O(n) iteration through all keys for external key validation + externalKeyIndex map[string]string } // NewAPIkeyStore creates a new in-memory API key store func NewAPIkeyStore() *APIkeyStore { return &APIkeyStore{ - apiKeysByAPI: make(map[string]map[string]*APIKey), + apiKeysByAPI: make(map[string]map[string]*APIKey), + externalKeyIndex: make(map[string]string), } } @@ -139,29 +163,42 @@ func (aks *APIkeyStore) StoreAPIKey(apiId string, apiKey *APIKey) error { } // ValidateAPIKey validates the provided API key against the internal APIkey store +// Supports both local keys (with format: key_id) and external keys (any format) func (aks *APIkeyStore) ValidateAPIKey(apiId, apiOperation, operationMethod, providedAPIKey string) (bool, error) { aks.mu.Lock() defer aks.mu.Unlock() - parsedAPIkey, ok := parseAPIKey(providedAPIKey) - if !ok { - return false, ErrNotFound - } - var targetAPIKey *APIKey - apiKey, exists := aks.apiKeysByAPI[apiId][parsedAPIkey.ID] - if !exists { - return false, ErrNotFound - } + // Quick check: Does the key contain the separator character? + // Local keys have format: key_value_{id} (contains underscore) + // External keys are arbitrary strings (may or may not contain underscore) - // Find the API key that matches the provided plain text key (by comparing against hashed values) - if apiKey != nil { - if compareAPIKeys(parsedAPIkey.APIKey, apiKey.APIKey) { + // Try to parse as local key (format: key_id) + parsedAPIkey, ok := parseAPIKey(providedAPIKey) + if ok { + // Optimized O(1) lookup for local keys using ID + apiKey, exists := aks.apiKeysByAPI[apiId][parsedAPIkey.ID] + if exists && apiKey.Source == "local" && compareAPIKeys(parsedAPIkey.APIKey, apiKey.APIKey) { targetAPIKey = apiKey } } + + // If not found via local key lookup, check all keys (handles both external keys and edge cases) + if targetAPIKey == nil { + apiKeys, exists := aks.apiKeysByAPI[apiId] + if exists { + for _, apiKey := range apiKeys { + // For external keys, compare the full provided key directly (no parsing) + if apiKey.Source == "external" && compareAPIKeys(providedAPIKey, apiKey.APIKey) { + targetAPIKey = apiKey + break + } + } + } + } + if targetAPIKey == nil { return false, ErrNotFound } @@ -177,7 +214,7 @@ func (aks *APIkeyStore) ValidateAPIKey(apiId, apiOperation, operationMethod, pro } // Check if the API key has expired - if targetAPIKey.Status == Expired || targetAPIKey.ExpiresAt != nil && time.Now().After(*targetAPIKey.ExpiresAt) { + if targetAPIKey.Status == Expired || (targetAPIKey.ExpiresAt != nil && time.Now().After(*targetAPIKey.ExpiresAt)) { targetAPIKey.Status = Expired return false, nil } @@ -210,26 +247,41 @@ func (aks *APIkeyStore) ValidateAPIKey(apiId, apiOperation, operationMethod, pro } // RevokeAPIKey revokes a specific API key by plain text API key value +// Supports both local keys (with format: key_id) and external keys (any format) func (aks *APIkeyStore) RevokeAPIKey(apiId, providedAPIKey string) error { aks.mu.Lock() defer aks.mu.Unlock() - parsedAPIkey, ok := parseAPIKey(providedAPIKey) - if !ok { - return nil - } - var matchedKey *APIKey - apiKey, exists := aks.apiKeysByAPI[apiId][parsedAPIkey.ID] - if !exists { - return nil + // Quick check: Does the key contain the separator character? + // Local keys have format: key_value_{id} (contains underscore) + // External keys are arbitrary strings (may or may not contain underscore) + hasLocalKeyFormat := strings.Contains(providedAPIKey, APIKeySeparator) + + if hasLocalKeyFormat { + // Try to parse as local key (format: key_id) + parsedAPIkey, ok := parseAPIKey(providedAPIKey) + if ok { + apiKey, exists := aks.apiKeysByAPI[apiId][parsedAPIkey.ID] + if exists && apiKey.Source == "local" && compareAPIKeys(parsedAPIkey.APIKey, apiKey.APIKey) { + matchedKey = apiKey + } + } } - // Find the API key that matches the provided plain text key - if apiKey != nil { - if compareAPIKeys(parsedAPIkey.APIKey, apiKey.APIKey) { - matchedKey = apiKey + // If not found via local key lookup, check all keys + if matchedKey == nil { + apiKeys, exists := aks.apiKeysByAPI[apiId] + if exists { + for _, apiKey := range apiKeys { + // For external keys, compare the full provided key directly + // Also catches local keys that failed parsing (edge case) + if compareAPIKeys(providedAPIKey, apiKey.APIKey) { + matchedKey = apiKey + break + } + } } } @@ -241,7 +293,7 @@ func (aks *APIkeyStore) RevokeAPIKey(apiId, providedAPIKey string) error { // Set status to revoked matchedKey.Status = Revoked - aks.removeFromAPIMapping(apiKey) + aks.removeFromAPIMapping(matchedKey) return nil } diff --git a/gateway/gateway-builder/Dockerfile b/gateway/gateway-builder/Dockerfile index d3c3c5598..e3511b72a 100644 --- a/gateway/gateway-builder/Dockerfile +++ b/gateway/gateway-builder/Dockerfile @@ -37,6 +37,9 @@ WORKDIR /workspace # Copy SDK (needed for go.mod replace directive) COPY --from=sdk . /workspace/sdk +# Copy common (needed for go.mod replace directive) +COPY --from=common . /workspace/common + # Pre-download Go dependencies for SDK WORKDIR /workspace/sdk RUN go mod download diff --git a/gateway/gateway-builder/Makefile b/gateway/gateway-builder/Makefile index 995eb232f..c1d148aa8 100644 --- a/gateway/gateway-builder/Makefile +++ b/gateway/gateway-builder/Makefile @@ -61,6 +61,7 @@ build: ## Build Docker image using buildx --build-context policy-engine=../policy-engine \ --build-context system-policies=../system-policies \ --build-context sdk=../../sdk \ + --build-context common=../../common \ --build-arg VERSION=$(VERSION) \ --build-arg GIT_COMMIT=$(GIT_COMMIT) \ -t $(IMAGE_NAME):$(VERSION) \ @@ -75,6 +76,7 @@ build-local: ## Build Docker image locally (faster, no buildx) --build-context policy-engine=../policy-engine \ --build-context system-policies=../system-policies \ --build-context sdk=../../sdk \ + --build-context common=../../common \ --build-arg VERSION=$(VERSION) \ --build-arg GIT_COMMIT=$(GIT_COMMIT) \ -t $(IMAGE_NAME):$(VERSION) \ @@ -94,6 +96,7 @@ build-and-push-multiarch: ## Build and push multi-architecture Docker image (lin --build-context policy-engine=../policy-engine \ --build-context system-policies=../system-policies \ --build-context sdk=../../sdk \ + --build-context common=../../common \ --platform linux/amd64,linux/arm64 \ --build-arg VERSION=$(VERSION) \ --build-arg GIT_COMMIT=$(GIT_COMMIT) \ diff --git a/gateway/gateway-controller/api/openapi.yaml b/gateway/gateway-controller/api/openapi.yaml index 58bd679b3..e35a5401d 100644 --- a/gateway/gateway-controller/api/openapi.yaml +++ b/gateway/gateway-controller/api/openapi.yaml @@ -349,13 +349,13 @@ paths: /apis/{id}/api-keys: post: - summary: Generate API key for an API + summary: Create a new API key for an API description: | - Generate a new API key for the specified API. The generated key can be + Create a new API key for the specified API. The created key can be used by clients to authenticate requests to the API if API Key validation policy is applied. The key is a 32-byte random value encoded in hexadecimal - and prefixed with "apip_". - operationId: generateAPIKey + and prefixed with "apip_" for local keys, or the provided key for external keys. + operationId: createAPIKey tags: - API Management parameters: @@ -372,13 +372,13 @@ paths: content: application/yaml: schema: - $ref: "#/components/schemas/APIKeyGenerationRequest" + $ref: "#/components/schemas/APIKeyCreationRequest" application/json: schema: - $ref: "#/components/schemas/APIKeyGenerationRequest" + $ref: "#/components/schemas/APIKeyCreationRequest" responses: '201': - description: API key generated successfully + description: API key created successfully content: application/json: schema: @@ -2222,13 +2222,22 @@ components: format: date-time example: 2025-10-11T11:45:00Z - APIKeyGenerationRequest: + APIKeyCreationRequest: type: object properties: name: type: string description: Name of the API key example: my-weather-api-key + api_key: + type: string + description: | + Optional plain-text API key value for external key injection. + If provided, this key will be used instead of generating a new one. + The key will be hashed before storage. The key can be in any format + (minimum 16 characters). Use this for injecting externally generated + API keys (e.g., from Cloud APIM). + example: "cloud-apim-key-abc123xyz789" expires_in: type: object description: Expiration duration for the API key @@ -2256,6 +2265,13 @@ components: format: date-time description: Expiration timestamp. If both expires_in and expires_at are provided, expires_at takes precedence. example: "2026-12-08T10:30:00Z" + external_ref_id: + type: string + description: | + External reference ID for the API key (e.g., Cloud APIM key ID). + This field is optional and used for tracing purposes only. + The gateway generates its own internal ID for tracking. + example: "cloud-apim-key-98765" APIKeyGenerationResponse: type: object properties: @@ -2317,6 +2333,13 @@ components: nullable: true description: Expiration timestamp (null if no expiration) example: "2025-12-08T10:30:00Z" + source: + type: string + description: Source of the API key (local or external) + enum: + - local + - external + example: local required: - name - apiId @@ -2325,6 +2348,7 @@ components: - created_at - created_by - expires_at + - source APIKeyRegenerationRequest: type: object properties: diff --git a/gateway/gateway-controller/cmd/controller/main.go b/gateway/gateway-controller/cmd/controller/main.go index 18acdc16b..edf19f98c 100644 --- a/gateway/gateway-controller/cmd/controller/main.go +++ b/gateway/gateway-controller/cmd/controller/main.go @@ -291,8 +291,8 @@ func main() { policyValidator := config.NewPolicyValidator(policyDefinitions) validator.SetPolicyValidator(policyValidator) - // Initialize and start control plane client with dependencies for API creation - cpClient := controlplane.NewClient(cfg.GatewayController.ControlPlane, log, configStore, db, snapshotManager, validator, &cfg.GatewayController.Router) + // Initialize and start control plane client with dependencies for API creation and API key management + cpClient := controlplane.NewClient(cfg.GatewayController.ControlPlane, log, configStore, db, snapshotManager, validator, &cfg.GatewayController.Router, apiKeyXDSManager, &cfg.GatewayController.APIKey) if err := cpClient.Start(); err != nil { log.Error("Failed to start control plane client", slog.Any("error", err)) // Don't fail startup - gateway can run in degraded mode without control plane diff --git a/gateway/gateway-controller/pkg/api/generated/generated.go b/gateway/gateway-controller/pkg/api/generated/generated.go index 7455bf320..88c7abd35 100644 --- a/gateway/gateway-controller/pkg/api/generated/generated.go +++ b/gateway/gateway-controller/pkg/api/generated/generated.go @@ -39,6 +39,12 @@ const ( APIDetailResponseApiMetadataStatusPending APIDetailResponseApiMetadataStatus = "pending" ) +// Defines values for APIKeySource. +const ( + External APIKeySource = "external" + Local APIKeySource = "local" +) + // Defines values for APIKeyStatus. const ( Active APIKeyStatus = "active" @@ -46,14 +52,14 @@ const ( Revoked APIKeyStatus = "revoked" ) -// Defines values for APIKeyGenerationRequestExpiresInUnit. +// Defines values for APIKeyCreationRequestExpiresInUnit. const ( - APIKeyGenerationRequestExpiresInUnitDays APIKeyGenerationRequestExpiresInUnit = "days" - APIKeyGenerationRequestExpiresInUnitHours APIKeyGenerationRequestExpiresInUnit = "hours" - APIKeyGenerationRequestExpiresInUnitMinutes APIKeyGenerationRequestExpiresInUnit = "minutes" - APIKeyGenerationRequestExpiresInUnitMonths APIKeyGenerationRequestExpiresInUnit = "months" - APIKeyGenerationRequestExpiresInUnitSeconds APIKeyGenerationRequestExpiresInUnit = "seconds" - APIKeyGenerationRequestExpiresInUnitWeeks APIKeyGenerationRequestExpiresInUnit = "weeks" + APIKeyCreationRequestExpiresInUnitDays APIKeyCreationRequestExpiresInUnit = "days" + APIKeyCreationRequestExpiresInUnitHours APIKeyCreationRequestExpiresInUnit = "hours" + APIKeyCreationRequestExpiresInUnitMinutes APIKeyCreationRequestExpiresInUnit = "minutes" + APIKeyCreationRequestExpiresInUnitMonths APIKeyCreationRequestExpiresInUnit = "months" + APIKeyCreationRequestExpiresInUnitSeconds APIKeyCreationRequestExpiresInUnit = "seconds" + APIKeyCreationRequestExpiresInUnitWeeks APIKeyCreationRequestExpiresInUnit = "weeks" ) // Defines values for APIKeyRegenerationRequestExpiresInUnit. @@ -396,15 +402,28 @@ type APIKey struct { // Operations List of API operations the key will have access to Operations string `json:"operations" yaml:"operations"` + // Source Source of the API key (local or external) + Source APIKeySource `json:"source" yaml:"source"` + // Status Status of the API key Status APIKeyStatus `json:"status" yaml:"status"` } +// APIKeySource Source of the API key (local or external) +type APIKeySource string + // APIKeyStatus Status of the API key type APIKeyStatus string -// APIKeyGenerationRequest defines model for APIKeyGenerationRequest. -type APIKeyGenerationRequest struct { +// APIKeyCreationRequest defines model for APIKeyCreationRequest. +type APIKeyCreationRequest struct { + // ApiKey Optional plain-text API key value for external key injection. + // If provided, this key will be used instead of generating a new one. + // The key will be hashed before storage. The key can be in any format + // (minimum 16 characters). Use this for injecting externally generated + // API keys (e.g., from Cloud APIM). + ApiKey *string `json:"api_key,omitempty" yaml:"api_key,omitempty"` + // ExpiresAt Expiration timestamp. If both expires_in and expires_at are provided, expires_at takes precedence. ExpiresAt *time.Time `json:"expires_at,omitempty" yaml:"expires_at,omitempty"` @@ -414,15 +433,20 @@ type APIKeyGenerationRequest struct { Duration int `json:"duration" yaml:"duration"` // Unit Time unit for expiration - Unit APIKeyGenerationRequestExpiresInUnit `json:"unit" yaml:"unit"` + Unit APIKeyCreationRequestExpiresInUnit `json:"unit" yaml:"unit"` } `json:"expires_in,omitempty" yaml:"expires_in,omitempty"` + // ExternalRefId External reference ID for the API key (e.g., Cloud APIM key ID). + // This field is optional and used for tracing purposes only. + // The gateway generates its own internal ID for tracking. + ExternalRefId *string `json:"external_ref_id,omitempty" yaml:"external_ref_id,omitempty"` + // Name Name of the API key Name *string `json:"name,omitempty" yaml:"name,omitempty"` } -// APIKeyGenerationRequestExpiresInUnit Time unit for expiration -type APIKeyGenerationRequestExpiresInUnit string +// APIKeyCreationRequestExpiresInUnit Time unit for expiration +type APIKeyCreationRequestExpiresInUnit string // APIKeyGenerationResponse defines model for APIKeyGenerationResponse. type APIKeyGenerationResponse struct { @@ -1363,8 +1387,8 @@ type CreateAPIJSONRequestBody = APIConfiguration // UpdateAPIJSONRequestBody defines body for UpdateAPI for application/json ContentType. type UpdateAPIJSONRequestBody = APIConfiguration -// GenerateAPIKeyJSONRequestBody defines body for GenerateAPIKey for application/json ContentType. -type GenerateAPIKeyJSONRequestBody = APIKeyGenerationRequest +// CreateAPIKeyJSONRequestBody defines body for CreateAPIKey for application/json ContentType. +type CreateAPIKeyJSONRequestBody = APIKeyCreationRequest // RegenerateAPIKeyJSONRequestBody defines body for RegenerateAPIKey for application/json ContentType. type RegenerateAPIKeyJSONRequestBody = APIKeyRegenerationRequest @@ -1878,9 +1902,9 @@ type ServerInterface interface { // Get the list of API keys for an API // (GET /apis/{id}/api-keys) ListAPIKeys(c *gin.Context, id string) - // Generate API key for an API + // Create a new API key for an API // (POST /apis/{id}/api-keys) - GenerateAPIKey(c *gin.Context, id string) + CreateAPIKey(c *gin.Context, id string) // Revoke an API key // (DELETE /apis/{id}/api-keys/{apiKeyName}) RevokeAPIKey(c *gin.Context, id string, apiKeyName string) @@ -2138,8 +2162,8 @@ func (siw *ServerInterfaceWrapper) ListAPIKeys(c *gin.Context) { siw.Handler.ListAPIKeys(c, id) } -// GenerateAPIKey operation middleware -func (siw *ServerInterfaceWrapper) GenerateAPIKey(c *gin.Context) { +// CreateAPIKey operation middleware +func (siw *ServerInterfaceWrapper) CreateAPIKey(c *gin.Context) { var err error @@ -2159,7 +2183,7 @@ func (siw *ServerInterfaceWrapper) GenerateAPIKey(c *gin.Context) { } } - siw.Handler.GenerateAPIKey(c, id) + siw.Handler.CreateAPIKey(c, id) } // RevokeAPIKey operation middleware @@ -2895,7 +2919,7 @@ func RegisterHandlersWithOptions(router gin.IRouter, si ServerInterface, options router.GET(options.BaseURL+"/apis/:id", wrapper.GetAPIById) router.PUT(options.BaseURL+"/apis/:id", wrapper.UpdateAPI) router.GET(options.BaseURL+"/apis/:id/api-keys", wrapper.ListAPIKeys) - router.POST(options.BaseURL+"/apis/:id/api-keys", wrapper.GenerateAPIKey) + router.POST(options.BaseURL+"/apis/:id/api-keys", wrapper.CreateAPIKey) router.DELETE(options.BaseURL+"/apis/:id/api-keys/:apiKeyName", wrapper.RevokeAPIKey) router.POST(options.BaseURL+"/apis/:id/api-keys/:apiKeyName/regenerate", wrapper.RegenerateAPIKey) router.GET(options.BaseURL+"/certificates", wrapper.ListCertificates) @@ -2930,175 +2954,181 @@ func RegisterHandlersWithOptions(router gin.IRouter, si ServerInterface, options // Base64 encoded, gzipped, json marshaled Swagger object var swaggerSpec = []string{ - "H4sIAAAAAAAC/+x9eXfbOLLvV8HTm3NuktZmx850/M6cOYrsTtSJE4+X7n7TyktDZEnCmATZBGiLnefv", - "fg8W7iBFLV6v54/pWCSBAlCo5YdC1feW5bm+R4Fy1jr43mLWHFws/zk4GQ09OiWzQ8yx+MEPPB8CTkA+", - "tjzKYcHFP21gVkB8TjzaOmi9wwyQj/kcTb0AYcdBg5MRCryQA0Mv3JBxxDgOOLomfI56bUQ9xANMHEJn", - "iDmYzV+22i1YYNd3oHXQ6l0D5nMIWu2WixefgM74vHWw2++3Wy6h8d877ZaPOYdAkPD/xuPe77jz16Dz", - "737n7bfxuDMe976++l38/vVvrXaLR75omvGA0Fnrpt2yCfMdHH3GLpRH9CF0Me0EgG08cUAOh2IX9GAm", - "gC5OP3WmAQFqOxHqII86EXJAUMPaiIbuRP6D+dgC1kbzyJ8DZW0UUhsCZnmB+BVTG9keZ2LGvGuw85Og", - "56CDfZKfh53aeUgnYTzufBuPu+jrD8bxi5XFYrisPPxPhHHkTdGH8/MTlL7YU0vaarcIB1d+97cApq2D", - "1v/upUzV0xzV+xJ/KLpzCR2pj3YSYnAQ4Eg89D2HWJrLzJQMTkYdB67AQfG7CPu+Q8BG3JMsl5KJQuoA", - "Y8i7giAgtg20KcUnom1JUZHC0Gc8AOyWKUwpi99BltxEoR58u7CNXEzoMkIu4u5u2i2GqT3xFs0/uWm3", - "AvgzJAHYrYPfVX9fkyF5k/+AxUXDVxAwOYbikM7AxZQTC+k3xALwudwGORa92un2WznuuxqP7R/G4674", - "j5HrruYe44Z1HoaMey66IgEPsYPkWz3bE7QzKVXS/s2zubQ53ZpszA88O7TEu0IOTafEyo0L+6Sr/+pa", - "ntuq3GDd8bhTsb0yq7YSafo7I136WWdz+pqxSOGtrMRMuaed6IXMLsmJFxPvJaom3iUlbYN98ksVgwp5", - "zHywyJRY8nOUUgM0dAW1M8zhGkdd7JOO72A+9QK3e828XTFlvasd7PhzvCOISye44TeG5b4k1DbTKV9N", - "yToFxgdSpP8Kk7NwIv6doyF9odSJCxzbWjXXiYLj+D3Bhz5Ycjpp9GXaOvi9/su8BXDTrn/7V5jMPe9y", - "cDJSr39tG8afE4bIx5HjYRu9OD06O0degAYsopZUsFc4IJhy9rLEeBlWyEyCnnQ9xComCwBzOAXme5SB", - "waaRz+1vWJo16Srs9nf3Ozv9zs7O+U7/4HX/oN//d6vdEgwhXm3ZmEOHE7kTSutEDKxwQcmfISBiJ9JM", - "d41Kk1RlBnS0vDXwBWN4BvkRlOc+7pCFlgWMTUPHiYyii2Mesnxr+hujJDHN+yFwTJzqeRdWjcnAzEuE", - "RqwapkZGs4nfxoSnG/EO+MkG3/GiRq3uN281s8xaNvlAbfEw7VE0hokDdl5GZR6Xmg19e9szcGPSTCWu", - "2wbbfoSozEGKl5mwgjCV3HMJUckQwT4ZVbOfH04cYiFiA+VkSiDI2FSIzzGXf1xChAhDmDHPInKvCo9p", - "ZfbEPvl2aRrJe6BCK2uhI3qTHhn2if8N+QFMyaJoCPnfdnZf7+2/+fuPb/t4YtkwXfVvE4X5bZIn8py4", - "wDh2fXQ9B5pMkqQWMzSLx5CjVHHXbqf/4xr7K6ZmYpiyUWnFQgYBup57KSVZGovz983yKAtd6cyWOoaF", - "TwJgxmk4Es+U4ObJjLygoeMgMhUeNCQvvFx7KkRzwsNtHfAgBAOF1OgeCxMwy8DFcbtRJ8un6vFaHqho", - "PePZxZvkmjgOmuMrQFhucMS9HAG/vz86R73vlhdSHkTfLM+Gm953i/Dopo1Ovpydo56Q31/r5WLBK5K/", - "G4atpSe2OLkSkxrAlXep+dOXNkxOeCbv1dvkVJnZSqzkJishMbePcmycY62vlbJOywPi0VP4MwTGywpt", - "VRbtotEUTTw+R/GXhEqkI20I4QCEC3ZFbLDb2QccXwITgsgCG6gF3SJjv1l7j6fU1I7Djo2lrLFgEvd2", - "xkwpqIu4iSvshCAbSrdqdkCv+wmdhHKYQSD1JyUVQhGJR4b2NP8xsDxqC65wCdU4zdwLA/FfG0fiP9cA", - "l/IFj/I5K+h09Uo9U0ri2ungTax1eyLjphEj15icsVZcYlcKK6DOrBYSKBX+y8zpAIR7Tejsm6bg25+h", - "p2zH/BSdxi8m+k6+mLCiUDzZOXtrYp9VraDs6iZiJR55teQQ8rl2qj9CJP/ZCHdL57yIu600nHaLexw7", - "QyH1DVtIPNPYbKxaLkEyfboly1NazXSnMNu+/HyWeI9N4tUxyJVnLZFKtUJGWxJb99jX2fRix484uLXn", - "QsYznCXW/7ac5Px5TtVBSgVYs5qD9Xhc5xzKX0Lum+nXC0lWNQuvM4MNISs9I9vdAM0meudgb38jjKLd", - "Goo5khA11OtLK32xudLMtJ60vCUN+i7ipsM4pUEn4qEEQxwHZShHU+JATpvu7u7svzVaKavo6douGips", - "01wZ5JiRns8mShgiCpoQFGUJ2jENtxaXTPCFFxcXo8OXKT6c9pYzCvb3+/DjXr/fgd23k87ejr3XwX/f", - "edPZ23vzZn9/b6/f7xu3HGEshMBwIJWZX/UOOvyMXggypiRgXBKCyBRNQmo7kAcbhp//cRyh4aD9Rfz3", - "SzDDlPwl9257+I+LsyVbv+BgK65EXoAIVXuOeBQ7KP4i13GG6tB3PGyDLf3Ms8OzxmJjuatStQhu1LHk", - "QV7HwsaWPT6Y8mXTDRkzTPzdcNKVWbjT2X2D+m8O+n8/2H2zAeqbCgMIAi/Iq6saScFCtb1qR6hfuk2O", - "WrLfLyRzVNrn2QUujeTk6LgD1PIEb/3W3e+/zfLDC/ayi4aYCpXFMaHIDR1OfCfHNCwPYXTE/94dvR99", - "RsOj0/PRT6Ph4PxI/jqmx6PR4W/nw+Hg8tfZ4Hr0bjAb/Tz4+Kl/8f4H9/Qj/8/xoP9+ePbn+7PR5PXh", - "v47eDa8vBsdHF4vhX4Of380+/zKm3W53TGVrR58PDT003wNaOsmgF4NA6qJjHQgTqhexFXiMFVVCYfSF", - "TbNGTEv3W6Pz7PyulSM0WbXDOaYUHAMHqwfoBfd8YvXgCihH6mj7JbJhSihJXCYcH2DKwRaNez73DLI/", - "iYxB6g15SNzNeDZnF+8yFC9brJhcuVpisQTVWc0SgIM5uQLEvfj0S1js+dWRst+405eH55SCcmRAFPcQ", - "nxOGrHg6dUQOSBmPbZtpggqhPS83jdcxQ6l6NYycoA69Q9cfnIxiL6d8li2IEopfGanIDl0fBbE90b6d", - "08yVFX+iB8KQ2JuEEeQmJY0puFk2f8eZ9vNzGD+RG0dOaG4uy1P4fJp6F6ep2fWrBfUMEmDgOCgeQPlk", - "vXGsYHkDGlyZoptUpiSAGWEcArBzaqgxFc1cqmp5KGjQtqh8KcpoC7aaVDtMPqzy6gjjxDIdVIWui4MI", - "pe8gPPFCdcRshUEgtFl9jKL0zwbGBTfBqKU1Txh1v9r7S+d6LXdzJU+zlnFqHM58J5Xtn1QyRLFtI1es", - "6s2u6tIn2HKTs/asblt23i5ckK3InyPheVSLHumYsKrIDLDRFXaIrSwq/W7DvfZL8qEkwbTVKv3VD2Q2", - "15aL7BRlH+dcmhymlaFVa4OGgJZyz7aC5x4teIBlAGwa32AC9rLP8oP/+ezL5xOsjnkDYCpMOEBzwDYE", - "yhLlXmyDRpKzuHcJ+owgNz1/64aC0C6hfsjPxUtGNnY0ll6m5dc5BLK7KaF2pqsMipCxrXUIYqvdUsS2", - "2q0/QwiiExxgHUs7V//Oaen0s/r5T8hsZ+fPtAifPh0P5KYdepQHnmM6PLLAr4iQ0JMfv6CM7SQewlJN", - "ItezoeleOPVCDkdxi8atIForKz1jl0lYhON419+w40hDiEbynwX7R/+6NExZtFwxk9oVKE0hLZ0HTEJ7", - "Bjyec5O7g/m8OQyb9C0WxDRplQB8BQRv8FzS6GZFW+0cSDoM50zC+ckPK16i90fnrXbr5MuZ/M+F+P/D", - "o09H50fiz8H58EOr3fpycj768vms1W59OBocttqtV0YHtWQqiY2kzEfbJgrPO8kQpsKOyqIFncnp1SJ1", - "QuhMcrdsDjgETDK6z8FGk0h5mUq1dtG5+INwBs5UBtOhXHueFbpApetbmkJfz1zmFMuaYy5X3YFYW9ev", - "mGyjnUx3MgNVS6bCYIK6e1e4KCSWsGNeqNy08xe3pjh0hIbutdq3fY3L84FisuItrheV17he/nPji1yf", - "Ph2jeMqR3lwpwb+efdlFX3ygg1Hy1q3cvdoUUEmCwbYPqaSi1LCbObi+Y0RKz/WTRPOHLAYOCctNe27G", - "Ew4xOL7phSvsOA3uLmTuTDV7cRAKgf11nZtQlQNa90pUuetfMheE1KyGDFQQu9iShM666Cz0fS/gTOxL", - "auPARvomkXiftRELJ/oOVVuwxzVxbCt9i2kUd+oJFY1Ofxp2pKQjmHLZrew1CB1gXfSr/pbJ6D3Jjfra", - "YnwS5sCUd1xBrYMn4KAX0J112+hV9qrSy+6Ylq5aGcXE/uuajfZiPH41Hnf/f7rhvr7450Fu+3393m+/", - "2bnJvPHyn+Nx9+UP+pev33fbN8uR5Ko7T8lOyF16ykvqRiJ/rftPiQh7DJegEmL1dZ2Ytk+Om2yhHAHZ", - "B1u+BbVM8pW1ce1dJD2izJWkyrtI2dY3upO009ndX/tOUhPJawzNEALPjxfyzi4SZSZt2YWimLgNbxVV", - "7s8EOBbW47dbQntLkTTM2+3UrdR615DWZKElwHnS6v5KrdYj3GuRekf3hTK8UhMcdysrURXrVmHBNpQG", - "Hb/mk1ti+axJ2cwutLc/nxvGyGUY4TwznOb6PLGeH4M+T4it1ufJLFTp9fPUfLoP/R53f1saPm7/Hm8f", - "b0fTx7vzXlR+bpUMaj0GZTRkvGT5jZD3GvBBztPN+TJK7i71Y8poQOC5Pt9sFMklk02bkWFSx54Nzvpt", - "KHbfqBF5rLbJWGr8uIabd5nFueopXKVmWNdATYToipv+Tu+u39N18IaiZV27bdt3FBLRsYE0v6NQEcMs", - "rnpBYCsa6XFfDcjM4srXK1KnsNZgXz6X9zKFykTf2hQuouejkzs7OvHFfN99JjzB8JZHO5gxwjimfBWs", - "+MkfymSRr6rUN94U4TRMz3Fc5JtOL5qKlrXOSyTzPB+W/A88LPEzMP8SMb7uccgieixnIYvIDJwsIhNa", - "sojuHiLJqdTtoiOL6EEcfhg1ykoG1CK6a0hkETU4AllEW3Evi7vx/g4/bM9iy1bp+RDk3g9BFtGDOgEZ", - "ehQN6phmPYFwD5bWvVwruc9TlEW0lj+8qTB/3K7w8fBkmXJwLX9D1XA8PDGrhmZZN4+HJzVZN13L78iV", - "6OxsPevmTmd371Yk/d5juSe21gzckQJRbOX6hrvZOJjJYF1mukGrcwM42jdP3pUMp64Z63vjWS+7kNIo", - "22b5cknyV+zGxp2sl0ug7uvUxDZcbuBzCHItIMJQ8kXS2sTzHMAqcJ9wB2pmbZ7HduTry8k0BaebbP0i", - "QlE7zVU05S/TrHZtPXP1ORY/6pDNfD1qpbmimRVVjco+tEGC/DDwPQZs/dnLy9kVS3EIKaucTP0K0gHp", - "GWwxkbWbQoJpZ/HoS2eiGbl+T5HUKZHm+hGb14ZQXND8xkgq8QyNBcC8MLBgpeZO9UfGm6I+WJXIiZic", - "Stwkr0T6bzo7P573hQbRSsSQO8hzVqL73FMoe121jdsN/i4C+edzQBNsXQK1JecwCK4gQGHgSJAah3xe", - "vO1ah4emzGea1ypD5380yOla/k6husUjgjoTzl0u2deCOlOGegxwZ0ptoQrGseXnexY/3CHQadCx2wI6", - "k6Y3Ajp3Ozu72608McfUdgC9iMfQFXv5ZakahVgyPzYgKpw15rnQEZp9pex+mZbvCjmN12LV3JW1FtLt", - "OJtVeFbegFoKZDXxowsWwep6/vH4sBuDUjEHNQOlHvrGuxeUS4my7aBciaG7miuf+HJLnEqXuHAuf6xs", - "4Xh0fBRrs4ZOqbAps15jbOIbZ578Vde7eCysK5kUpWXMR7K+NxvT1dCfbbfCgKziglePu5hzOSB1adti", - "x2E1HvhQCS+I8U9DaqkZIty4JWRyCnWJ3JwMI72xPlVJ3GHhgyV2W3ppfRtAhnCyjNU0Ql5DYbL+9aSq", - "RhDjQWjxMIAt4yWCdiN3dZtmQshv4OyiGDmlEjGunXUtsQ18u0qS46Z4T1omsyJpg4GXz89PdOrAjFHd", - "JI2DTt4QZ3PI6Wb1vTEdhqFYgRdyHfImY9q8ePm/S3a/Qb6DLZh7jq34PmNdGcuytIrBbN1Xjy1Kq2E2", - "wmTd5MSaOKIqlwkswAoF7UOPqmQaxsz+cToenZBFxhha8RfYQUkzCYyp+ssukr4I0I2Nlt9xyOdCFllC", - "039F/+sfiAchrAeEG/pTFSPSbExmRlw9l8ggmBAe4CDKpg5JMGGVjOvFNADoCKsEXULUU0UMEhH4srWN", - "0qmGIScpYCri36rD4OqTxFRzVCal3Bq6E1XpznR8/8ViiV6KXO2vqvr04pQQZVVaIccp9TGyZj5S+vhx", - "5KVhEePgnmyXbEwjJLkBO/qcrLxFtkL9mnslkyPvTrZNuzV1vGtWs32W5PCPs8fX5wvM6q+tZoPcCCgp", - "pP6qyx5VaYnkcu41t0maZZEymSAyA1rMBe1tZm0y8cBFZQXw+ElycJCvYfGCETpzAHEczIALWyOAKQRA", - "LalbPAr6fCPvAjmtrzft/I+CS77efC0m65x7gi+vAxIn5onvKeBQlsUrJItWVgFDc+9abrcPHuNx3j7C", - "tPFrq4SQ+lwhTi4Xo9td9Ido+w9kgwMzWQ5BHkoEkgr9wRG98qI2up4Ta66fACv1GLJYhqbl052QcQhk", - "k130h4tpiJ0/hM8gtA9DomsXC8GR9qcrzIHFmfiv2GOFHKf6nCHOSqemRrVt5EG5JcsluvTKyarzyA9A", - "SimwE+oPs1LL4DEbso0fkgAsnnDPxekn0bq8PBKXAS/WN5xz7h/0en7g2R393cF+v9/vYZ/0rnZzWaAD", - "0kwG5E7GyocSxl91BsWD71V7OJUEaVW3CeAgFwyeQetkcshyc4XtKp82q8RUTPBZGsKUgGNwsX4SP6vK", - "rNNijtE8JueD1U1LoDcvd1EAJFQKUWOpC32SX7pGJLnewpR6HAnOUb82W+1C7e6yOlM522v8rPgN1NFF", - "vNMSlS/OLt4phFMVOZfp8RsnYdZZ+SUeR0fqkx1DNuiqGIitX8ZKS1mtchur8jLWxlexxHDu/BJWrorW", - "fQRylD3zxMMXwthxsoViQ+rIUjLKxbeBbh7msbIdOzgZbeW6k2FOhjK+DV1lIgJYT5/hZwv5lStSYFM5", - "QENzujXZmFAzoaXqFCp9VLqWlD+wr2CA7njcqVh+hqk98RYrk6a/M9Kln3U2p6+I4IhJNKqfJiECqa5I", - "ZKyhLQk5T734jBKrQjvKY1ZB7kIKnOhDenSuYl+K5srR2bl8T0yViymexdVF8zEsccBGud33OiJgTMf0", - "fA7x30hbkQ4E2uFlFc3+38HxJ2HzSndRGSVK0kQUu8TCjhONafyZtg+lNxKgF9KCVAEEL9EVwWhxeCaY", - "kXuW52RiXaah46Dh6cUhcsgUrMhyYEzjEhMFkqTMDwA78uBJn4gl2ZNVz3K0r159hAj9BJgLug5evRrT", - "DjoLJy7hDYYqXj5Nesmk/JaGqlT1AQjqCZ2Jd/8NgdexvWsq3zcVwWPitRPBRYyrcjRegGegBnT2r0+E", - "g3jjXyEEUV1FBlUFSGH4LcNyKqmRCLuWcrdVuW4qpP9B63W3333dyiRK7sW1IWbATUYzDwhcAcJptG59", - "1Qg1qOTAdExPgYcBZWiCGbGyeb11ZQPA1ly280LskHYsidtxFGQbpfeXZK1VHXaUaIyRrTWNtlSyGNHv", - "ZdvQEfw5iWSXlbGPvyqVqWdUyF2VaDw+2zooiAiWnN2UxE89BTWxZaZe09fX7lFPa68cXJraSqauU9m3", - "VteZRUzS3GcytCdBCKaukw/SnhtGLxQJ/JrmuJFMv9vvJ6EkCgKSdomKzOr9hymbIe3WXFalaaHkJJbF", - "VCClhEHtb6NGtEE3VVfrL0dH7q84P7XJfnKFIgykjKjQ7NiJIzdV0YQbWa9OlkWJyY39glLtEo5nYtPL", - "gk/HQmeCjIb/Ko1UU1ymVgoYUbguNxmrlrKo7aLzeUHWjylhsbYAu418Le9tZZ7zAFPmyBCOGGCROjEJ", - "GlZNKi02phOYCXswAXE0lpAt9CwELaFoH+laylr1pV4zOg2dRP0ljscPia1ree6EUF3MLFcTTnxQ5br+", - "0ftDDijnuf7R+0N2wpEDWDAUzYBC4m3xQ3p+F9ta4pufpCnopq5RIvkToiyPxqpTF6vTQ2AGTaCC95Tg", - "1kdh7zw7asDGyY76rmMiW6fA+EB6TXFkYuK5tno+cKHIIZVTJ8DPxC9abaQ+jVRE8YmwBlYVMCqb6X33", - "gY9seZCa+FK/mw8NVzje01QZzufS47gYBWr91hFO2keJ8riY2ph7YsOJprIOVIzh30iEMxmSRokzY2rl", - "n1/UPk5wZdOk5N7Mz52c/x6hV0AluXU0qXe9QAxXArGPZabbie/AgH+QrbBcy5rO+JEYz28dXTWzc6aD", - "rlLHQwlGNQmxMix+q36t/djMFWkYtWpMoqYJ5ql3TJdd49kMgi7xele78qt8UzmfvWlo8027oSIqF+y7", - "aecEQoRdp7leMzSXcyf1whbsjp2t6VXRfz5g2aBby7rNGMl7027t3a3KlxqzeOxSqrP0UlH29u4oE0vq", - "EIujTqJslZqSSlSotFiNYicAbEcIFoTxh2k1Kf6oMnPqDKebtvIQe9+JfaPsJwdMVSROwfWEn0gNZtQ0", - "8NxaQ0qjBox7PhtThUqUzR7CzHYPGtHO1CGzOUdaFDKkTxBhTLP8/X/kBCQvBWABuQK0199Dnz2OfvJC", - "apu8y0M5aA3K1bmXcQBEOHHyZVxTXFGYf2oSi9dnDHFp0hvSnppWArIwaF661Llk2/V4Shf4l4bSVQQ2", - "l5lEzcmt3yxoIhqNpEgBtHd3+7pMlrC4p4JFH6SMUXvEKADqPbN64EnVNVSbuShWvADh5DKV7HYSIcIZ", - "ImIXC8FCbBXxoktdK7bIFe4Xk4qRLN9v2vnvgQ9ORu+ikb321s/4bI9ixy+xNQrZKja2nUrtNdqg4hv2", - "vCeX7Mn3YAC85Saxl6AlITcFy9hY7XBp6hjPI5Q+V7A3ygIi6g6uPvTD3NMHCHqfagNAK2Smg0rUh3ni", - "m2j/MU0khrTbRGueU2ipYAuEDKp71dDKyPW9gGPKD169QqNpsRwoa8sWksnJE67SfDOELU6uwCRr1Pxu", - "z8pQQ7k7mbMK1vKIPLWtSs/CZbRGMsZ49euBe2rPQrlSKDcRo41dsp6OzWpyhOc4WvjI/sRHiW2ijaj4", - "RE9DAxNV/Ddkwk8bTZM/pE1FEbZdQtso0B3I6BPhvZm7SMwf48HdRzGE7Yi9IGs6xiQ8AcPrI+TDmJcc", - "5kimeN6Lyw0kme6iMHHKt6AayF/5WOm9LryeQVwuITLvNmUxJaXa5YsWpmgCYyqThEwiZDlE5sfiHspi", - "0Kn1og+qREdEjeIjRBn7ZEx1ZD5JIq9Uv6I36SG93u1MItEkprbnqlLfCKjl2SopyBwW2AaLuFjscWoj", - "P4ApWYA+ABq3sE/8b+OW2YlSg1NMvKVtHs9YvM3vdJffiqnzESI9U8SjGr/f3OKpaPWuIeoCGfUiRCxn", - "uiOe7Z4nImv1hs2Kw+Uy1mzu9L6rw7bP2IUluPSVdwkq4P+KeCFzsqy1RDJ/oZYQsqIFuz2msaQR4pl6", - "yPHoDAJ5as50JKtJOJskoqJqq/JQkXnX0rBdd1k0nt2EuhqK1P0CA0HpOj8wY0ysodVYnGkuehZmT0OY", - "xWKFxky+oQjrBRBLJXnqYjQrTyFn9MzIFdAmxiWF65zUe4ImZjo1T8LIbC5WYwrvWrTelgWcLOSWbWBz", - "u3cN/61sBQcef7aBn5LamK1vBVsQcJU2EhrCfSqXNjr/dIayHyMrDAKg3ImQ42E7zfqZeQmpSC15PMMg", - "/zkOMvlLryAg04jQGfpwfn5ylrkL7FEK8uoRqwL+htkR3eLOy/TTFELLTfaDjoTWi2zl5zLmpMzQm2FX", - "F77gCY1cmRkotgXK7KLCotOfxzSO4SUUnRwd62tEXTSYclnFUPTVNjcmjYb4vri6bBSA5ldhHZwdnqEX", - "Z2AFwNEhYZZ3BUGEziC4Iha8FF/HByfcQ37I1DkghesxLYxFRWP7gbcgEIdRH6pLTkih9QevXqHhHNMZ", - "MMTxJSCYTsHiiLgu2ARzcCJppHghRwHIaOn4bvwsuYZlOPATw8ms0CYhy5kxtQ5aHfG/d0fvR5/R8Oj0", - "fPTTaDg4P5K/junxaHT42/lwOLj8dTa4Hr0bzEY/Dz5+6l+8/8E9/cj/czzovx+e/fn+bDR5ffivo3fD", - "64vB8dHFYvjX4Od3s8+/jGm32x1T2drR50NDD6mN4UYdxUQdCzcP0czMiZqkewKuMnTUBgtm+Enx9INR", - "2RnKdF6Bh3kmlhU6uQ2xVJAVVWNPSYlqN+pYZo5wIsQDMptBgDBSn8TX23LKLglenBIHVDofKX6UcBnT", - "s8OzOMWOjCKYho5wkCIv/K8rQG7cF7YFT+SWQ7SnRWlOJDFky7QSXhBpYfTZUyJIdgPU9j2iKknwyFey", - "Udo9FEAKR6aZkKnrmqBTqYxpTpwmw1eDr8CpChJqYzVdTE5jiA3MdodKIv+WEqHKquXfVHLOchpQ8VBl", - "7ox5RFNV0LppAqHdnf23b8s3uJoEJJrHXxQnD24PJ9tKb6ZVDZLSPl4WchxHHBosIDSJ0OgwNjPiHVBh", - "aMibW/mtUeK6vDURqGDnYmtCVIzpfVkTajry1kQtBjI6jBGFgj2kJzyLJ+zv9+HHvX6/A7tvJ529HXuv", - "g/++86azt/fmzf7+3l6/338M8coNh9EshjnLyXHI8K1KqRWFx8OIY84S9DgimNcyQCQI8c0OXX+5a56P", - "aVa+uLwVnUB8hsv9hFpOaBM6O5A3Letv4cevaClWSsuXvBDAjDAOQUGVyYQJytZRHCoZO74IpzJKFEwR", - "bfrIFMEwCWczQmdt5HqUcC+Q/xZNTLB1Gfpp8mBzyLWu7SAm8zZRgaSX+otAxuBzudIPkotD10+YKk+z", - "ZLEMR6sV1hw8B+yoPGFVzCvTOAjuVK/GnFHJsqWV/SC/G87ButyuGWmSoorIyJwG2xVaVW3VNevpldbG", - "tGUZiqnIr5GaCGSJmUg2UdXCOI6b1BntcHB9pyEAmEvZoetlylZQ0koVMKcKesqXz5MeG6fWiJuvzq/x", - "xQc62DS1xnZNhXIChtcbJ2Bot3Lr1ShPhGHqq/NGrJLhwcwBDxvbrKA53SnihXi61kj5YGzfdKMhZWmZ", - "NJKNKfcuQSbVsi7jLJauZ4ODYCF+1DH/KuNDJh1OTYqGeLnVFdNyRoZz2WN6VsnUOyHTWYpk9iKZWRWS", - "TOLVGRIMfNa6nYM9U0+bHemZW7xTZNBAwvJb2BXs9nwTu+FN7GSHFK9jP7Ir2EY+aCTVqg2CFW5om9mw", - "7pZ2JdxgliIbhV4kBJmRCFV5/RFgDQmhKxU0LyzKvd2KXoGcu0YUzKQ9ltvR6+/97V2VrqKh5IgbtvdW", - "7kJXEfAgt/mKZsBWL0g3ab/x3r2fS9OPcbu+B16hJYuXp2sdkIa3qJt4IZX3hW9ZAysg+1a35pP2OG5V", - "1Cy/TWxmrecbxU9OYjUVK2t5GRuhjWwZwrgCspgb0hJ0MRnb7WXwzZFzt6l8c11X5/TNy+qnmNK38fLI", - "yvzF5PdqfnRueeMy6c/uD4Y216LK7sxVceW6PMS3lmA4LxIeDeq8Gdh8KDnYhPoYMOZUsCmMWZV58pBY", - "9gBbfEwl7KV9SKYCXdvpwXAaeh0fKbF29jKPjIDBcinjApNtfdFG50LtNkGLbx8l3mbOl5pm6/V+3iwx", - "l0No3RPivCLSHAPMKhpQBw08o83L0OZkqz+h5J9ZvljPFFwXZ66Bl5djyw092rIrWxhv5oIc83Y7Svt3", - "/IKh+JATbprJXgNifhjI8sMDlB8jjnyP8PES1HgFtPgpbN6G+vu2IOIVoeEHgQg/MiBY4r8xo24BB+bF", - "OiISQqlAcZZDwI9trz1JP+JCw6vV/kTrnoDjFQHjh4sTP8ustaHgVc3+BVk72lR+Wo3+6serYb+LCOWR", - "2/vDfRfR/YC+i+gZ8V26ME8N7o234Qpg7yK6T6RXEvwYcF4thraL8qo9aoB4F1EzfDd+VcN1OkOHvv+X", - "RX1LCO8KgO4iulU0dxFt3wQrt1mlq4tL8HBA3EXUGMEVg3iGb9eEbxfRE8RuF9E6FtzqsO0iWhuzXUSb", - "+qGyhYITansW62DGCONYXpd6FGBtieqVsFqpAu4XqK0i4Z48sEX02CDahht26/is7LcCnF1E20BmH8ku", - "baKQbwGSNTRau8fuFYx98Nsqg8QuIuHrhUXmvEM01rC1slDs49J/T8z6L6CvRS/gHqDXRdQYd11Ez6Dr", - "45NN1YjrCsa6a/nrwq2JV3g8PNF+Tz4fiHKDMteQ43QOE8yIhQhVrrD0iiZeyBFga55p7YUqzK49p6RC", - "ezuLCHLiwsuqhALHw5OVAV/RfQ7wLUf6HkcpkbcH96aE3C3cm/ZbDffCFQQRn1fjrk8C8r1t0HXfBLq6", - "lv9tVeBV8/n9AK9Vu/9ho7CVVKeCM31l9RQPFc3HOWyrSlLnXpYZ4pJSlm3ki33N5D8xtREPMGVOnBxO", - "5X9bHJ6hAJgsoM+yVa7HdAIzQhkKvNBQ5BoyBJdqXVaiuTHb3RKaGze/TXuuqs07zeKQELEUjq1io+fk", - "DU3x2DxfPxFMtoItlsuugsW3AjxbxYnbq7JfI4HuqtZ+RqBtdJU1HUpl2f3UguqIBXkklffNVDfDlqs4", - "6N6Q5pUIumsntIq4x4JCry2itgdIpx3cRoH+WFZslJmiKC8ei5RYYt1sFdY2tbfCXr4ffPtxbt/3wCsV", - "fTEFRbV31DD/REVHz7X8N63lfytWjLms/63KpyfvUfa3PrDliH/V9n5OzvEE5XlzqdvMdYwD/JrWASvU", - "+yonBE/dRyub9u+k/CIOIG5GfqPqmOj4xJiutIQJwlzI8CQpsixnEPpSh8jyCzK20QVXlTsxnh6cxKO9", - "xY2rRtq0PFh5Ah82yJrJ824gPWU5vd55fhNNyj5MuuuTZ2EH2XAFjufLL9qtMHBaB6055/5Br+eIF+Ye", - "4wdv+2/7rfJhw6FnXULQ+xhOIKAg698khw7FxnT8aydlKN3q12QMJTRY5bHXScsV28nE5UmWhFQ56sTb", - "ZRqHpxeHKGFMJh2cctr9tKFCAb9mDdYg4bpZo0QoN34qVzuNYAggZHjigHntddvlpS83rB5W1hUUgyhU", - "AZTlAfUOSPuqqKVw8/XmvwMAAP//VAoIyOQsAQA=", + "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", + "MDABAA==", } // GetSwagger returns the content of the embedded swagger specification file diff --git a/gateway/gateway-controller/pkg/api/handlers/handlers.go b/gateway/gateway-controller/pkg/api/handlers/handlers.go index 8a2af2301..ad49986d1 100644 --- a/gateway/gateway-controller/pkg/api/handlers/handlers.go +++ b/gateway/gateway-controller/pkg/api/handlers/handlers.go @@ -2360,29 +2360,30 @@ func (s *APIServer) GetConfigDump(c *gin.Context) { slog.Int("certificates", len(certificates))) } -// GenerateAPIKey implements ServerInterface.GenerateAPIKey +// CreateAPIKey implements ServerInterface.CreateAPIKey // (POST /apis/{id}/api-keys) -func (s *APIServer) GenerateAPIKey(c *gin.Context, id string) { +// Handles both local key generation and external key injection based on request payload +func (s *APIServer) CreateAPIKey(c *gin.Context, id string) { // Get correlation-aware logger from context log := middleware.GetLogger(c, s.logger) handle := id correlationID := middleware.GetCorrelationID(c) // Extract authenticated user from context - user, ok := s.extractAuthenticatedUser(c, "GenerateAPIKey", correlationID) + user, ok := s.extractAuthenticatedUser(c, "CreateAPIKey", correlationID) if !ok { return // Error response already sent by extractAuthenticatedUser } - log.Debug("Starting API key generation", + log.Debug("Starting API key creation by generating or injecting a new key", slog.String("handle", handle), slog.String("user", user.UserID), slog.String("correlation_id", correlationID)) // Parse and validate request body - var request api.APIKeyGenerationRequest + var request api.APIKeyCreationRequest if err := c.ShouldBindJSON(&request); err != nil { - log.Warn("Invalid request body for API key generation", + log.Warn("Invalid request body for API key creation", slog.Any("error", err), slog.String("handle", handle), slog.String("correlation_id", correlationID)) @@ -2394,7 +2395,7 @@ func (s *APIServer) GenerateAPIKey(c *gin.Context, id string) { } // Prepare parameters - params := utils.APIKeyGenerationParams{ + params := utils.APIKeyCreationParams{ Handle: handle, Request: request, User: user, @@ -2402,7 +2403,7 @@ func (s *APIServer) GenerateAPIKey(c *gin.Context, id string) { Logger: log, } - result, err := s.apiKeyService.GenerateAPIKey(params) + result, err := s.apiKeyService.CreateAPIKey(params) if err != nil { // Check error type to determine appropriate status code if strings.Contains(err.Error(), "not found") { @@ -2419,7 +2420,7 @@ func (s *APIServer) GenerateAPIKey(c *gin.Context, id string) { return } - log.Info("API key generation completed", + log.Info("API key creation completed", slog.String("handle", handle), slog.String("key name", result.Response.ApiKey.Name), slog.String("user", user.UserID), diff --git a/gateway/gateway-controller/pkg/apikeyxds/apikey_snapshot.go b/gateway/gateway-controller/pkg/apikeyxds/apikey_snapshot.go index 628f098ac..621012af8 100644 --- a/gateway/gateway-controller/pkg/apikeyxds/apikey_snapshot.go +++ b/gateway/gateway-controller/pkg/apikeyxds/apikey_snapshot.go @@ -198,6 +198,7 @@ type APIKeyData struct { CreatedBy string `json:"createdBy"` UpdatedAt time.Time `json:"updatedAt"` ExpiresAt *time.Time `json:"expiresAt"` + Source string `json:"source"` // "local" | "external" } // TranslateAPIKeys translates API key configurations to xDS resources @@ -218,6 +219,7 @@ func (t *APIKeyTranslator) TranslateAPIKeys(apiKeys []*models.APIKey) (map[strin CreatedBy: apiKey.CreatedBy, UpdatedAt: apiKey.UpdatedAt, ExpiresAt: apiKey.ExpiresAt, + Source: apiKey.Source, } apiKeyData = append(apiKeyData, data) } diff --git a/gateway/gateway-controller/pkg/controlplane/client.go b/gateway/gateway-controller/pkg/controlplane/client.go index 19cf29019..8ec9bb59d 100644 --- a/gateway/gateway-controller/pkg/controlplane/client.go +++ b/gateway/gateway-controller/pkg/controlplane/client.go @@ -103,6 +103,7 @@ type Client struct { validator config.Validator deploymentService *utils.APIDeploymentService apiUtilsService *utils.APIUtilsService + apiKeyService *utils.APIKeyService routerConfig *config.RouterConfig } @@ -115,6 +116,8 @@ func NewClient( snapshotManager *xds.SnapshotManager, validator config.Validator, routerConfig *config.RouterConfig, + apiKeyXDSManager utils.XDSManager, + apiKeyConfig *config.APIKeyConfig, ) *Client { ctx, cancel := context.WithCancel(context.Background()) @@ -127,6 +130,8 @@ func NewClient( parser: config.NewParser(), validator: validator, deploymentService: utils.NewAPIDeploymentService(store, db, snapshotManager, validator, routerConfig), + apiKeyService: utils.NewAPIKeyService(store, db, apiKeyXDSManager, apiKeyConfig), + routerConfig: routerConfig, state: &ConnectionState{ Current: Disconnected, Conn: nil, @@ -516,6 +521,10 @@ func (c *Client) handleMessage(messageType int, message []byte) { c.handleAPIDeployedEvent(event) case "api.undeployed": c.handleAPIUndeployedEvent(event) + case "apikey.created": + c.handleAPIKeyCreatedEvent(event) + case "apikey.revoked": + c.handleAPIKeyRevokedEvent(event) default: c.logger.Info("Received unknown event type (will be processed when handlers are implemented)", slog.String("type", eventType), @@ -614,6 +623,164 @@ func (c *Client) handleAPIUndeployedEvent(event map[string]interface{}) { // TODO: Implement actual API undeployment logic in Phase 6 } +// handleAPIKeyCreatedEvent handles API key created events from platform-api +func (c *Client) handleAPIKeyCreatedEvent(event map[string]interface{}) { + c.logger.Info("API Key Created Event", + slog.Any("payload", event["payload"]), + slog.Any("timestamp", event["timestamp"]), + slog.Any("correlationId", event["correlationId"]), + ) + + // Parse the event into structured format + eventBytes, err := json.Marshal(event) + if err != nil { + c.logger.Error("Failed to marshal event for parsing", + slog.Any("error", err), + ) + return + } + + var keyCreatedEvent APIKeyCreatedEvent + if err := json.Unmarshal(eventBytes, &keyCreatedEvent); err != nil { + c.logger.Error("Failed to parse API key created event", + slog.Any("error", err), + ) + return + } + + // Extract event payload + payload := keyCreatedEvent.Payload + + // Validate required fields + if payload.ApiId == "" { + c.logger.Error("API ID is empty in API key created event") + return + } + if payload.KeyName == "" { + c.logger.Error("Key name is empty in API key created event") + return + } + if payload.ApiKey == "" { + c.logger.Error("API key is empty in API key created event") + return + } + + c.logger.Info("Processing API key creation", + slog.String("api_id", payload.ApiId), + slog.String("key_name", payload.KeyName), + slog.String("correlation_id", keyCreatedEvent.CorrelationID), + ) + + // Parse expiration time if provided + var expiresAt *time.Time + if payload.ExpiresAt != nil && *payload.ExpiresAt != "" { + parsedTime, err := time.Parse(time.RFC3339, *payload.ExpiresAt) + if err != nil { + c.logger.Warn("Failed to parse expiration time, proceeding without expiry", + slog.String("expires_at", *payload.ExpiresAt), + slog.Any("error", err), + ) + } else { + expiresAt = &parsedTime + } + } + + // Create the external API key + err = c.apiKeyService.CreateExternalAPIKeyFromEvent( + payload.ApiId, + payload.KeyName, + payload.ApiKey, // Plain text API key from platform-api + payload.ExternalRefId, + payload.Operations, + expiresAt, + c.logger, + ) + + if err != nil { + c.logger.Error("Failed to create external API key", + slog.String("api_id", payload.ApiId), + slog.String("key_name", payload.KeyName), + slog.String("correlation_id", keyCreatedEvent.CorrelationID), + slog.Any("error", err), + ) + return + } + + c.logger.Info("Successfully processed API key created event", + slog.String("api_id", payload.ApiId), + slog.String("key_name", payload.KeyName), + slog.String("correlation_id", keyCreatedEvent.CorrelationID), + ) +} + +// handleAPIKeyRevokedEvent handles API key revoked events from platform-api +func (c *Client) handleAPIKeyRevokedEvent(event map[string]interface{}) { + c.logger.Info("API Key Revoked Event", + slog.Any("payload", event["payload"]), + slog.Any("timestamp", event["timestamp"]), + slog.Any("correlationId", event["correlationId"]), + ) + + // Parse the event into structured format + eventBytes, err := json.Marshal(event) + if err != nil { + c.logger.Error("Failed to marshal event for parsing", + slog.Any("error", err), + ) + return + } + + var keyRevokedEvent APIKeyRevokedEvent + if err := json.Unmarshal(eventBytes, &keyRevokedEvent); err != nil { + c.logger.Error("Failed to parse API key revoked event", + slog.Any("error", err), + ) + return + } + + // Extract event payload + payload := keyRevokedEvent.Payload + + // Validate required fields + if payload.ApiId == "" { + c.logger.Error("API ID is empty in API key revoked event") + return + } + if payload.KeyName == "" { + c.logger.Error("Key name is empty in API key revoked event") + return + } + + c.logger.Info("Processing API key revocation", + slog.String("api_id", payload.ApiId), + slog.String("key_name", payload.KeyName), + slog.String("correlation_id", keyRevokedEvent.CorrelationID), + ) + + // Revoke the external API key + err = c.apiKeyService.RevokeExternalAPIKeyFromEvent( + payload.ApiId, + payload.KeyName, + c.logger, + ) + + if err != nil { + c.logger.Error("Failed to revoke external API key", + slog.String("api_id", payload.ApiId), + slog.String("key_name", payload.KeyName), + slog.String("correlation_id", keyRevokedEvent.CorrelationID), + slog.Any("error", err), + ) + return + } + + c.logger.Info("Successfully processed API key revoked event", + slog.String("api_id", payload.ApiId), + slog.String("key_name", payload.KeyName), + slog.String("correlation_id", keyRevokedEvent.CorrelationID), + ) +} + // calculateNextRetryDelay calculates the next retry delay with exponential backoff and jitter func (c *Client) calculateNextRetryDelay() { // Exponential backoff: initial * 2^retries diff --git a/gateway/gateway-controller/pkg/controlplane/events.go b/gateway/gateway-controller/pkg/controlplane/events.go index 14f87323b..4e242ef21 100644 --- a/gateway/gateway-controller/pkg/controlplane/events.go +++ b/gateway/gateway-controller/pkg/controlplane/events.go @@ -42,3 +42,36 @@ type APIDeployedEvent struct { Timestamp string `json:"timestamp"` CorrelationID string `json:"correlationId"` } + +// APIKeyCreatedEventPayload represents the payload of an API key created event +type APIKeyCreatedEventPayload struct { + ApiId string `json:"apiId"` + KeyName string `json:"keyName"` + ApiKey string `json:"apiKey"` // Plain text API key (will be hashed by gateway) + ExternalRefId *string `json:"externalRefId,omitempty"` + Operations string `json:"operations"` + ExpiresAt *string `json:"expiresAt,omitempty"` // ISO 8601 format + // TODO: Support expires in field +} + +// APIKeyCreatedEvent represents the complete API key created event +type APIKeyCreatedEvent struct { + Type string `json:"type"` + Payload APIKeyCreatedEventPayload `json:"payload"` + Timestamp string `json:"timestamp"` + CorrelationID string `json:"correlationId"` +} + +// APIKeyRevokedEventPayload represents the payload of an API key revoked event +type APIKeyRevokedEventPayload struct { + ApiId string `json:"apiId"` + KeyName string `json:"keyName"` +} + +// APIKeyRevokedEvent represents the complete API key revoked event +type APIKeyRevokedEvent struct { + Type string `json:"type"` + Payload APIKeyRevokedEventPayload `json:"payload"` + Timestamp string `json:"timestamp"` + CorrelationID string `json:"correlationId"` +} diff --git a/gateway/gateway-controller/pkg/models/api_key.go b/gateway/gateway-controller/pkg/models/api_key.go index 923c91984..efb2c259a 100644 --- a/gateway/gateway-controller/pkg/models/api_key.go +++ b/gateway/gateway-controller/pkg/models/api_key.go @@ -47,6 +47,10 @@ type APIKey struct { ExpiresAt *time.Time `json:"expires_at" db:"expires_at"` Unit *string `json:"-" db:"expires_in_unit"` Duration *int `json:"-" db:"expires_in_duration"` + + // Source tracking for external key support + Source string `json:"source" db:"source"` // "local" | "external" + ExternalRefId *string `json:"external_ref_id" db:"external_ref_id"` // Cloud APIM key ID or other external reference } // IsValid checks if the API key is valid (active and not expired) diff --git a/gateway/gateway-controller/pkg/storage/gateway-controller-db.sql b/gateway/gateway-controller/pkg/storage/gateway-controller-db.sql index 920aeee76..0cc6ef26d 100644 --- a/gateway/gateway-controller/pkg/storage/gateway-controller-db.sql +++ b/gateway/gateway-controller/pkg/storage/gateway-controller-db.sql @@ -110,10 +110,10 @@ CREATE TABLE IF NOT EXISTS api_keys ( -- Human-readable name for the API key name TEXT NOT NULL, - + -- The generated API key (hashed) api_key TEXT NOT NULL UNIQUE, - + -- Masked version of the API key for display purposes masked_api_key TEXT NOT NULL, @@ -125,7 +125,7 @@ CREATE TABLE IF NOT EXISTS api_keys ( -- Key status status TEXT NOT NULL CHECK(status IN ('active', 'revoked', 'expired')) DEFAULT 'active', - + -- Timestamps created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, @@ -138,7 +138,11 @@ CREATE TABLE IF NOT EXISTS api_keys ( -- Expiration policy fields expires_in_unit TEXT NULL, expires_in_duration INTEGER NULL, - + + -- External API key support (added in schema version 6) + source TEXT NOT NULL DEFAULT 'local', -- 'local' or 'external' + external_ref_id TEXT NULL, -- Cloud APIM key ID or other external reference + -- Foreign key relationship to deployments FOREIGN KEY (apiId) REFERENCES deployments(id) ON DELETE CASCADE, @@ -152,6 +156,8 @@ CREATE INDEX IF NOT EXISTS idx_api_key_api ON api_keys(apiId); CREATE INDEX IF NOT EXISTS idx_api_key_status ON api_keys(status); CREATE INDEX IF NOT EXISTS idx_api_key_expiry ON api_keys(expires_at); CREATE INDEX IF NOT EXISTS idx_created_by ON api_keys(created_by); +CREATE INDEX IF NOT EXISTS idx_api_key_source ON api_keys(source); +CREATE INDEX IF NOT EXISTS idx_api_key_external_ref ON api_keys(external_ref_id); --- Set schema version to 5 -PRAGMA user_version = 5; +-- Set schema version to 6 +PRAGMA user_version = 6; diff --git a/gateway/gateway-controller/pkg/storage/sqlite.go b/gateway/gateway-controller/pkg/storage/sqlite.go index b8ba82b5d..58f9b882e 100644 --- a/gateway/gateway-controller/pkg/storage/sqlite.go +++ b/gateway/gateway-controller/pkg/storage/sqlite.go @@ -194,6 +194,7 @@ func (s *SQLiteStorage) initSchema() error { );`); err != nil { return fmt.Errorf("failed to migrate schema to version 5 (api_keys): %w", err) } + if _, err := s.db.Exec(`CREATE INDEX IF NOT EXISTS idx_api_key ON api_keys(api_key);`); err != nil { return fmt.Errorf("failed to create api_keys key index: %w", err) } @@ -216,7 +217,52 @@ func (s *SQLiteStorage) initSchema() error { version = 5 } - s.logger.Debug("Database schema up to date", slog.Int("version", version)) + if version == 5 { + // Check if masked_api_key column exists, if not add it (for existing tables) + var columnExists int + err := s.db.QueryRow(` + SELECT COUNT(*) FROM pragma_table_info('api_keys') + WHERE name = 'masked_api_key' + `).Scan(&columnExists) + if err == nil && columnExists == 0 { + // Column doesn't exist, add it (as nullable first, then update) + if _, err := s.db.Exec(`ALTER TABLE api_keys ADD COLUMN masked_api_key TEXT`); err != nil { + return fmt.Errorf("failed to add masked_api_key column: %w", err) + } + // Update existing rows to have a masked version of their api_key + if _, err := s.db.Exec(` + UPDATE api_keys + SET masked_api_key = CASE + WHEN length(api_key) > 12 THEN substr(api_key, 1, 8) || '...' || substr(api_key, -4) + ELSE api_key + END + WHERE masked_api_key IS NULL + `); err != nil { + s.logger.Warn("Failed to update existing masked_api_key values", slog.Any("error", err)) + } + } + + // Add external API key support columns + if _, err := s.db.Exec(`ALTER TABLE api_keys ADD COLUMN source TEXT NOT NULL DEFAULT 'local';`); err != nil { + return fmt.Errorf("failed to add source column to api_keys: %w", err) + } + if _, err := s.db.Exec(`ALTER TABLE api_keys ADD COLUMN external_ref_id TEXT NULL;`); err != nil { + return fmt.Errorf("failed to add external_ref_id column to api_keys: %w", err) + } + if _, err := s.db.Exec(`CREATE INDEX IF NOT EXISTS idx_api_key_source ON api_keys(source);`); err != nil { + return fmt.Errorf("failed to create api_keys source index: %w", err) + } + if _, err := s.db.Exec(`CREATE INDEX IF NOT EXISTS idx_api_key_external_ref ON api_keys(external_ref_id);`); err != nil { + return fmt.Errorf("failed to create api_keys external_ref_id index: %w", err) + } + if _, err := s.db.Exec("PRAGMA user_version = 6"); err != nil { + return fmt.Errorf("failed to set schema version to 6: %w", err) + } + s.logger.Info("Schema migrated to version 6 (external API key support)") + version = 6 + } + + s.logger.Info("Database schema up to date", slog.Int("version", version)) } return nil @@ -1134,8 +1180,9 @@ func (s *SQLiteStorage) SaveAPIKey(apiKey *models.APIKey) error { insertQuery := ` INSERT INTO api_keys ( id, name, api_key, masked_api_key, apiId, operations, status, - created_at, created_by, updated_at, expires_at, expires_in_unit, expires_in_duration - ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) + created_at, created_by, updated_at, expires_at, expires_in_unit, expires_in_duration, + source, external_ref_id + ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) ` _, err := tx.Exec(insertQuery, @@ -1152,6 +1199,8 @@ func (s *SQLiteStorage) SaveAPIKey(apiKey *models.APIKey) error { apiKey.ExpiresAt, apiKey.Unit, apiKey.Duration, + apiKey.Source, + apiKey.ExternalRefId, ) if err != nil { @@ -1171,8 +1220,9 @@ func (s *SQLiteStorage) SaveAPIKey(apiKey *models.APIKey) error { } else { // Existing record found, update it with new API key data updateQuery := ` - UPDATE api_keys - SET api_key = ?, masked_api_key = ?, operations = ?, status = ?, created_by = ?, updated_at = ?, expires_at = ?, expires_in_unit = ?, expires_in_duration = ? + UPDATE api_keys + SET api_key = ?, masked_api_key = ?, operations = ?, status = ?, created_by = ?, updated_at = ?, expires_at = ?, expires_in_unit = ?, expires_in_duration = ?, + source = ?, external_ref_id = ? WHERE apiId = ? AND name = ? ` @@ -1186,6 +1236,8 @@ func (s *SQLiteStorage) SaveAPIKey(apiKey *models.APIKey) error { apiKey.ExpiresAt, apiKey.Unit, apiKey.Duration, + apiKey.Source, + apiKey.ExternalRefId, apiKey.APIId, apiKey.Name, ) @@ -1218,13 +1270,14 @@ func (s *SQLiteStorage) SaveAPIKey(apiKey *models.APIKey) error { func (s *SQLiteStorage) GetAPIKeyByID(id string) (*models.APIKey, error) { query := ` SELECT id, name, api_key, masked_api_key, apiId, operations, status, - created_at, created_by, updated_at, expires_at + created_at, created_by, updated_at, expires_at, source, external_ref_id FROM api_keys WHERE id = ? ` var apiKey models.APIKey var expiresAt sql.NullTime + var externalRefId sql.NullString err := s.db.QueryRow(query, id).Scan( &apiKey.ID, @@ -1238,6 +1291,8 @@ func (s *SQLiteStorage) GetAPIKeyByID(id string) (*models.APIKey, error) { &apiKey.CreatedBy, &apiKey.UpdatedAt, &expiresAt, + &apiKey.Source, + &externalRefId, ) if err != nil { @@ -1247,10 +1302,13 @@ func (s *SQLiteStorage) GetAPIKeyByID(id string) (*models.APIKey, error) { return nil, fmt.Errorf("failed to query API key: %w", err) } - // Handle nullable expires_at field + // Handle nullable fields if expiresAt.Valid { apiKey.ExpiresAt = &expiresAt.Time } + if externalRefId.Valid { + apiKey.ExternalRefId = &externalRefId.String + } return &apiKey, nil } @@ -1259,13 +1317,14 @@ func (s *SQLiteStorage) GetAPIKeyByID(id string) (*models.APIKey, error) { func (s *SQLiteStorage) GetAPIKeyByKey(key string) (*models.APIKey, error) { query := ` SELECT id, name, api_key, masked_api_key, apiId, operations, status, - created_at, created_by, updated_at, expires_at + created_at, created_by, updated_at, expires_at, source, external_ref_id FROM api_keys WHERE api_key = ? ` var apiKey models.APIKey var expiresAt sql.NullTime + var externalRefId sql.NullString err := s.db.QueryRow(query, key).Scan( &apiKey.ID, @@ -1279,6 +1338,8 @@ func (s *SQLiteStorage) GetAPIKeyByKey(key string) (*models.APIKey, error) { &apiKey.CreatedBy, &apiKey.UpdatedAt, &expiresAt, + &apiKey.Source, + &externalRefId, ) if err != nil { @@ -1288,10 +1349,13 @@ func (s *SQLiteStorage) GetAPIKeyByKey(key string) (*models.APIKey, error) { return nil, fmt.Errorf("failed to query API key: %w", err) } - // Handle nullable expires_at field + // Handle nullable fields if expiresAt.Valid { apiKey.ExpiresAt = &expiresAt.Time } + if externalRefId.Valid { + apiKey.ExternalRefId = &externalRefId.String + } return &apiKey, nil } @@ -1300,7 +1364,7 @@ func (s *SQLiteStorage) GetAPIKeyByKey(key string) (*models.APIKey, error) { func (s *SQLiteStorage) GetAPIKeysByAPI(apiId string) ([]*models.APIKey, error) { query := ` SELECT id, name, api_key, masked_api_key, apiId, operations, status, - created_at, created_by, updated_at, expires_at + created_at, created_by, updated_at, expires_at, source, external_ref_id FROM api_keys WHERE apiId = ? ORDER BY created_at DESC @@ -1317,6 +1381,7 @@ func (s *SQLiteStorage) GetAPIKeysByAPI(apiId string) ([]*models.APIKey, error) for rows.Next() { var apiKey models.APIKey var expiresAt sql.NullTime + var externalRefId sql.NullString err := rows.Scan( &apiKey.ID, @@ -1330,16 +1395,21 @@ func (s *SQLiteStorage) GetAPIKeysByAPI(apiId string) ([]*models.APIKey, error) &apiKey.CreatedBy, &apiKey.UpdatedAt, &expiresAt, + &apiKey.Source, + &externalRefId, ) if err != nil { return nil, fmt.Errorf("failed to scan API key row: %w", err) } - // Handle nullable expires_at field + // Handle nullable fields if expiresAt.Valid { apiKey.ExpiresAt = &expiresAt.Time } + if externalRefId.Valid { + apiKey.ExternalRefId = &externalRefId.String + } apiKeys = append(apiKeys, &apiKey) } @@ -1355,7 +1425,7 @@ func (s *SQLiteStorage) GetAPIKeysByAPI(apiId string) ([]*models.APIKey, error) func (s *SQLiteStorage) GetAPIKeysByAPIAndName(apiId, name string) (*models.APIKey, error) { query := ` SELECT id, name, api_key, masked_api_key, apiId, operations, status, - created_at, created_by, updated_at, expires_at + created_at, created_by, updated_at, expires_at, source, external_ref_id FROM api_keys WHERE apiId = ? AND name = ? LIMIT 1 @@ -1363,6 +1433,7 @@ func (s *SQLiteStorage) GetAPIKeysByAPIAndName(apiId, name string) (*models.APIK var apiKey models.APIKey var expiresAt sql.NullTime + var externalRefId sql.NullString err := s.db.QueryRow(query, apiId, name).Scan( &apiKey.ID, @@ -1376,6 +1447,8 @@ func (s *SQLiteStorage) GetAPIKeysByAPIAndName(apiId, name string) (*models.APIK &apiKey.CreatedBy, &apiKey.UpdatedAt, &expiresAt, + &apiKey.Source, + &externalRefId, ) if err != nil { @@ -1385,10 +1458,13 @@ func (s *SQLiteStorage) GetAPIKeysByAPIAndName(apiId, name string) (*models.APIK return nil, fmt.Errorf("failed to query API key by name: %w", err) } - // Handle nullable expires_at field + // Handle nullable fields if expiresAt.Valid { apiKey.ExpiresAt = &expiresAt.Time } + if externalRefId.Valid { + apiKey.ExternalRefId = &externalRefId.String + } return &apiKey, nil } @@ -1617,7 +1693,7 @@ func isAPIKeyUniqueConstraintError(err error) bool { func (s *SQLiteStorage) GetAllAPIKeys() ([]*models.APIKey, error) { query := ` SELECT id, name, api_key, masked_api_key, apiId, operations, status, - created_at, created_by, updated_at, expires_at + created_at, created_by, updated_at, expires_at, source, external_ref_id FROM api_keys WHERE status = 'active' ORDER BY created_at DESC @@ -1634,6 +1710,7 @@ func (s *SQLiteStorage) GetAllAPIKeys() ([]*models.APIKey, error) { for rows.Next() { var apiKey models.APIKey var expiresAt sql.NullTime + var externalRefId sql.NullString err := rows.Scan( &apiKey.ID, @@ -1647,16 +1724,21 @@ func (s *SQLiteStorage) GetAllAPIKeys() ([]*models.APIKey, error) { &apiKey.CreatedBy, &apiKey.UpdatedAt, &expiresAt, + &apiKey.Source, + &externalRefId, ) if err != nil { return nil, fmt.Errorf("failed to scan API key row: %w", err) } - // Handle nullable expires_at field + // Handle nullable fields if expiresAt.Valid { apiKey.ExpiresAt = &expiresAt.Time } + if externalRefId.Valid { + apiKey.ExternalRefId = &externalRefId.String + } apiKeys = append(apiKeys, &apiKey) } diff --git a/gateway/gateway-controller/pkg/utils/api_key.go b/gateway/gateway-controller/pkg/utils/api_key.go index d4478889c..7395a69aa 100644 --- a/gateway/gateway-controller/pkg/utils/api_key.go +++ b/gateway/gateway-controller/pkg/utils/api_key.go @@ -43,17 +43,19 @@ import ( "golang.org/x/crypto/bcrypt" ) -// APIKeyGenerationParams contains parameters for API key generation operations -type APIKeyGenerationParams struct { +// APIKeyCreationParams contains parameters for API key creation operations. +// Handles both local key generation and external key injection. +type APIKeyCreationParams struct { Handle string // API handle/ID - Request api.APIKeyGenerationRequest // Request body with API key generation details + Request api.APIKeyCreationRequest // Request body with API key creation details User *commonmodels.AuthContext // User who initiated the request CorrelationID string // Correlation ID for tracking Logger *slog.Logger // Logger instance } -// APIKeyGenerationResult contains the result of API key generation -type APIKeyGenerationResult struct { +// APIKeyCreationResult contains the result of API key creation. +// Used for both locally generated keys and externally injected keys. +type APIKeyCreationResult struct { Response api.APIKeyGenerationResponse // Response following the generated schema IsRetry bool // Whether this was a retry due to collision } @@ -148,16 +150,26 @@ const ( sha256SaltLen = 32 // Length of salt in bytes for SHA-256 ) -// GenerateAPIKey handles the complete API key generation process -func (s *APIKeyService) GenerateAPIKey(params APIKeyGenerationParams) (*APIKeyGenerationResult, error) { +// CreateAPIKey handles the complete API key creation process. +// Supports both local key generation by generating a new random key and external key injection +// (accepts key from external systems like Cloud APIM). +func (s *APIKeyService) CreateAPIKey(params APIKeyCreationParams) (*APIKeyCreationResult, error) { logger := params.Logger user := params.User + // Determine operation type for context-aware messaging + isExternalKeyInjection := params.Request.ApiKey != nil && strings.TrimSpace(*params.Request.ApiKey) != "" + operationType := "generate" + if isExternalKeyInjection { + operationType = "register" + } + // Validate that API exists config, err := s.store.GetByHandle(params.Handle) if err != nil { logger.Warn("API configuration not found for API Key generation", slog.String("handle", params.Handle), + slog.String("operation", operationType), slog.String("correlation_id", params.CorrelationID)) return nil, fmt.Errorf("API configuration handle '%s' not found", params.Handle) } @@ -168,22 +180,24 @@ func (s *APIKeyService) GenerateAPIKey(params APIKeyGenerationParams) (*APIKeyGe slog.String("user_id", user.UserID), slog.String("api_id", config.ID), slog.String("handle", params.Handle), + slog.String("operation", operationType + "_key"), slog.Any("error", err), slog.String("correlation_id", params.CorrelationID)) return nil, err } - // Generate the API key from request - apiKey, err := s.generateAPIKeyFromRequest(params.Handle, ¶ms.Request, user.UserID, config) + // Create the API key from request (generate new or register external) + apiKey, err := s.createAPIKeyFromRequest(params.Handle, ¶ms.Request, user.UserID, config) if err != nil { - logger.Error("Failed to generate API key", + logger.Error(fmt.Sprintf("Failed to %s API key", operationType), slog.Any("error", err), slog.String("handle", params.Handle), + slog.String("operation", operationType + "_key"), slog.String("correlation_id", params.CorrelationID)) - return nil, fmt.Errorf("failed to generate API key: %w", err) + return nil, fmt.Errorf("failed to %s API key: %w", operationType, err) } - result := &APIKeyGenerationResult{ + result := &APIKeyCreationResult{ IsRetry: false, } @@ -191,18 +205,29 @@ func (s *APIKeyService) GenerateAPIKey(params APIKeyGenerationParams) (*APIKeyGe if s.db != nil { if err := s.db.SaveAPIKey(apiKey); err != nil { if errors.Is(err, storage.ErrConflict) { - // Handle collision by retrying once with a new key - logger.Warn("API key collision detected, retrying", + // Handle collision - only retry for locally generated keys + if isExternalKeyInjection { + // For external keys, collision means the key already exists + logger.Error("External API key already exists in the system", + slog.String("handle", params.Handle), + slog.String("operation", operationType + "_key"), + slog.String("correlation_id", params.CorrelationID)) + return nil, fmt.Errorf("the provided API key already exists in the system") + } + + // For local keys, retry with a new generated key + logger.Warn("API key collision detected, generating new key", slog.String("handle", params.Handle), + slog.String("operation", operationType + "_key"), slog.String("correlation_id", params.CorrelationID)) // Generate a new key - apiKey, err = s.generateAPIKeyFromRequest(params.Handle, ¶ms.Request, user.UserID, config) + apiKey, err = s.createAPIKeyFromRequest(params.Handle, ¶ms.Request, user.UserID, config) if err != nil { - logger.Error("Failed to regenerate API key after collision", + logger.Error("Failed to generate API key after collision", slog.Any("error", err), slog.String("correlation_id", params.CorrelationID)) - return nil, fmt.Errorf("failed to regenerate API key after collision: %w", err) + return nil, fmt.Errorf("failed to generate API key after collision: %w", err) } // Try saving again @@ -218,6 +243,7 @@ func (s *APIKeyService) GenerateAPIKey(params APIKeyGenerationParams) (*APIKeyGe logger.Error("Failed to save API key to database", slog.Any("error", err), slog.String("handle", params.Handle), + slog.String("operation", operationType + "_key"), slog.String("correlation_id", params.CorrelationID)) return nil, fmt.Errorf("failed to save API key to database: %w", err) } @@ -227,11 +253,12 @@ func (s *APIKeyService) GenerateAPIKey(params APIKeyGenerationParams) (*APIKeyGe plainAPIKey := apiKey.PlainAPIKey // Store plain API key for response apiKey.PlainAPIKey = "" // Clear plain API key from the struct for security - // Store the generated API key in the ConfigStore + // Store the API key in the ConfigStore (for both generated and registered keys) if err := s.store.StoreAPIKey(apiKey); err != nil { logger.Error("Failed to store API key in ConfigStore", slog.Any("error", err), slog.String("handle", params.Handle), + slog.String("operation", operationType + "_key"), slog.String("correlation_id", params.CorrelationID)) // Rollback database save to maintain consistency @@ -262,6 +289,7 @@ func (s *APIKeyService) GenerateAPIKey(params APIKeyGenerationParams) (*APIKeyGe slog.String("name", apiKey.Name), slog.String("api_name", apiName), slog.String("api_version", apiVersion), + slog.String("operation", operationType + "_key"), slog.String("user", user.UserID), slog.String("correlation_id", params.CorrelationID)) @@ -270,17 +298,19 @@ func (s *APIKeyService) GenerateAPIKey(params APIKeyGenerationParams) (*APIKeyGe if err := s.xdsManager.StoreAPIKey(apiId, apiName, apiVersion, apiKey, params.CorrelationID); err != nil { logger.Error("Failed to send API key to policy engine", slog.Any("error", err), + slog.String("operation", operationType + "_key"), slog.String("correlation_id", params.CorrelationID)) return nil, fmt.Errorf("failed to send API key to policy engine: %w", err) } } // Build response following the generated schema - result.Response = s.buildAPIKeyResponse(apiKey, params.Handle, plainAPIKey) + result.Response = s.buildAPIKeyResponse(apiKey, params.Handle, plainAPIKey, isExternalKeyInjection) - logger.Info("API key generated successfully", + logger.Info("API key successfully created", slog.String("handle", params.Handle), slog.String("name", apiKey.Name), + slog.String("operation", operationType + "_key"), slog.String("user", user.UserID), slog.Bool("is_retry", result.IsRetry), slog.String("correlation_id", params.CorrelationID)) @@ -604,7 +634,7 @@ func (s *APIKeyService) RegenerateAPIKey(params APIKeyRegenerationParams) (*APIK } // Build and return the response - result.Response = s.buildAPIKeyResponse(regeneratedKey, params.Handle, plainAPIKey) + result.Response = s.buildAPIKeyResponse(regeneratedKey, params.Handle, plainAPIKey, false) logger.Info("API key regeneration completed successfully", slog.String("handle", params.Handle), @@ -691,6 +721,7 @@ func (s *APIKeyService) ListAPIKeys(params ListAPIKeyParams) (*ListAPIKeyResult, CreatedAt: key.CreatedAt, CreatedBy: key.CreatedBy, ExpiresAt: key.ExpiresAt, + Source: api.APIKeySource(key.Source), } responseAPIKeys = append(responseAPIKeys, responseAPIKey) } @@ -716,23 +747,54 @@ func (s *APIKeyService) ListAPIKeys(params ListAPIKeyParams) (*ListAPIKeyResult, return result, nil } -// generateAPIKeyFromRequest creates a new API key based on the APIKeyGenerationRequest -func (s *APIKeyService) generateAPIKeyFromRequest(handle string, request *api.APIKeyGenerationRequest, user string, +// createAPIKeyFromRequest creates a new API key from a request. +// Handles both local key generation (creates new random key) and external key injection +// (uses provided key from external platforms). +func (s *APIKeyService) createAPIKeyFromRequest(handle string, request *api.APIKeyCreationRequest, user string, config *models.StoredConfig) (*models.APIKey, error) { // Generate short unique ID (22 characters, URL-safe) + // This is an internal ID for tracking and is always generated regardless of source id, err := s.generateShortUniqueID() if err != nil { return nil, fmt.Errorf("failed to generate unique ID: %w", err) } - // Generate 32 random bytes for the API key - plainAPIKeyValue, err := s.generateAPIKeyValue() - if err != nil { - return nil, err + // Determine if this is an external key injection or local key generation + var plainAPIKeyValue string // The key value to be hashed + var source string + var isExternalKey bool + + if request.ApiKey != nil { + // External key injection: use provided key AS-IS + providedKey := strings.TrimSpace(*request.ApiKey) + + if providedKey == "" { + return nil, fmt.Errorf("provided API key is empty") + } + + // Basic validation: ensure reasonable length (not too short) + if len(providedKey) < 16 { + return nil, fmt.Errorf("provided API key is too short (minimum 16 characters required)") + } + + // Use the key as-is - we don't dictate format for external keys + plainAPIKeyValue = providedKey + source = "external" + isExternalKey = true + } else { + // Local key generation: generate new random key with our standard format + // Format: apip_{64_hex_chars} (32 bytes → hex encoded) + plainAPIKeyValue, err = s.generateAPIKeyValue() + if err != nil { + return nil, err + } + source = "local" + isExternalKey = false } // Hash the API key for storage and policy engine + // Works for any format - we just hash whatever we receive hashedAPIKeyValue, err := s.hashAPIKey(plainAPIKeyValue) if err != nil { return nil, fmt.Errorf("failed to hash API key: %w", err) @@ -769,17 +831,17 @@ func (s *APIKeyService) generateAPIKeyFromRequest(handle string, request *api.AP duration = &request.ExpiresIn.Duration timeDuration := time.Duration(request.ExpiresIn.Duration) switch request.ExpiresIn.Unit { - case api.APIKeyGenerationRequestExpiresInUnitSeconds: + case api.APIKeyCreationRequestExpiresInUnitSeconds: timeDuration *= time.Second - case api.APIKeyGenerationRequestExpiresInUnitMinutes: + case api.APIKeyCreationRequestExpiresInUnitMinutes: timeDuration *= time.Minute - case api.APIKeyGenerationRequestExpiresInUnitHours: + case api.APIKeyCreationRequestExpiresInUnitHours: timeDuration *= time.Hour - case api.APIKeyGenerationRequestExpiresInUnitDays: + case api.APIKeyCreationRequestExpiresInUnitDays: timeDuration *= 24 * time.Hour - case api.APIKeyGenerationRequestExpiresInUnitWeeks: + case api.APIKeyCreationRequestExpiresInUnitWeeks: timeDuration *= 7 * 24 * time.Hour - case api.APIKeyGenerationRequestExpiresInUnitMonths: + case api.APIKeyCreationRequestExpiresInUnitMonths: timeDuration *= 30 * 24 * time.Hour // Approximate month as 30 days default: return nil, fmt.Errorf("unsupported expiration unit: %s", request.ExpiresIn.Unit) @@ -808,11 +870,22 @@ func (s *APIKeyService) generateAPIKeyFromRequest(handle string, request *api.AP ExpiresAt: expiresAt, Unit: unit, Duration: duration, + Source: source, // "local" or "external" + } + + // Set external reference fields if provided + // external_ref_id is optional and used for tracing purposes only + if request.ExternalRefId != nil && strings.TrimSpace(*request.ExternalRefId) != "" { + externalRefId := strings.TrimSpace(*request.ExternalRefId) + apiKey.ExternalRefId = &externalRefId } // Temporarily store the plain key for response generation // This field is not persisted and only used for returning to user - apiKey.PlainAPIKey = plainAPIKeyValue + // For external keys, we do NOT store the plain key (caller already has it) + if !isExternalKey { + apiKey.PlainAPIKey = plainAPIKeyValue + } return apiKey, nil } @@ -843,7 +916,7 @@ func (s *APIKeyService) generateOperationsString(operations []api.Operation) str } // buildAPIKeyResponse builds the response following the generated schema -func (s *APIKeyService) buildAPIKeyResponse(key *models.APIKey, handle, plainAPIKey string) api.APIKeyGenerationResponse { +func (s *APIKeyService) buildAPIKeyResponse(key *models.APIKey, handle string, plainAPIKey string, isExternalKeyInjection bool) api.APIKeyGenerationResponse { if key == nil { return api.APIKeyGenerationResponse{ Status: "error", @@ -851,6 +924,14 @@ func (s *APIKeyService) buildAPIKeyResponse(key *models.APIKey, handle, plainAPI } } + // Use provided message or default + var message string + if isExternalKeyInjection { + message = "API key registered successfully" + } else { + message = "API key generated successfully" + } + // Calculate remaining API key quota var remainingQuota *int currentCount, err := s.getCurrentAPIKeyCount(key.APIId, key.CreatedBy) @@ -865,8 +946,8 @@ func (s *APIKeyService) buildAPIKeyResponse(key *models.APIKey, handle, plainAPI // Use plainAPIKey for response if available, otherwise mask the hashed key var responseAPIKey *string - if plainAPIKey != "" { - // Format: apip_{key}_{base64url_encoded_id} + if plainAPIKey != "" && !isExternalKeyInjection { + // Format: apip_{64_hex_chars}.{hex_encoded_id} // Since the ID is already base64url encoded (22 chars), we can use it directly formattedAPIKey := plainAPIKey + constants.APIKeySeparator + key.ID responseAPIKey = &formattedAPIKey @@ -877,17 +958,18 @@ func (s *APIKeyService) buildAPIKeyResponse(key *models.APIKey, handle, plainAPI return api.APIKeyGenerationResponse{ Status: "success", - Message: "API key generated successfully", + Message: message, RemainingApiKeyQuota: remainingQuota, ApiKey: &api.APIKey{ Name: key.Name, - ApiKey: responseAPIKey, // Return plain key only during generation/regeneration + ApiKey: responseAPIKey, // Return plain key only for locally generated keys ApiId: handle, Operations: key.Operations, Status: api.APIKeyStatus(key.Status), CreatedAt: key.CreatedAt, CreatedBy: key.CreatedBy, ExpiresAt: key.ExpiresAt, + Source: api.APIKeySource(key.Source), }, } } @@ -1012,6 +1094,7 @@ func (s *APIKeyService) regenerateAPIKey(existingKey *models.APIKey, request api ExpiresAt: expiresAt, Unit: unit, Duration: duration, + Source: existingKey.Source, // Preserve source from original key } // Temporarily store the plain key for response generation @@ -1490,3 +1573,243 @@ func (s *APIKeyService) generateShortUniqueID() (string, error) { return id, nil } + +// CreateExternalAPIKeyFromEvent creates an API key from an external event (websocket). +// This is used when platform-api broadcasts an apikey.created event. +// The plain API key is hashed before storage. +func (s *APIKeyService) CreateExternalAPIKeyFromEvent( + apiId string, + keyName string, + plainAPIKey string, + externalRefId *string, + operations string, + expiresAt *time.Time, + logger *slog.Logger, +) error { + logger.Info("Creating external API key from event", + slog.String("api_id", apiId), + slog.String("key_name", keyName), + slog.Bool("has_expiry", expiresAt != nil), + ) + + // Validate inputs + if apiId == "" { + return fmt.Errorf("API ID cannot be empty") + } + if keyName == "" { + return fmt.Errorf("key name cannot be empty") + } + if plainAPIKey == "" { + return fmt.Errorf("API key cannot be empty") + } + + // Validate API key length + if len(plainAPIKey) < 16 { + return fmt.Errorf("API key is too short (minimum 16 characters required)") + } + + // Check if API exists + config, err := s.store.Get(apiId) + if err != nil { + return fmt.Errorf("API not found: %s", apiId) + } + + // Check if an API key with this name already exists + existingKeys, err := s.db.GetAPIKeysByAPI(apiId) + if err != nil && !errors.Is(err, storage.ErrNotFound) { + return fmt.Errorf("failed to check existing keys: %w", err) + } + + for _, key := range existingKeys { + if key.Name == keyName { + // Key with same name exists - update it instead of creating new one + logger.Info("API key with same name already exists, updating it", + slog.String("api_id", apiId), + slog.String("key_name", keyName), + slog.String("existing_id", key.ID), + ) + + // Hash the new API key + hashedAPIKey, err := s.hashAPIKey(plainAPIKey) + if err != nil { + return fmt.Errorf("failed to hash API key: %w", err) + } + + // Update existing key + key.APIKey = hashedAPIKey + key.MaskedAPIKey = s.MaskAPIKey(plainAPIKey) + key.Operations = operations + key.Status = models.APIKeyStatusActive + key.UpdatedAt = time.Now() + key.ExpiresAt = expiresAt + key.Source = "external" + key.ExternalRefId = externalRefId + + if err := s.db.UpdateAPIKey(key); err != nil { + return fmt.Errorf("failed to update API key: %w", err) + } + + // Trigger xDS snapshot update via xdsManager + if err := s.xdsManager.StoreAPIKey(apiId, config.GetDisplayName(), config.GetVersion(), key, "external-update"); err != nil { + logger.Error("Failed to update xDS snapshot after API key update", + slog.String("api_id", apiId), + slog.Any("error", err), + ) + return fmt.Errorf("failed to update xDS snapshot: %w", err) + } + + logger.Info("Successfully updated external API key", + slog.String("api_id", apiId), + slog.String("key_name", keyName), + slog.String("key_id", key.ID), + ) + + return nil + } + } + + // Generate unique ID for the key + id, err := s.generateShortUniqueID() + if err != nil { + return fmt.Errorf("failed to generate unique ID: %w", err) + } + + // Hash the API key + hashedAPIKey, err := s.hashAPIKey(plainAPIKey) + if err != nil { + return fmt.Errorf("failed to hash API key: %w", err) + } + + // Generate masked API key for display + maskedAPIKey := s.MaskAPIKey(plainAPIKey) + + // Create API key model + now := time.Now() + apiKey := &models.APIKey{ + ID: id, + Name: keyName, + APIKey: hashedAPIKey, + MaskedAPIKey: maskedAPIKey, + APIId: apiId, + Operations: operations, + Status: models.APIKeyStatusActive, + CreatedAt: now, + CreatedBy: "platform-api", // External keys are created by platform-api + UpdatedAt: now, + ExpiresAt: expiresAt, + Source: "external", + ExternalRefId: externalRefId, + } + + // Store in database + if err := s.db.SaveAPIKey(apiKey); err != nil { + if errors.Is(err, storage.ErrConflict) { + return fmt.Errorf("API key with name '%s' already exists", keyName) + } + return fmt.Errorf("failed to store API key: %w", err) + } + + // Trigger xDS snapshot update to propagate to policy engine via xdsManager + if err := s.xdsManager.StoreAPIKey(apiId, config.GetDisplayName(), config.GetVersion(), apiKey, "external-create"); err != nil { + logger.Error("Failed to update xDS snapshot after API key creation", + slog.String("api_id", apiId), + slog.Any("error", err), + ) + // Don't return error - key is already stored in DB + // Policy engine will get it on next full sync + } + + logger.Info("Successfully created external API key", + slog.String("api_id", apiId), + slog.String("key_name", keyName), + slog.String("key_id", id), + slog.String("source", "external"), + ) + + return nil +} + +// RevokeExternalAPIKeyFromEvent revokes an API key from an external event (websocket). +// This is used when platform-api broadcasts an apikey.revoked event. +func (s *APIKeyService) RevokeExternalAPIKeyFromEvent( + apiId string, + keyName string, + logger *slog.Logger, +) error { + logger.Info("Revoking external API key from event", + slog.String("api_id", apiId), + slog.String("key_name", keyName), + ) + + // Validate inputs + if apiId == "" { + return fmt.Errorf("API ID cannot be empty") + } + if keyName == "" { + return fmt.Errorf("key name cannot be empty") + } + + // Check if API exists + config, err := s.store.Get(apiId) + if err != nil { + logger.Warn("API not found, skipping revocation", + slog.String("api_id", apiId), + ) + return nil // Idempotent - already gone + } + + // Get API keys for this API + apiKeys, err := s.db.GetAPIKeysByAPI(apiId) + if err != nil { + if errors.Is(err, storage.ErrNotFound) { + logger.Warn("No API keys found for API, skipping revocation", + slog.String("api_id", apiId), + ) + return nil // Idempotent - no keys to revoke + } + return fmt.Errorf("failed to get API keys: %w", err) + } + + // Find the key by name + var targetKey *models.APIKey + for _, key := range apiKeys { + if key.Name == keyName { + targetKey = key + break + } + } + + if targetKey == nil { + logger.Warn("API key not found, skipping revocation (idempotent)", + slog.String("api_id", apiId), + slog.String("key_name", keyName), + ) + return nil // Idempotent - key doesn't exist + } + + // Mark as revoked + targetKey.Status = models.APIKeyStatusRevoked + targetKey.UpdatedAt = time.Now() + + // Update in database + if err := s.db.UpdateAPIKey(targetKey); err != nil { + return fmt.Errorf("failed to update API key status: %w", err) + } + + // Trigger xDS snapshot update to propagate to policy engine via xdsManager + if err := s.xdsManager.RevokeAPIKey(apiId, config.GetDisplayName(), config.GetVersion(), keyName, "external-revoke"); err != nil { + logger.Error("Failed to update xDS snapshot after API key revocation", + slog.String("api_id", apiId), + slog.Any("error", err), + ) + // Don't return error - key is already revoked in DB + } + + logger.Info("Successfully revoked external API key", + slog.String("api_id", apiId), + slog.String("key_name", keyName), + slog.String("key_id", targetKey.ID), + ) + + return nil +} diff --git a/gateway/policy-engine/Dockerfile b/gateway/policy-engine/Dockerfile index 480514ff1..39eee842e 100644 --- a/gateway/policy-engine/Dockerfile +++ b/gateway/policy-engine/Dockerfile @@ -38,6 +38,9 @@ WORKDIR /workspace # Copy SDK components COPY --from=sdk . /api-platform/sdk +# Copy common (needed for go.mod replace directive) +COPY --from=common . /api-platform/common + # Copy Gateway Builder application COPY --from=gateway-builder . /api-platform/gateway/gateway-builder @@ -70,6 +73,7 @@ ENV GIT_COMMIT=${GIT_COMMIT} # Copy Policy Engine framework source code COPY --from=policy-engine . /api-platform/gateway/policy-engine COPY --from=sdk . /api-platform/sdk +COPY --from=common . /api-platform/common # Pre-download Go dependencies for runtime WORKDIR /api-platform/gateway/policy-engine diff --git a/gateway/policy-engine/Makefile b/gateway/policy-engine/Makefile index 7da303db7..20ae682fe 100644 --- a/gateway/policy-engine/Makefile +++ b/gateway/policy-engine/Makefile @@ -84,6 +84,7 @@ build: test @echo "Building Policy Engine image: $(RUNTIME_TAG)" docker buildx build -f Dockerfile \ --build-context sdk=../../sdk \ + --build-context common=../../common \ --build-context gateway-builder=../gateway-builder \ --build-context policy-engine=. \ --build-context policies=../policies \ @@ -101,6 +102,7 @@ build-local: @echo "Building Policy Engine image locally: $(RUNTIME_TAG)" DOCKER_BUILDKIT=1 docker build -f Dockerfile \ --build-context sdk=../../sdk \ + --build-context common=../../common \ --build-context gateway-builder=../gateway-builder \ --build-context policy-engine=. \ --build-context policies=../policies \ @@ -123,6 +125,7 @@ build-and-push-multiarch: @echo "Building and pushing multi-arch Policy Engine image: $(RUNTIME_TAG)" docker buildx build -f Dockerfile \ --build-context sdk=../../sdk \ + --build-context common=../../common \ --build-context gateway-builder=../gateway-builder \ --build-context policy-engine=. \ --build-context policies=../policies \ diff --git a/gateway/policy-engine/go.mod b/gateway/policy-engine/go.mod index 02c5a9d73..db2acd0ad 100644 --- a/gateway/policy-engine/go.mod +++ b/gateway/policy-engine/go.mod @@ -13,6 +13,7 @@ require ( github.com/knadh/koanf/v2 v2.3.0 github.com/moesif/moesifapi-go v1.1.5 github.com/prometheus/client_golang v1.23.2 + github.com/wso2/api-platform/common v0.0.0 github.com/wso2/api-platform/sdk v0.3.1 go.opentelemetry.io/otel v1.39.0 go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracegrpc v1.39.0 @@ -61,3 +62,5 @@ require ( // Local module replacements for Docker builds replace github.com/wso2/api-platform/sdk => ../../sdk + +replace github.com/wso2/api-platform/common => ../../common diff --git a/gateway/policy-engine/go.sum b/gateway/policy-engine/go.sum index 2999cb383..89bf056d4 100644 --- a/gateway/policy-engine/go.sum +++ b/gateway/policy-engine/go.sum @@ -115,6 +115,7 @@ go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg= golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto= golang.org/x/crypto v0.46.0 h1:cKRW/pmt1pKAfetfu+RCEvjvZkA9RimPbh7bhFjGVBU= +golang.org/x/crypto v0.46.0/go.mod h1:Evb/oLKmMraqjZ2iQTwDwvCtJkczlDuTmdJXoZVzqU0= golang.org/x/exp v0.0.0-20250408133849-7e4ce0ab07d0 h1:R84qjqJb5nVJMxqWYb3np9L5ZsaDtB+a39EqjV0JSUM= golang.org/x/exp v0.0.0-20250408133849-7e4ce0ab07d0/go.mod h1:S9Xr4PYopiDyqSyp5NjCrhFrqg6A5zA2E/iPHPhqnS8= golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg= diff --git a/gateway/policy-engine/internal/xdsclient/api_key_handler.go b/gateway/policy-engine/internal/xdsclient/api_key_handler.go index e5939c44e..0b95d4a11 100644 --- a/gateway/policy-engine/internal/xdsclient/api_key_handler.go +++ b/gateway/policy-engine/internal/xdsclient/api_key_handler.go @@ -22,25 +22,25 @@ import ( "context" "encoding/json" "fmt" - "google.golang.org/protobuf/encoding/protojson" - "google.golang.org/protobuf/proto" "log/slog" "time" - policy "github.com/wso2/api-platform/sdk/gateway/policy/v1alpha" + "github.com/wso2/api-platform/common/apikey" policyenginev1 "github.com/wso2/api-platform/sdk/gateway/policyengine/v1" + "google.golang.org/protobuf/encoding/protojson" + "google.golang.org/protobuf/proto" "google.golang.org/protobuf/types/known/anypb" "google.golang.org/protobuf/types/known/structpb" ) // APIKeyOperationHandler handles API key operations received via xDS type APIKeyOperationHandler struct { - apiKeyStore *policy.APIkeyStore + apiKeyStore *apikey.APIkeyStore logger *slog.Logger } // NewAPIKeyOperationHandler creates a new API key operation handler -func NewAPIKeyOperationHandler(apiKeyStore *policy.APIkeyStore, logger *slog.Logger) *APIKeyOperationHandler { +func NewAPIKeyOperationHandler(apiKeyStore *apikey.APIkeyStore, logger *slog.Logger) *APIKeyOperationHandler { return &APIKeyOperationHandler{ apiKeyStore: apiKeyStore, logger: logger, @@ -128,18 +128,19 @@ func (h *APIKeyOperationHandler) handleStoreOperation(operation policyenginev1.A "api_key_name", operation.APIKey.Name, "correlation_id", operation.CorrelationID) - // Convert APIKeyData to policy.APIKey - apiKey := &policy.APIKey{ + // Convert APIKeyData to apikey.APIKey + apiKey := &apikey.APIKey{ ID: operation.APIKey.ID, Name: operation.APIKey.Name, APIKey: operation.APIKey.APIKey, APIId: operation.APIKey.APIId, Operations: operation.APIKey.Operations, - Status: policy.APIKeyStatus(operation.APIKey.Status), + Status: apikey.APIKeyStatus(operation.APIKey.Status), CreatedAt: operation.APIKey.CreatedAt, CreatedBy: operation.APIKey.CreatedBy, UpdatedAt: operation.APIKey.UpdatedAt, ExpiresAt: operation.APIKey.ExpiresAt, + Source: operation.APIKey.Source, } // Store the API key @@ -206,18 +207,19 @@ func (h *APIKeyOperationHandler) replaceAllAPIKeys(apiKeyDataList []APIKeyData) // Then, add all API keys from the new state for i, apiKeyData := range apiKeyDataList { - // Convert APIKeyData to policy.APIKey - apiKey := &policy.APIKey{ + // Convert APIKeyData to apikey.APIKey + apiKey := &apikey.APIKey{ ID: apiKeyData.ID, Name: apiKeyData.Name, APIKey: apiKeyData.APIKey, APIId: apiKeyData.APIId, Operations: apiKeyData.Operations, - Status: policy.APIKeyStatus(apiKeyData.Status), + Status: apikey.APIKeyStatus(apiKeyData.Status), CreatedAt: apiKeyData.CreatedAt, CreatedBy: apiKeyData.CreatedBy, UpdatedAt: apiKeyData.UpdatedAt, ExpiresAt: apiKeyData.ExpiresAt, + Source: apiKeyData.Source, } // Store the API key @@ -256,4 +258,5 @@ type APIKeyData struct { CreatedBy string `json:"createdBy"` UpdatedAt time.Time `json:"updatedAt"` ExpiresAt *time.Time `json:"expiresAt"` + Source string `json:"source"` // "local" | "external" } diff --git a/gateway/policy-engine/internal/xdsclient/handler.go b/gateway/policy-engine/internal/xdsclient/handler.go index 397a89a8b..b671153bb 100644 --- a/gateway/policy-engine/internal/xdsclient/handler.go +++ b/gateway/policy-engine/internal/xdsclient/handler.go @@ -30,6 +30,7 @@ import ( "google.golang.org/protobuf/types/known/anypb" "google.golang.org/protobuf/types/known/structpb" + "github.com/wso2/api-platform/common/apikey" "github.com/wso2/api-platform/gateway/policy-engine/internal/kernel" "github.com/wso2/api-platform/gateway/policy-engine/internal/metrics" "github.com/wso2/api-platform/gateway/policy-engine/internal/registry" @@ -56,7 +57,7 @@ type ResourceHandler struct { // NewResourceHandler creates a new ResourceHandler func NewResourceHandler(k *kernel.Kernel, reg *registry.PolicyRegistry) *ResourceHandler { - apiKeyStore := policy.GetAPIkeyStoreInstance() + apiKeyStore := apikey.GetAPIkeyStoreInstance() lazyResourceStore := policy.GetLazyResourceStoreInstance() return &ResourceHandler{ kernel: k, diff --git a/platform-api/src/internal/constants/error.go b/platform-api/src/internal/constants/error.go index 0532c39a4..61861445b 100644 --- a/platform-api/src/internal/constants/error.go +++ b/platform-api/src/internal/constants/error.go @@ -116,3 +116,13 @@ var ( ErrOpenAPIFileNotFound = errors.New("OpenAPI definition file not found") ErrWSO2ArtifactNotFound = errors.New("WSO2 API artifact not found") ) + +var ( + // API Key errors + ErrAPIKeyNotFound = errors.New("api key not found") + ErrAPIKeyAlreadyExists = errors.New("api key already exists") + ErrInvalidAPIKey = errors.New("invalid api key") + ErrGatewayUnavailable = errors.New("gateway unavailable") + ErrAPIKeyEventDelivery = errors.New("failed to deliver api key event to gateway") + ErrAPIKeyHashingFailed = errors.New("failed to hash api key") +) diff --git a/platform-api/src/internal/dto/apikey.go b/platform-api/src/internal/dto/apikey.go new file mode 100644 index 000000000..7d9917d47 --- /dev/null +++ b/platform-api/src/internal/dto/apikey.go @@ -0,0 +1,90 @@ +/* + * Copyright (c) 2026, WSO2 LLC. (http://www.wso2.org) All Rights Reserved. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + * + */ + +package dto + +// CreateAPIKeyRequest represents the request to register an external API key. +// This is used when Cloud APIM injects API keys to hybrid gateways. +type CreateAPIKeyRequest struct { + // Name is the unique identifier for this API key within the API + Name string `json:"name" binding:"required"` + + // ApiKey is the plain text API key value that will be hashed before storage + ApiKey string `json:"api_key" binding:"required"` + + // ExternalRefId is an optional reference ID for tracing purposes (from Cloud APIM) + ExternalRefId *string `json:"external_ref_id,omitempty"` + + // Operations specifies which API operations this key can access (default: "*" for all) + Operations string `json:"operations,omitempty"` + + // ExpiresAt is the optional expiration time in ISO 8601 format + ExpiresAt *string `json:"expires_at,omitempty"` +} + +// CreateAPIKeyResponse represents the response after registering an API key. +type CreateAPIKeyResponse struct { + // Status indicates the result of the operation ("success" or "error") + Status string `json:"status"` + + // Message provides additional details about the operation result + Message string `json:"message"` + + // KeyId is the internal ID generated by the gateway for tracking + KeyId string `json:"key_id,omitempty"` +} + +// RevokeAPIKeyResponse represents the response after revoking an API key. +type RevokeAPIKeyResponse struct { + // Status indicates the result of the operation ("success" or "error") + Status string `json:"status"` + + // Message provides additional details about the operation result + Message string `json:"message"` +} + +// APIKeyCreatedEventDTO represents the event payload for apikey.created. +// This is sent over WebSocket to notify gateways of new API keys. +type APIKeyCreatedEventDTO struct { + // ApiId identifies the API this key belongs to + ApiId string `json:"apiId"` + + // KeyName is the unique name of the API key + KeyName string `json:"keyName"` + + // HashedApiKey is the SHA256 hashed API key for secure storage + HashedApiKey string `json:"hashedApiKey"` + + // ExternalRefId is an optional reference ID for tracing + ExternalRefId *string `json:"externalRefId,omitempty"` + + // Operations specifies which API operations this key can access + Operations string `json:"operations"` + + // ExpiresAt is the optional expiration time in ISO 8601 format + ExpiresAt *string `json:"expiresAt,omitempty"` +} + +// APIKeyRevokedEventDTO represents the event payload for apikey.revoked. +// This is sent over WebSocket to notify gateways of revoked API keys. +type APIKeyRevokedEventDTO struct { + // ApiId identifies the API this key belongs to + ApiId string `json:"apiId"` + + // KeyName is the unique name of the API key that was revoked + KeyName string `json:"keyName"` +} diff --git a/platform-api/src/internal/handler/apikey_internal.go b/platform-api/src/internal/handler/apikey_internal.go new file mode 100644 index 000000000..a685e9083 --- /dev/null +++ b/platform-api/src/internal/handler/apikey_internal.go @@ -0,0 +1,211 @@ +/* + * Copyright (c) 2026, WSO2 LLC. (http://www.wso2.org) All Rights Reserved. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + * + */ + +package handler + +import ( + "errors" + "log" + "net/http" + + "platform-api/src/internal/constants" + "platform-api/src/internal/dto" + "platform-api/src/internal/service" + "platform-api/src/internal/utils" + + "github.com/gin-gonic/gin" +) + +// APIKeyInternalHandler handles internal API key operations for Cloud APIM integration +type APIKeyInternalHandler struct { + gatewayService *service.GatewayService + apiKeyService *service.APIKeyService +} + +// NewAPIKeyInternalHandler creates a new API key internal handler +func NewAPIKeyInternalHandler(gatewayService *service.GatewayService, apiKeyService *service.APIKeyService) *APIKeyInternalHandler { + return &APIKeyInternalHandler{ + gatewayService: gatewayService, + apiKeyService: apiKeyService, + } +} + +// CreateAPIKey handles POST /api/internal/v1/apis/{apiId}/api-keys +// This endpoint allows Cloud APIM to inject external API keys to hybrid gateways +func (h *APIKeyInternalHandler) CreateAPIKey(c *gin.Context) { + // Extract client IP for logging + clientIP := c.ClientIP() + + // Extract and validate API key from header + apiKey := c.GetHeader("api-key") + if apiKey == "" { + log.Printf("[WARN] Unauthorized API key creation attempt from IP: %s - Missing API key", clientIP) + c.JSON(http.StatusUnauthorized, utils.NewErrorResponse(401, "Unauthorized", + "API key is required. Provide 'api-key' header.")) + return + } + + // Authenticate gateway using API key + gateway, err := h.gatewayService.VerifyToken(apiKey) + if err != nil { + log.Printf("[WARN] API key creation authentication failed ip: %s - error=%v", clientIP, err) + c.JSON(http.StatusUnauthorized, utils.NewErrorResponse(401, "Unauthorized", + "Invalid or expired API key")) + return + } + + // Extract API ID from path parameter + apiID := c.Param("apiId") + if apiID == "" { + c.JSON(http.StatusBadRequest, utils.NewErrorResponse(400, "Bad Request", + "API ID is required")) + return + } + + // Parse and validate request body + var req dto.CreateAPIKeyRequest + if err := c.ShouldBindJSON(&req); err != nil { + log.Printf("[WARN] Invalid API key creation request from IP: %s - error=%v", clientIP, err) + c.JSON(http.StatusBadRequest, utils.NewErrorResponse(400, "Bad Request", + "Invalid request body: "+err.Error())) + return + } + + // Validate organization matches + orgID := gateway.OrganizationID + + // Create the API key + err = h.apiKeyService.CreateAPIKey(c.Request.Context(), apiID, orgID, &req) + if err != nil { + // Handle specific error cases + if errors.Is(err, constants.ErrAPINotFound) { + c.JSON(http.StatusNotFound, utils.NewErrorResponse(404, "Not Found", + "API not found")) + return + } + if errors.Is(err, constants.ErrGatewayUnavailable) { + c.JSON(http.StatusServiceUnavailable, utils.NewErrorResponse(503, "Service Unavailable", + "No gateway connections available for API")) + return + } + if errors.Is(err, constants.ErrAPIKeyHashingFailed) { + c.JSON(http.StatusInternalServerError, utils.NewErrorResponse(500, "Internal Server Error", + "Failed to process API key")) + return + } + + log.Printf("[ERROR] Failed to create API key: apiId=%s gatewayId=%s keyName=%s error=%v", + apiID, gateway.ID, req.Name, err) + c.JSON(http.StatusInternalServerError, utils.NewErrorResponse(500, "Internal Server Error", + "Failed to create API key")) + return + } + + log.Printf("[INFO] Successfully created API key: apiId=%s gatewayId=%s keyName=%s orgId=%s", + apiID, gateway.ID, req.Name, orgID) + + // Return success response + c.JSON(http.StatusCreated, dto.CreateAPIKeyResponse{ + Status: "success", + Message: "API key registered successfully", + KeyId: req.Name, // Using name as keyId for external reference + }) +} + +// RevokeAPIKey handles DELETE /api/internal/v1/apis/{apiId}/api-keys/{keyName} +// This endpoint allows Cloud APIM to revoke API keys from hybrid gateways +func (h *APIKeyInternalHandler) RevokeAPIKey(c *gin.Context) { + // Extract client IP for logging + clientIP := c.ClientIP() + + // Extract and validate API key from header + apiKey := c.GetHeader("api-key") + if apiKey == "" { + log.Printf("[WARN] Unauthorized API key revocation attempt from IP: %s - Missing API key", clientIP) + c.JSON(http.StatusUnauthorized, utils.NewErrorResponse(401, "Unauthorized", + "API key is required. Provide 'api-key' header.")) + return + } + + // Authenticate gateway using API key + gateway, err := h.gatewayService.VerifyToken(apiKey) + if err != nil { + log.Printf("[WARN] API key revocation authentication failed ip: %s - error=%v", clientIP, err) + c.JSON(http.StatusUnauthorized, utils.NewErrorResponse(401, "Unauthorized", + "Invalid or expired API key")) + return + } + + // Extract API ID from path parameter + apiID := c.Param("apiId") + if apiID == "" { + c.JSON(http.StatusBadRequest, utils.NewErrorResponse(400, "Bad Request", + "API ID is required")) + return + } + + // Extract key name from path parameter + keyName := c.Param("keyName") + if keyName == "" { + c.JSON(http.StatusBadRequest, utils.NewErrorResponse(400, "Bad Request", + "Key name is required")) + return + } + + // Validate organization matches + orgID := gateway.OrganizationID + + // Revoke the API key + err = h.apiKeyService.RevokeAPIKey(c.Request.Context(), apiID, orgID, keyName) + if err != nil { + // Handle specific error cases + if errors.Is(err, constants.ErrAPINotFound) { + c.JSON(http.StatusNotFound, utils.NewErrorResponse(404, "Not Found", + "API not found")) + return + } + if errors.Is(err, constants.ErrGatewayUnavailable) { + c.JSON(http.StatusServiceUnavailable, utils.NewErrorResponse(503, "Service Unavailable", + "No gateway connections available for API")) + return + } + + log.Printf("[ERROR] Failed to revoke API key: apiId=%s gatewayId=%s keyName=%s error=%v", + apiID, gateway.ID, keyName, err) + c.JSON(http.StatusInternalServerError, utils.NewErrorResponse(500, "Internal Server Error", + "Failed to revoke API key")) + return + } + + log.Printf("[INFO] Successfully revoked API key: apiId=%s gatewayId=%s keyName=%s orgId=%s", + apiID, gateway.ID, keyName, orgID) + + // Return success response + c.JSON(http.StatusOK, dto.RevokeAPIKeyResponse{ + Status: "success", + Message: "API key revoked successfully", + }) +} + +// RegisterRoutes registers the API key internal routes +func (h *APIKeyInternalHandler) RegisterRoutes(r *gin.Engine) { + apiKeyGroup := r.Group("/api/internal/v1/apis/:apiId/api-keys") + { + apiKeyGroup.POST("", h.CreateAPIKey) + apiKeyGroup.DELETE("/:keyName", h.RevokeAPIKey) + } +} diff --git a/platform-api/src/internal/model/apikey_event.go b/platform-api/src/internal/model/apikey_event.go new file mode 100644 index 000000000..4cafafbbb --- /dev/null +++ b/platform-api/src/internal/model/apikey_event.go @@ -0,0 +1,50 @@ +/* + * Copyright (c) 2026, WSO2 LLC. (http://www.wso2.org) All Rights Reserved. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + * + */ + +package model + +// APIKeyCreatedEvent represents the payload for "apikey.created" event type. +// This event is sent when an external API key is registered to hybrid gateways. +type APIKeyCreatedEvent struct { + // ApiId identifies the API this key belongs to + ApiId string `json:"apiId"` + + // KeyName is the unique name of the API key + KeyName string `json:"keyName"` + + // ApiKey is the plain API key value (hashing happens in the gateway) + ApiKey string `json:"apiKey"` + + // ExternalRefId is an optional reference ID for tracing purposes + ExternalRefId *string `json:"externalRefId,omitempty"` + + // Operations specifies which API operations this key can access (default: "*") + Operations string `json:"operations"` + + // ExpiresAt is the optional expiration time in ISO 8601 format + ExpiresAt *string `json:"expiresAt,omitempty"` +} + +// APIKeyRevokedEvent represents the payload for "apikey.revoked" event type. +// This event is sent when an API key is revoked from hybrid gateways. +type APIKeyRevokedEvent struct { + // ApiId identifies the API this key belongs to + ApiId string `json:"apiId"` + + // KeyName is the unique name of the API key that was revoked + KeyName string `json:"keyName"` +} diff --git a/platform-api/src/internal/server/server.go b/platform-api/src/internal/server/server.go index be7aa7fb5..0ce99a446 100644 --- a/platform-api/src/internal/server/server.go +++ b/platform-api/src/internal/server/server.go @@ -100,6 +100,7 @@ func StartPlatformAPIServer(cfg *config.Server) (*Server, error) { backendServiceRepo, upstreamService, gatewayEventsService, devPortalService, apiUtil) gatewayService := service.NewGatewayService(gatewayRepo, orgRepo, apiRepo) internalGatewayService := service.NewGatewayInternalAPIService(apiRepo, gatewayRepo, orgRepo, projectRepo, upstreamService) + apiKeyService := service.NewAPIKeyService(apiRepo, gatewayEventsService) gitService := service.NewGitService() deploymentService := service.NewDeploymentService(apiRepo, gatewayRepo, backendServiceRepo, orgRepo, gatewayEventsService, apiUtil, cfg) @@ -111,6 +112,7 @@ func StartPlatformAPIServer(cfg *config.Server) (*Server, error) { gatewayHandler := handler.NewGatewayHandler(gatewayService) wsHandler := handler.NewWebSocketHandler(wsManager, gatewayService, cfg.WebSocket.RateLimitPerMin) internalGatewayHandler := handler.NewGatewayInternalAPIHandler(gatewayService, internalGatewayService) + apiKeyInternalHandler := handler.NewAPIKeyInternalHandler(gatewayService, apiKeyService) gitHandler := handler.NewGitHandler(gitService) deploymentHandler := handler.NewDeploymentHandler(deploymentService) @@ -142,6 +144,7 @@ func StartPlatformAPIServer(cfg *config.Server) (*Server, error) { gatewayHandler.RegisterRoutes(router) wsHandler.RegisterRoutes(router) internalGatewayHandler.RegisterRoutes(router) + apiKeyInternalHandler.RegisterRoutes(router) gitHandler.RegisterRoutes(router) deploymentHandler.RegisterRoutes(router) diff --git a/platform-api/src/internal/service/apikey.go b/platform-api/src/internal/service/apikey.go new file mode 100644 index 000000000..ce9e3c2ac --- /dev/null +++ b/platform-api/src/internal/service/apikey.go @@ -0,0 +1,193 @@ +/* + * Copyright (c) 2026, WSO2 LLC. (http://www.wso2.org) All Rights Reserved. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + * + */ + +package service + +import ( + "context" + "fmt" + "log" + + "platform-api/src/internal/constants" + "platform-api/src/internal/dto" + "platform-api/src/internal/model" + "platform-api/src/internal/repository" +) + +// APIKeyService handles API key management operations for external API key injection +type APIKeyService struct { + apiRepo repository.APIRepository + gatewayEventsService *GatewayEventsService +} + +// NewAPIKeyService creates a new API key service instance +func NewAPIKeyService(apiRepo repository.APIRepository, gatewayEventsService *GatewayEventsService) *APIKeyService { + return &APIKeyService{ + apiRepo: apiRepo, + gatewayEventsService: gatewayEventsService, + } +} + +// CreateAPIKey hashes an external API key and broadcasts it to gateways where the API is deployed. +// This method is used when Cloud APIM injects API keys to hybrid gateways. +func (s *APIKeyService) CreateAPIKey(ctx context.Context, apiId, orgId string, req *dto.CreateAPIKeyRequest) error { + // Validate API exists and get its deployments + api, err := s.apiRepo.GetAPIByUUID(apiId, orgId) + if err != nil { + log.Printf("[ERROR] Failed to get API for API key creation: apiId=%s error=%v", apiId, err) + return fmt.Errorf("failed to get API: %w", err) + } + if api == nil { + log.Printf("[WARN] API not found for API key creation: apiId=%s", apiId) + return constants.ErrAPINotFound + } + + // Get all deployments for this API to find target gateways + deployments, err := s.apiRepo.GetDeploymentsByAPIUUID(apiId, orgId, nil, nil) + if err != nil { + log.Printf("[ERROR] Failed to get deployments for API key creation: apiId=%s error=%v", apiId, err) + return fmt.Errorf("failed to get API deployments: %w", err) + } + + if len(deployments) == 0 { + log.Printf("[WARN] No gateway deployments found for API: apiId=%s", apiId) + return constants.ErrGatewayUnavailable + } + + operations := "[\"*\"]" // Default to all operations + + // Build the API key created event + // Note: API key is sent as plain text - hashing happens in the gateway/policy-engine + event := &model.APIKeyCreatedEvent{ + ApiId: apiId, + KeyName: req.Name, + ApiKey: req.ApiKey, // Send plain API key (no hashing in platform-api) + ExternalRefId: req.ExternalRefId, + Operations: operations, + ExpiresAt: req.ExpiresAt, + } + + // Track delivery statistics + successCount := 0 + failureCount := 0 + var lastError error + + // Broadcast event to all gateways where API is deployed + for _, deployment := range deployments { + gatewayID := deployment.GatewayID + + log.Printf("[INFO] Broadcasting API key created event: apiId=%s gatewayId=%s keyName=%s", + apiId, gatewayID, req.Name) + + // Broadcast with retries + err := s.gatewayEventsService.BroadcastAPIKeyCreatedEvent(gatewayID, event) + if err != nil { + failureCount++ + lastError = err + log.Printf("[ERROR] Failed to broadcast API key created event: apiId=%s gatewayId=%s keyName=%s error=%v", + apiId, gatewayID, req.Name, err) + } else { + successCount++ + log.Printf("[INFO] Successfully broadcast API key created event: apiId=%s gatewayId=%s keyName=%s", + apiId, gatewayID, req.Name) + } + } + + // Log summary + log.Printf("[INFO] API key creation broadcast summary: apiId=%s keyName=%s total=%d success=%d failed=%d", + apiId, req.Name, len(deployments), successCount, failureCount) + + // Return error if all deliveries failed + if successCount == 0 { + log.Printf("[ERROR] Failed to deliver API key to any gateway: apiId=%s keyName=%s", apiId, req.Name) + return fmt.Errorf("failed to deliver API key event to any gateway: %w", lastError) + } + + // Partial success is still considered success (some gateways received the event) + return nil +} + +// RevokeAPIKey broadcasts API key revocation to all gateways where the API is deployed +func (s *APIKeyService) RevokeAPIKey(ctx context.Context, apiId, orgId, keyName string) error { + // Validate API exists and get its deployments + api, err := s.apiRepo.GetAPIByUUID(apiId, orgId) + if err != nil { + log.Printf("[ERROR] Failed to get API for API key revocation: apiId=%s error=%v", apiId, err) + return fmt.Errorf("failed to get API: %w", err) + } + if api == nil { + log.Printf("[WARN] API not found for API key revocation: apiId=%s", apiId) + return constants.ErrAPINotFound + } + + // Get all deployments for this API to find target gateways + deployments, err := s.apiRepo.GetDeploymentsByAPIUUID(apiId, orgId, nil, nil) + if err != nil { + log.Printf("[ERROR] Failed to get deployments for API key revocation: apiId=%s error=%v", apiId, err) + return fmt.Errorf("failed to get API deployments: %w", err) + } + + if len(deployments) == 0 { + log.Printf("[WARN] No gateway deployments found for API: apiId=%s", apiId) + return constants.ErrGatewayUnavailable + } + + // Build the API key revoked event + event := &model.APIKeyRevokedEvent{ + ApiId: apiId, + KeyName: keyName, + } + + // Track delivery statistics + successCount := 0 + failureCount := 0 + var lastError error + + // Broadcast event to all gateways where API is deployed + for _, deployment := range deployments { + gatewayID := deployment.GatewayID + + log.Printf("[INFO] Broadcasting API key revoked event: apiId=%s gatewayId=%s keyName=%s", + apiId, gatewayID, keyName) + + // Broadcast with retries + err := s.gatewayEventsService.BroadcastAPIKeyRevokedEvent(gatewayID, event) + if err != nil { + failureCount++ + lastError = err + log.Printf("[ERROR] Failed to broadcast API key revoked event: apiId=%s gatewayId=%s keyName=%s error=%v", + apiId, gatewayID, keyName, err) + } else { + successCount++ + log.Printf("[INFO] Successfully broadcast API key revoked event: apiId=%s gatewayId=%s keyName=%s", + apiId, gatewayID, keyName) + } + } + + // Log summary + log.Printf("[INFO] API key revocation broadcast summary: apiId=%s keyName=%s total=%d success=%d failed=%d", + apiId, keyName, len(deployments), successCount, failureCount) + + // Return error if all deliveries failed + if successCount == 0 { + log.Printf("[ERROR] Failed to deliver API key revocation to any gateway: apiId=%s keyName=%s", apiId, keyName) + return fmt.Errorf("failed to deliver API key revocation event to any gateway: %w", lastError) + } + + // Partial success is still considered success + return nil +} diff --git a/platform-api/src/internal/service/gateway_events.go b/platform-api/src/internal/service/gateway_events.go index 555a0da56..c533217b2 100644 --- a/platform-api/src/internal/service/gateway_events.go +++ b/platform-api/src/internal/service/gateway_events.go @@ -165,7 +165,7 @@ func (s *GatewayEventsService) BroadcastUndeploymentEvent(gatewayID string, unde // Serialize complete event eventJSON, err := json.Marshal(eventDTO) if err != nil { - log.Printf("[ERROR] Failed to marshal undeployment event DTO: gatewayID=%s correlationId=%s error=%v", gatewayID, correlationID, err) + log.Printf("[ERROR] Failed to marshal event DTO: gatewayID=%s correlationId=%s error=%v", gatewayID, correlationID, err) return fmt.Errorf("failed to marshal event: %w", err) } @@ -207,3 +207,235 @@ func (s *GatewayEventsService) BroadcastUndeploymentEvent(gatewayID string, unde return nil } + +// BroadcastAPIKeyCreatedEvent sends an API key created event to target gateway with retries. +// This method handles: +// - Looking up gateway connections by gateway ID +// - Serializing event to JSON +// - Broadcasting to all connections for the gateway (clustering support) +// - Retry logic for critical API key events (up to 3 attempts) +// - Payload size validation +// - Delivery statistics tracking +func (s *GatewayEventsService) BroadcastAPIKeyCreatedEvent(gatewayID string, event *model.APIKeyCreatedEvent) error { + const maxRetries = 1 + const retryDelay = 1 * time.Second + + var lastError error + + // Retry loop for critical API key events + for attempt := 0; attempt < maxRetries; attempt++ { + if attempt > 0 { + log.Printf("[INFO] Retrying API key created event broadcast: gatewayID=%s attempt=%d/%d", + gatewayID, attempt+1, maxRetries) + time.Sleep(retryDelay * time.Duration(attempt)) // Linear backoff + } + + err := s.broadcastAPIKeyCreated(gatewayID, event) + if err == nil { + if attempt > 0 { + log.Printf("[INFO] API key created event delivered after retry: gatewayID=%s attempts=%d", + gatewayID, attempt+1) + } + return nil + } + + lastError = err + log.Printf("[WARN] API key created event delivery failed: gatewayID=%s attempt=%d/%d error=%v", + gatewayID, attempt+1, maxRetries, err) + } + + log.Printf("[ERROR] API key created event delivery failed after all retries: gatewayID=%s retries=%d error=%v", + gatewayID, maxRetries, lastError) + return fmt.Errorf("failed to deliver API key created event after %d retries: %w", maxRetries, lastError) +} + +// BroadcastAPIKeyRevokedEvent sends an API key revoked event to target gateway with retries. +// This method handles: +// - Looking up gateway connections by gateway ID +// - Serializing event to JSON +// - Broadcasting to all connections for the gateway (clustering support) +// - Retry logic for critical API key events (up to 3 attempts) +// - Payload size validation +// - Delivery statistics tracking +func (s *GatewayEventsService) BroadcastAPIKeyRevokedEvent(gatewayID string, event *model.APIKeyRevokedEvent) error { + const maxRetries = 1 + const retryDelay = 1 * time.Second + + var lastError error + + // Retry loop for critical API key events + for attempt := 0; attempt < maxRetries; attempt++ { + if attempt > 0 { + log.Printf("[INFO] Retrying API key revoked event broadcast: gatewayID=%s attempt=%d/%d", + gatewayID, attempt+1, maxRetries) + time.Sleep(retryDelay * time.Duration(attempt)) // Linear backoff + } + + err := s.broadcastAPIKeyRevoked(gatewayID, event) + if err == nil { + if attempt > 0 { + log.Printf("[INFO] API key revoked event delivered after retry: gatewayID=%s attempts=%d", + gatewayID, attempt+1) + } + return nil + } + + lastError = err + log.Printf("[WARN] API key revoked event delivery failed: gatewayID=%s attempt=%d/%d error=%v", + gatewayID, attempt+1, maxRetries, err) + } + + log.Printf("[ERROR] API key revoked event delivery failed after all retries: gatewayID=%s retries=%d error=%v", + gatewayID, maxRetries, lastError) + return fmt.Errorf("failed to deliver API key revoked event after %d retries: %w", maxRetries, lastError) +} + +// broadcastAPIKeyCreated is the internal implementation for broadcasting API key created events +func (s *GatewayEventsService) broadcastAPIKeyCreated(gatewayID string, event *model.APIKeyCreatedEvent) error { + // Create correlation ID for tracing + correlationID := uuid.New().String() + + // Serialize payload + payloadJSON, err := json.Marshal(event) + if err != nil { + log.Printf("[ERROR] Failed to serialize API key created event: gatewayID=%s error=%v", gatewayID, err) + return fmt.Errorf("failed to serialize API key created event: %w", err) + } + + // Validate payload size + if len(payloadJSON) > MaxEventPayloadSize { + err := fmt.Errorf("event payload exceeds maximum size: %d bytes (limit: %d bytes)", len(payloadJSON), MaxEventPayloadSize) + log.Printf("[ERROR] Payload size validation failed: gatewayID=%s size=%d error=%v", gatewayID, len(payloadJSON), err) + return err + } + + // Create gateway event DTO + eventDTO := dto.GatewayEventDTO{ + Type: "apikey.created", + Payload: event, + Timestamp: time.Now().Format(time.RFC3339), + CorrelationID: correlationID, + } + + // Serialize complete event + eventJSON, err := json.Marshal(eventDTO) + if err != nil { + log.Printf("[ERROR] Failed to marshal API key created event DTO: gatewayID=%s correlationId=%s error=%v", + gatewayID, correlationID, err) + return fmt.Errorf("failed to marshal event: %w", err) + } + + // Get all connections for this gateway + connections := s.manager.GetConnections(gatewayID) + if len(connections) == 0 { + log.Printf("[WARN] No active connections for gateway: gatewayID=%s correlationId=%s", gatewayID, correlationID) + return fmt.Errorf("no active connections for gateway: %s", gatewayID) + } + + // Broadcast to all connections + successCount := 0 + failureCount := 0 + var lastError error + + for _, conn := range connections { + err := conn.Send(eventJSON) + if err != nil { + failureCount++ + lastError = err + log.Printf("[ERROR] Failed to send API key created event: gatewayID=%s connectionID=%s correlationId=%s error=%v", + gatewayID, conn.ConnectionID, correlationID, err) + conn.DeliveryStats.IncrementFailed(fmt.Sprintf("send error: %v", err)) + } else { + successCount++ + log.Printf("[INFO] API key created event sent: gatewayID=%s connectionID=%s correlationId=%s keyName=%s", + gatewayID, conn.ConnectionID, correlationID, event.KeyName) + conn.DeliveryStats.IncrementTotalSent() + } + } + + // Log broadcast summary + log.Printf("[INFO] Broadcast summary: gatewayID=%s correlationId=%s type=apikey.created total=%d success=%d failed=%d", + gatewayID, correlationID, len(connections), successCount, failureCount) + + // Return error if all deliveries failed + if successCount == 0 { + return fmt.Errorf("failed to deliver event to any connection: %w", lastError) + } + + return nil +} + +// broadcastAPIKeyRevoked is the internal implementation for broadcasting API key revoked events +func (s *GatewayEventsService) broadcastAPIKeyRevoked(gatewayID string, event *model.APIKeyRevokedEvent) error { + // Create correlation ID for tracing + correlationID := uuid.New().String() + + // Serialize payload + payloadJSON, err := json.Marshal(event) + if err != nil { + log.Printf("[ERROR] Failed to serialize API key revoked event: gatewayID=%s error=%v", gatewayID, err) + return fmt.Errorf("failed to serialize API key revoked event: %w", err) + } + + // Validate payload size + if len(payloadJSON) > MaxEventPayloadSize { + err := fmt.Errorf("event payload exceeds maximum size: %d bytes (limit: %d bytes)", len(payloadJSON), MaxEventPayloadSize) + log.Printf("[ERROR] Payload size validation failed: gatewayID=%s size=%d error=%v", gatewayID, len(payloadJSON), err) + return err + } + + // Create gateway event DTO + eventDTO := dto.GatewayEventDTO{ + Type: "apikey.revoked", + Payload: event, + Timestamp: time.Now().Format(time.RFC3339), + CorrelationID: correlationID, + } + + // Serialize complete event + eventJSON, err := json.Marshal(eventDTO) + if err != nil { + log.Printf("[ERROR] Failed to marshal API key revoked event DTO: gatewayID=%s correlationId=%s error=%v", + gatewayID, correlationID, err) + return fmt.Errorf("failed to marshal event: %w", err) + } + + // Get all connections for this gateway + connections := s.manager.GetConnections(gatewayID) + if len(connections) == 0 { + log.Printf("[WARN] No active connections for gateway: gatewayID=%s correlationId=%s", gatewayID, correlationID) + return fmt.Errorf("no active connections for gateway: %s", gatewayID) + } + + // Broadcast to all connections + successCount := 0 + failureCount := 0 + var lastError error + + for _, conn := range connections { + err := conn.Send(eventJSON) + if err != nil { + failureCount++ + lastError = err + log.Printf("[ERROR] Failed to send API key revoked event: gatewayID=%s connectionID=%s correlationId=%s error=%v", + gatewayID, conn.ConnectionID, correlationID, err) + conn.DeliveryStats.IncrementFailed(fmt.Sprintf("send error: %v", err)) + } else { + successCount++ + log.Printf("[INFO] API key revoked event sent: gatewayID=%s connectionID=%s correlationId=%s keyName=%s", + gatewayID, conn.ConnectionID, correlationID, event.KeyName) + conn.DeliveryStats.IncrementTotalSent() + } + } + + // Log broadcast summary + log.Printf("[INFO] Broadcast summary: gatewayID=%s correlationId=%s type=apikey.revoked total=%d success=%d failed=%d", + gatewayID, correlationID, len(connections), successCount, failureCount) + + // Return error if all deliveries failed + if successCount == 0 { + return fmt.Errorf("failed to deliver event to any connection: %w", lastError) + } + + return nil +} diff --git a/sdk/gateway/policy/v1alpha/api_key_compat.go b/sdk/gateway/policy/v1alpha/api_key_compat.go new file mode 100644 index 000000000..9c33c9260 --- /dev/null +++ b/sdk/gateway/policy/v1alpha/api_key_compat.go @@ -0,0 +1,74 @@ +/* + * Copyright (c) 2026, WSO2 LLC. (https://www.wso2.com). + * + * WSO2 LLC. licenses this file to you under the Apache License, + * Version 2.0 (the "License"); you may not use this file except + * in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +package policyv1alpha + +import ( + "github.com/wso2/api-platform/common/apikey" +) + +// DEPRECATED: This file provides backward compatibility for code that imports API key types from the SDK. +// New code should import directly from github.com/wso2/api-platform/common/apikey instead. +// These re-exports will be removed in a future version. + +// APIKey is deprecated. Use apikey.APIKey instead. +// Deprecated: Use github.com/wso2/api-platform/common/apikey.APIKey +type APIKey = apikey.APIKey + +// APIKeyStatus is deprecated. Use apikey.APIKeyStatus instead. +// Deprecated: Use github.com/wso2/api-platform/common/apikey.APIKeyStatus +type APIKeyStatus = apikey.APIKeyStatus + +// ParsedAPIKey is deprecated. Use apikey.ParsedAPIKey instead. +// Deprecated: Use github.com/wso2/api-platform/common/apikey.ParsedAPIKey +type ParsedAPIKey = apikey.ParsedAPIKey + +// APIkeyStore is deprecated. Use apikey.APIkeyStore instead. +// Deprecated: Use github.com/wso2/api-platform/common/apikey.APIkeyStore +type APIkeyStore = apikey.APIkeyStore + +// Status constants - deprecated +// Deprecated: Use github.com/wso2/api-platform/common/apikey constants +const ( + Active = apikey.Active + Expired = apikey.Expired + Revoked = apikey.Revoked +) + +// APIKeySeparator is deprecated. Use apikey.APIKeySeparator instead. +// Deprecated: Use github.com/wso2/api-platform/common/apikey.APIKeySeparator +const APIKeySeparator = apikey.APIKeySeparator + +// Errors - deprecated +// Deprecated: Use github.com/wso2/api-platform/common/apikey errors +var ( + ErrNotFound = apikey.ErrNotFound + ErrConflict = apikey.ErrConflict +) + +// NewAPIkeyStore is deprecated. Use apikey.NewAPIkeyStore instead. +// Deprecated: Use github.com/wso2/api-platform/common/apikey.NewAPIkeyStore +func NewAPIkeyStore() *APIkeyStore { + return apikey.NewAPIkeyStore() +} + +// GetAPIkeyStoreInstance is deprecated. Use apikey.GetAPIkeyStoreInstance instead. +// Deprecated: Use github.com/wso2/api-platform/common/apikey.GetAPIkeyStoreInstance +func GetAPIkeyStoreInstance() *APIkeyStore { + return apikey.GetAPIkeyStoreInstance() +} diff --git a/sdk/gateway/policyengine/v1/api_key_xds.go b/sdk/gateway/policyengine/v1/api_key_xds.go index c6ad99c05..a2fb09a83 100644 --- a/sdk/gateway/policyengine/v1/api_key_xds.go +++ b/sdk/gateway/policyengine/v1/api_key_xds.go @@ -86,6 +86,9 @@ type APIKeyData struct { // ExpiresAt Expiration timestamp (null if no expiration) ExpiresAt *time.Time `json:"expires_at" yaml:"expires_at"` + + // Source tracking for external key support ("local" | "external") + Source string `json:"source" yaml:"source"` } // APIKeyOperationBatch represents a batch of API key operations diff --git a/sdk/go.mod b/sdk/go.mod index 220606ac3..2ac9632a3 100644 --- a/sdk/go.mod +++ b/sdk/go.mod @@ -8,6 +8,7 @@ require ( github.com/milvus-io/milvus/client/v2 v2.6.1 github.com/milvus-io/milvus/pkg/v2 v2.6.7 github.com/redis/go-redis/v9 v9.8.0 + github.com/wso2/api-platform/common v0.0.0 golang.org/x/crypto v0.46.0 ) @@ -124,3 +125,5 @@ require ( k8s.io/apimachinery v0.32.3 // indirect sigs.k8s.io/yaml v1.4.0 // indirect ) + +replace github.com/wso2/api-platform/common => ../common From ffc36bbeac2429dfe2f16787b4a82e6d6b241110 Mon Sep 17 00:00:00 2001 From: Thushani Jayasekera Date: Thu, 29 Jan 2026 12:13:36 +0530 Subject: [PATCH 02/14] Fix coderabbit review changes --- common/apikey/store.go | 38 ++-- gateway/gateway-controller/api/openapi.yaml | 1 + .../gateway-controller/pkg/storage/sqlite.go | 30 ++- .../gateway-controller/pkg/utils/api_key.go | 149 +++++++++---- platform-api/src/internal/handler/api_key.go | 183 +++++++++++++++ .../src/internal/handler/apikey_internal.go | 211 ------------------ platform-api/src/internal/server/server.go | 4 +- 7 files changed, 338 insertions(+), 278 deletions(-) create mode 100644 platform-api/src/internal/handler/api_key.go delete mode 100644 platform-api/src/internal/handler/apikey_internal.go diff --git a/common/apikey/store.go b/common/apikey/store.go index 8a3365ff7..8b6be38c3 100644 --- a/common/apikey/store.go +++ b/common/apikey/store.go @@ -77,6 +77,16 @@ const ( const APIKeySeparator = "_" +// effectiveSource returns the effective source for matching: empty or "null" is treated as "local" for legacy keys. +// Persisted storage (e.g. gateway-controller SQLite) is migrated to set source = 'local' by default; this +// fallback covers the in-memory store and any key that arrives with empty/null source (e.g. via xDS/sync). +func effectiveSource(source string) string { + if source == "" { + return "local" + } + return source +} + // Common storage errors - implementation agnostic var ( // ErrNotFound is returned when an API key is not found @@ -170,16 +180,12 @@ func (aks *APIkeyStore) ValidateAPIKey(apiId, apiOperation, operationMethod, pro var targetAPIKey *APIKey - // Quick check: Does the key contain the separator character? - // Local keys have format: key_value_{id} (contains underscore) - // External keys are arbitrary strings (may or may not contain underscore) - // Try to parse as local key (format: key_id) parsedAPIkey, ok := parseAPIKey(providedAPIKey) if ok { // Optimized O(1) lookup for local keys using ID apiKey, exists := aks.apiKeysByAPI[apiId][parsedAPIkey.ID] - if exists && apiKey.Source == "local" && compareAPIKeys(parsedAPIkey.APIKey, apiKey.APIKey) { + if exists && effectiveSource(apiKey.Source) == "local" && compareAPIKeys(parsedAPIkey.APIKey, apiKey.APIKey) { targetAPIKey = apiKey } } @@ -191,7 +197,7 @@ func (aks *APIkeyStore) ValidateAPIKey(apiId, apiOperation, operationMethod, pro if exists { for _, apiKey := range apiKeys { // For external keys, compare the full provided key directly (no parsing) - if apiKey.Source == "external" && compareAPIKeys(providedAPIKey, apiKey.APIKey) { + if effectiveSource(apiKey.Source) == "external" && compareAPIKeys(providedAPIKey, apiKey.APIKey) { targetAPIKey = apiKey break } @@ -254,19 +260,13 @@ func (aks *APIkeyStore) RevokeAPIKey(apiId, providedAPIKey string) error { var matchedKey *APIKey - // Quick check: Does the key contain the separator character? - // Local keys have format: key_value_{id} (contains underscore) - // External keys are arbitrary strings (may or may not contain underscore) - hasLocalKeyFormat := strings.Contains(providedAPIKey, APIKeySeparator) - - if hasLocalKeyFormat { - // Try to parse as local key (format: key_id) - parsedAPIkey, ok := parseAPIKey(providedAPIKey) - if ok { - apiKey, exists := aks.apiKeysByAPI[apiId][parsedAPIkey.ID] - if exists && apiKey.Source == "local" && compareAPIKeys(parsedAPIkey.APIKey, apiKey.APIKey) { - matchedKey = apiKey - } + + // Try to parse as local key (format: key_id); empty Source treated as "local" + parsedAPIkey, ok := parseAPIKey(providedAPIKey) + if ok { + apiKey, exists := aks.apiKeysByAPI[apiId][parsedAPIkey.ID] + if exists && effectiveSource(apiKey.Source) == "local" && compareAPIKeys(parsedAPIkey.APIKey, apiKey.APIKey) { + matchedKey = apiKey } } diff --git a/gateway/gateway-controller/api/openapi.yaml b/gateway/gateway-controller/api/openapi.yaml index e35a5401d..bd5c097a0 100644 --- a/gateway/gateway-controller/api/openapi.yaml +++ b/gateway/gateway-controller/api/openapi.yaml @@ -2231,6 +2231,7 @@ components: example: my-weather-api-key api_key: type: string + minLength: 16 description: | Optional plain-text API key value for external key injection. If provided, this key will be used instead of generating a new one. diff --git a/gateway/gateway-controller/pkg/storage/sqlite.go b/gateway/gateway-controller/pkg/storage/sqlite.go index 58f9b882e..fe1e10b82 100644 --- a/gateway/gateway-controller/pkg/storage/sqlite.go +++ b/gateway/gateway-controller/pkg/storage/sqlite.go @@ -242,12 +242,32 @@ func (s *SQLiteStorage) initSchema() error { } } - // Add external API key support columns - if _, err := s.db.Exec(`ALTER TABLE api_keys ADD COLUMN source TEXT NOT NULL DEFAULT 'local';`); err != nil { - return fmt.Errorf("failed to add source column to api_keys: %w", err) + // Add external API key support columns (only if missing; fresh DBs may already have them) + err = s.db.QueryRow(`SELECT COUNT(*) FROM pragma_table_info('api_keys') WHERE name = 'source'`).Scan(&columnExists) + if err == nil && columnExists == 0 { + if _, err := s.db.Exec(`ALTER TABLE api_keys ADD COLUMN source TEXT NOT NULL DEFAULT 'local'`); err != nil { + return fmt.Errorf("failed to add source column to api_keys: %w", err) + } + } + err = s.db.QueryRow(`SELECT COUNT(*) FROM pragma_table_info('api_keys') WHERE name = 'external_ref_id'`).Scan(&columnExists) + if err == nil && columnExists == 0 { + if _, err := s.db.Exec(`ALTER TABLE api_keys ADD COLUMN external_ref_id TEXT NULL`); err != nil { + return fmt.Errorf("failed to add external_ref_id column to api_keys: %w", err) + } } - if _, err := s.db.Exec(`ALTER TABLE api_keys ADD COLUMN external_ref_id TEXT NULL;`); err != nil { - return fmt.Errorf("failed to add external_ref_id column to api_keys: %w", err) + // Backfill legacy keys: treat empty or 'null' source as 'local' (DB + local cache consistency) + if _, err := s.db.Exec(` + UPDATE api_keys + SET source = 'local' + WHERE source != 'local' + AND ( + source IS NULL + OR trim(source) = '' + OR lower(trim(source)) = 'null' + ) + `); + err != nil { + s.logger.Warn("Failed to backfill api_keys.source for legacy keys", slog.Any("error", err)) } if _, err := s.db.Exec(`CREATE INDEX IF NOT EXISTS idx_api_key_source ON api_keys(source);`); err != nil { return fmt.Errorf("failed to create api_keys source index: %w", err) diff --git a/gateway/gateway-controller/pkg/utils/api_key.go b/gateway/gateway-controller/pkg/utils/api_key.go index 7395a69aa..0ffa1c9dc 100644 --- a/gateway/gateway-controller/pkg/utils/api_key.go +++ b/gateway/gateway-controller/pkg/utils/api_key.go @@ -212,7 +212,7 @@ func (s *APIKeyService) CreateAPIKey(params APIKeyCreationParams) (*APIKeyCreati slog.String("handle", params.Handle), slog.String("operation", operationType + "_key"), slog.String("correlation_id", params.CorrelationID)) - return nil, fmt.Errorf("the provided API key already exists in the system") + return nil, fmt.Errorf("%w: provided API key already exists", storage.ErrConflict) } // For local keys, retry with a new generated key @@ -1614,10 +1614,18 @@ func (s *APIKeyService) CreateExternalAPIKeyFromEvent( return fmt.Errorf("API not found: %s", apiId) } - // Check if an API key with this name already exists - existingKeys, err := s.db.GetAPIKeysByAPI(apiId) - if err != nil && !errors.Is(err, storage.ErrNotFound) { - return fmt.Errorf("failed to check existing keys: %w", err) + // Check if an API key with this name already exists (db or in-memory store) + var existingKeys []*models.APIKey + if s.db != nil { + existingKeys, err = s.db.GetAPIKeysByAPI(apiId) + if err != nil && !errors.Is(err, storage.ErrNotFound) { + return fmt.Errorf("failed to check existing keys: %w", err) + } + } else { + existingKeys, err = s.store.GetAPIKeysByAPI(apiId) + if err != nil && !errors.Is(err, storage.ErrNotFound) { + return fmt.Errorf("failed to check existing keys: %w", err) + } } for _, key := range existingKeys { @@ -1645,17 +1653,25 @@ func (s *APIKeyService) CreateExternalAPIKeyFromEvent( key.Source = "external" key.ExternalRefId = externalRefId - if err := s.db.UpdateAPIKey(key); err != nil { - return fmt.Errorf("failed to update API key: %w", err) + if s.db != nil { + if err := s.db.UpdateAPIKey(key); err != nil { + return fmt.Errorf("failed to update API key: %w", err) + } } - // Trigger xDS snapshot update via xdsManager - if err := s.xdsManager.StoreAPIKey(apiId, config.GetDisplayName(), config.GetVersion(), key, "external-update"); err != nil { - logger.Error("Failed to update xDS snapshot after API key update", - slog.String("api_id", apiId), - slog.Any("error", err), - ) - return fmt.Errorf("failed to update xDS snapshot: %w", err) + // Upsert into in-memory ConfigStore + if err := s.store.StoreAPIKey(key); err != nil { + return fmt.Errorf("failed to update API key in config store: %w", err) + } + + // Trigger xDS snapshot update via xdsManager (log only, do not fail) + if s.xdsManager != nil { + if err := s.xdsManager.StoreAPIKey(apiId, config.GetDisplayName(), config.GetVersion(), key, "external-update"); err != nil { + logger.Error("Failed to update xDS snapshot after API key update", + slog.String("api_id", apiId), + slog.Any("error", err), + ) + } } logger.Info("Successfully updated external API key", @@ -1668,6 +1684,19 @@ func (s *APIKeyService) CreateExternalAPIKeyFromEvent( } } + // Enforce API key quota/limit for this API before creating a NEW key. + // External events don't carry an end-user identity, so we attribute quota checks to the + // external creator ("platform-api") to ensure we never exceed the configured maximum. + if err := s.enforceAPIKeyLimit(apiId, "platform-api", logger); err != nil { + logger.Warn("API key creation limit exceeded for external event", + slog.String("api_id", apiId), + slog.String("key_name", keyName), + slog.String("created_by", "platform-api"), + slog.Any("error", err), + ) + return err + } + // Generate unique ID for the key id, err := s.generateShortUniqueID() if err != nil { @@ -1701,22 +1730,31 @@ func (s *APIKeyService) CreateExternalAPIKeyFromEvent( ExternalRefId: externalRefId, } - // Store in database - if err := s.db.SaveAPIKey(apiKey); err != nil { - if errors.Is(err, storage.ErrConflict) { - return fmt.Errorf("API key with name '%s' already exists", keyName) + // Store in database (optional) + if s.db != nil { + if err := s.db.SaveAPIKey(apiKey); err != nil { + if errors.Is(err, storage.ErrConflict) { + return fmt.Errorf("API key with name '%s' already exists", keyName) + } + return fmt.Errorf("failed to store API key: %w", err) } - return fmt.Errorf("failed to store API key: %w", err) } - // Trigger xDS snapshot update to propagate to policy engine via xdsManager - if err := s.xdsManager.StoreAPIKey(apiId, config.GetDisplayName(), config.GetVersion(), apiKey, "external-create"); err != nil { - logger.Error("Failed to update xDS snapshot after API key creation", - slog.String("api_id", apiId), - slog.Any("error", err), - ) - // Don't return error - key is already stored in DB - // Policy engine will get it on next full sync + // Upsert into in-memory ConfigStore + if err := s.store.StoreAPIKey(apiKey); err != nil { + return fmt.Errorf("failed to store API key in config store: %w", err) + } + + // Trigger xDS snapshot update to propagate to policy engine via xdsManager (log only, do not fail) + if s.xdsManager != nil { + if err := s.xdsManager.StoreAPIKey(apiId, config.GetDisplayName(), config.GetVersion(), apiKey, "external-create"); err != nil { + logger.Error("Failed to update xDS snapshot after API key creation", + slog.String("api_id", apiId), + slog.Any("error", err), + ) + // Don't return error - key is already stored in DB/store + // Policy engine will get it on next full sync + } } logger.Info("Successfully created external API key", @@ -1759,15 +1797,35 @@ func (s *APIKeyService) RevokeExternalAPIKeyFromEvent( } // Get API keys for this API - apiKeys, err := s.db.GetAPIKeysByAPI(apiId) - if err != nil { - if errors.Is(err, storage.ErrNotFound) { - logger.Warn("No API keys found for API, skipping revocation", + var apiKeys []*models.APIKey + if s.db != nil { + apiKeys, err = s.db.GetAPIKeysByAPI(apiId) + if err != nil { + if errors.Is(err, storage.ErrNotFound) { + logger.Warn("No API keys found for API, skipping revocation", + slog.String("api_id", apiId), + ) + return nil // Idempotent - no keys to revoke + } + return fmt.Errorf("failed to get API keys: %w", err) + } + } else { + // DB is optional; fall back to in-memory store for idempotent revocation handling + apiKeys, err = s.store.GetAPIKeysByAPI(apiId) + if err != nil { + // Treat errors as non-fatal; absence of keys is idempotent + logger.Warn("Failed to get API keys from in-memory store, skipping revocation", + slog.String("api_id", apiId), + slog.Any("error", err), + ) + return nil + } + if len(apiKeys) == 0 { + logger.Warn("No API keys found for API (in-memory), skipping revocation", slog.String("api_id", apiId), ) return nil // Idempotent - no keys to revoke } - return fmt.Errorf("failed to get API keys: %w", err) } // Find the key by name @@ -1792,17 +1850,26 @@ func (s *APIKeyService) RevokeExternalAPIKeyFromEvent( targetKey.UpdatedAt = time.Now() // Update in database - if err := s.db.UpdateAPIKey(targetKey); err != nil { - return fmt.Errorf("failed to update API key status: %w", err) + if s.db != nil { + if err := s.db.UpdateAPIKey(targetKey); err != nil { + return fmt.Errorf("failed to update API key status: %w", err) + } } - // Trigger xDS snapshot update to propagate to policy engine via xdsManager - if err := s.xdsManager.RevokeAPIKey(apiId, config.GetDisplayName(), config.GetVersion(), keyName, "external-revoke"); err != nil { - logger.Error("Failed to update xDS snapshot after API key revocation", - slog.String("api_id", apiId), - slog.Any("error", err), - ) - // Don't return error - key is already revoked in DB + // Remove from in-memory ConfigStore so cache isn't stale + if err := s.store.RemoveAPIKeyByID(apiId, targetKey.ID); err != nil && !errors.Is(err, storage.ErrNotFound) { + return fmt.Errorf("failed to remove API key from config store: %w", err) + } + + // Trigger xDS snapshot update to propagate to policy engine via xdsManager (optional; log only) + if s.xdsManager != nil { + if err := s.xdsManager.RevokeAPIKey(apiId, config.GetDisplayName(), config.GetVersion(), keyName, "external-revoke"); err != nil { + logger.Error("Failed to update xDS snapshot after API key revocation", + slog.String("api_id", apiId), + slog.Any("error", err), + ) + // Don't return error - key is already revoked in DB/store + } } logger.Info("Successfully revoked external API key", diff --git a/platform-api/src/internal/handler/api_key.go b/platform-api/src/internal/handler/api_key.go new file mode 100644 index 000000000..94faee085 --- /dev/null +++ b/platform-api/src/internal/handler/api_key.go @@ -0,0 +1,183 @@ +/* + * Copyright (c) 2026, WSO2 LLC. (http://www.wso2.org) All Rights Reserved. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + * + */ + +package handler + +import ( + "errors" + "log" + "net/http" + + "platform-api/src/internal/constants" + "platform-api/src/internal/dto" + "platform-api/src/internal/middleware" + "platform-api/src/internal/service" + "platform-api/src/internal/utils" + + "github.com/gin-gonic/gin" +) + +// APIKeyHandler handles API key operations for external services (Cloud APIM) +type APIKeyHandler struct { + apiKeyService *service.APIKeyService +} + +// NewAPIKeyHandler creates a new API key handler +func NewAPIKeyHandler(apiKeyService *service.APIKeyService) *APIKeyHandler { + return &APIKeyHandler{ + apiKeyService: apiKeyService, + } +} + +// CreateAPIKey handles POST /api/v1/apis/{apiId}/api-keys +// This endpoint allows Cloud APIM to inject external API keys to hybrid gateways +func (h *APIKeyHandler) CreateAPIKey(c *gin.Context) { + // Extract organization from JWT token + orgId, exists := middleware.GetOrganizationFromContext(c) + if !exists { + c.JSON(http.StatusUnauthorized, utils.NewErrorResponse(401, "Unauthorized", + "Organization claim not found in token")) + return + } + + // Extract API ID from path parameter + apiID := c.Param("apiId") + if apiID == "" { + c.JSON(http.StatusBadRequest, utils.NewErrorResponse(400, "Bad Request", + "API ID is required")) + return + } + + // Parse and validate request body + var req dto.CreateAPIKeyRequest + if err := c.ShouldBindJSON(&req); err != nil { + log.Printf("[WARN] Invalid API key creation request: orgId=%s apiId=%s error=%v", + orgId, apiID, err) + c.JSON(http.StatusBadRequest, utils.NewErrorResponse(400, "Bad Request", + "Invalid request body: "+err.Error())) + return + } + + // Validate request fields + if req.Name == "" { + c.JSON(http.StatusBadRequest, utils.NewErrorResponse(400, "Bad Request", + "API key name is required")) + return + } + + if req.ApiKey == "" { + c.JSON(http.StatusBadRequest, utils.NewErrorResponse(400, "Bad Request", + "API key value is required")) + return + } + + // Create the API key and broadcast to gateways + err := h.apiKeyService.CreateAPIKey(c.Request.Context(), apiID, orgId, &req) + if err != nil { + // Handle specific error cases + if errors.Is(err, constants.ErrAPINotFound) { + c.JSON(http.StatusNotFound, utils.NewErrorResponse(404, "Not Found", + "API not found")) + return + } + if errors.Is(err, constants.ErrGatewayUnavailable) { + c.JSON(http.StatusServiceUnavailable, utils.NewErrorResponse(503, "Service Unavailable", + "No gateway connections available for API")) + return + } + + log.Printf("[ERROR] Failed to create API key: apiId=%s orgId=%s keyName=%s error=%v", + apiID, orgId, req.Name, err) + c.JSON(http.StatusInternalServerError, utils.NewErrorResponse(500, "Internal Server Error", + "Failed to create API key")) + return + } + + log.Printf("[INFO] Successfully created API key: apiId=%s orgId=%s keyName=%s", + apiID, orgId, req.Name) + + // Return success response + c.JSON(http.StatusCreated, dto.CreateAPIKeyResponse{ + Status: "success", + Message: "API key registered and broadcasted to gateways successfully", + KeyId: req.Name, + }) +} + +// RevokeAPIKey handles DELETE /api/v1/apis/{apiId}/api-keys/{keyName} +// This endpoint allows Cloud APIM to revoke external API keys on hybrid gateways +func (h *APIKeyHandler) RevokeAPIKey(c *gin.Context) { + // Extract organization from JWT token + orgId, exists := middleware.GetOrganizationFromContext(c) + if !exists { + c.JSON(http.StatusUnauthorized, utils.NewErrorResponse(401, "Unauthorized", + "Organization claim not found in token")) + return + } + + // Extract API ID and key name from path parameters + apiID := c.Param("apiId") + if apiID == "" { + c.JSON(http.StatusBadRequest, utils.NewErrorResponse(400, "Bad Request", + "API ID is required")) + return + } + + keyName := c.Param("keyName") + if keyName == "" { + c.JSON(http.StatusBadRequest, utils.NewErrorResponse(400, "Bad Request", + "API key name is required")) + return + } + + // Revoke the API key and broadcast to gateways + err := h.apiKeyService.RevokeAPIKey(c.Request.Context(), apiID, orgId, keyName) + if err != nil { + // Handle specific error cases + if errors.Is(err, constants.ErrAPINotFound) { + c.JSON(http.StatusNotFound, utils.NewErrorResponse(404, "Not Found", + "API not found")) + return + } + if errors.Is(err, constants.ErrGatewayUnavailable) { + c.JSON(http.StatusServiceUnavailable, utils.NewErrorResponse(503, "Service Unavailable", + "No gateway connections available for API")) + return + } + + log.Printf("[ERROR] Failed to revoke API key: apiId=%s orgId=%s keyName=%s error=%v", + apiID, orgId, keyName, err) + c.JSON(http.StatusInternalServerError, utils.NewErrorResponse(500, "Internal Server Error", + "Failed to revoke API key")) + return + } + + log.Printf("[INFO] Successfully revoked API key: apiId=%s orgId=%s keyName=%s", + apiID, orgId, keyName) + + // Return success response (204 No Content) + c.Status(http.StatusNoContent) +} + +// RegisterRoutes registers API key routes with the router +func (h *APIKeyHandler) RegisterRoutes(r *gin.Engine) { + apiKeyGroup := r.Group("/api/v1/apis/:apiId/api-keys") + { + apiKeyGroup.POST("", h.CreateAPIKey) + apiKeyGroup.DELETE("/:keyName", h.RevokeAPIKey) + } +} diff --git a/platform-api/src/internal/handler/apikey_internal.go b/platform-api/src/internal/handler/apikey_internal.go deleted file mode 100644 index a685e9083..000000000 --- a/platform-api/src/internal/handler/apikey_internal.go +++ /dev/null @@ -1,211 +0,0 @@ -/* - * Copyright (c) 2026, WSO2 LLC. (http://www.wso2.org) All Rights Reserved. - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - * - */ - -package handler - -import ( - "errors" - "log" - "net/http" - - "platform-api/src/internal/constants" - "platform-api/src/internal/dto" - "platform-api/src/internal/service" - "platform-api/src/internal/utils" - - "github.com/gin-gonic/gin" -) - -// APIKeyInternalHandler handles internal API key operations for Cloud APIM integration -type APIKeyInternalHandler struct { - gatewayService *service.GatewayService - apiKeyService *service.APIKeyService -} - -// NewAPIKeyInternalHandler creates a new API key internal handler -func NewAPIKeyInternalHandler(gatewayService *service.GatewayService, apiKeyService *service.APIKeyService) *APIKeyInternalHandler { - return &APIKeyInternalHandler{ - gatewayService: gatewayService, - apiKeyService: apiKeyService, - } -} - -// CreateAPIKey handles POST /api/internal/v1/apis/{apiId}/api-keys -// This endpoint allows Cloud APIM to inject external API keys to hybrid gateways -func (h *APIKeyInternalHandler) CreateAPIKey(c *gin.Context) { - // Extract client IP for logging - clientIP := c.ClientIP() - - // Extract and validate API key from header - apiKey := c.GetHeader("api-key") - if apiKey == "" { - log.Printf("[WARN] Unauthorized API key creation attempt from IP: %s - Missing API key", clientIP) - c.JSON(http.StatusUnauthorized, utils.NewErrorResponse(401, "Unauthorized", - "API key is required. Provide 'api-key' header.")) - return - } - - // Authenticate gateway using API key - gateway, err := h.gatewayService.VerifyToken(apiKey) - if err != nil { - log.Printf("[WARN] API key creation authentication failed ip: %s - error=%v", clientIP, err) - c.JSON(http.StatusUnauthorized, utils.NewErrorResponse(401, "Unauthorized", - "Invalid or expired API key")) - return - } - - // Extract API ID from path parameter - apiID := c.Param("apiId") - if apiID == "" { - c.JSON(http.StatusBadRequest, utils.NewErrorResponse(400, "Bad Request", - "API ID is required")) - return - } - - // Parse and validate request body - var req dto.CreateAPIKeyRequest - if err := c.ShouldBindJSON(&req); err != nil { - log.Printf("[WARN] Invalid API key creation request from IP: %s - error=%v", clientIP, err) - c.JSON(http.StatusBadRequest, utils.NewErrorResponse(400, "Bad Request", - "Invalid request body: "+err.Error())) - return - } - - // Validate organization matches - orgID := gateway.OrganizationID - - // Create the API key - err = h.apiKeyService.CreateAPIKey(c.Request.Context(), apiID, orgID, &req) - if err != nil { - // Handle specific error cases - if errors.Is(err, constants.ErrAPINotFound) { - c.JSON(http.StatusNotFound, utils.NewErrorResponse(404, "Not Found", - "API not found")) - return - } - if errors.Is(err, constants.ErrGatewayUnavailable) { - c.JSON(http.StatusServiceUnavailable, utils.NewErrorResponse(503, "Service Unavailable", - "No gateway connections available for API")) - return - } - if errors.Is(err, constants.ErrAPIKeyHashingFailed) { - c.JSON(http.StatusInternalServerError, utils.NewErrorResponse(500, "Internal Server Error", - "Failed to process API key")) - return - } - - log.Printf("[ERROR] Failed to create API key: apiId=%s gatewayId=%s keyName=%s error=%v", - apiID, gateway.ID, req.Name, err) - c.JSON(http.StatusInternalServerError, utils.NewErrorResponse(500, "Internal Server Error", - "Failed to create API key")) - return - } - - log.Printf("[INFO] Successfully created API key: apiId=%s gatewayId=%s keyName=%s orgId=%s", - apiID, gateway.ID, req.Name, orgID) - - // Return success response - c.JSON(http.StatusCreated, dto.CreateAPIKeyResponse{ - Status: "success", - Message: "API key registered successfully", - KeyId: req.Name, // Using name as keyId for external reference - }) -} - -// RevokeAPIKey handles DELETE /api/internal/v1/apis/{apiId}/api-keys/{keyName} -// This endpoint allows Cloud APIM to revoke API keys from hybrid gateways -func (h *APIKeyInternalHandler) RevokeAPIKey(c *gin.Context) { - // Extract client IP for logging - clientIP := c.ClientIP() - - // Extract and validate API key from header - apiKey := c.GetHeader("api-key") - if apiKey == "" { - log.Printf("[WARN] Unauthorized API key revocation attempt from IP: %s - Missing API key", clientIP) - c.JSON(http.StatusUnauthorized, utils.NewErrorResponse(401, "Unauthorized", - "API key is required. Provide 'api-key' header.")) - return - } - - // Authenticate gateway using API key - gateway, err := h.gatewayService.VerifyToken(apiKey) - if err != nil { - log.Printf("[WARN] API key revocation authentication failed ip: %s - error=%v", clientIP, err) - c.JSON(http.StatusUnauthorized, utils.NewErrorResponse(401, "Unauthorized", - "Invalid or expired API key")) - return - } - - // Extract API ID from path parameter - apiID := c.Param("apiId") - if apiID == "" { - c.JSON(http.StatusBadRequest, utils.NewErrorResponse(400, "Bad Request", - "API ID is required")) - return - } - - // Extract key name from path parameter - keyName := c.Param("keyName") - if keyName == "" { - c.JSON(http.StatusBadRequest, utils.NewErrorResponse(400, "Bad Request", - "Key name is required")) - return - } - - // Validate organization matches - orgID := gateway.OrganizationID - - // Revoke the API key - err = h.apiKeyService.RevokeAPIKey(c.Request.Context(), apiID, orgID, keyName) - if err != nil { - // Handle specific error cases - if errors.Is(err, constants.ErrAPINotFound) { - c.JSON(http.StatusNotFound, utils.NewErrorResponse(404, "Not Found", - "API not found")) - return - } - if errors.Is(err, constants.ErrGatewayUnavailable) { - c.JSON(http.StatusServiceUnavailable, utils.NewErrorResponse(503, "Service Unavailable", - "No gateway connections available for API")) - return - } - - log.Printf("[ERROR] Failed to revoke API key: apiId=%s gatewayId=%s keyName=%s error=%v", - apiID, gateway.ID, keyName, err) - c.JSON(http.StatusInternalServerError, utils.NewErrorResponse(500, "Internal Server Error", - "Failed to revoke API key")) - return - } - - log.Printf("[INFO] Successfully revoked API key: apiId=%s gatewayId=%s keyName=%s orgId=%s", - apiID, gateway.ID, keyName, orgID) - - // Return success response - c.JSON(http.StatusOK, dto.RevokeAPIKeyResponse{ - Status: "success", - Message: "API key revoked successfully", - }) -} - -// RegisterRoutes registers the API key internal routes -func (h *APIKeyInternalHandler) RegisterRoutes(r *gin.Engine) { - apiKeyGroup := r.Group("/api/internal/v1/apis/:apiId/api-keys") - { - apiKeyGroup.POST("", h.CreateAPIKey) - apiKeyGroup.DELETE("/:keyName", h.RevokeAPIKey) - } -} diff --git a/platform-api/src/internal/server/server.go b/platform-api/src/internal/server/server.go index 0ce99a446..6f6fc2ce2 100644 --- a/platform-api/src/internal/server/server.go +++ b/platform-api/src/internal/server/server.go @@ -112,7 +112,7 @@ func StartPlatformAPIServer(cfg *config.Server) (*Server, error) { gatewayHandler := handler.NewGatewayHandler(gatewayService) wsHandler := handler.NewWebSocketHandler(wsManager, gatewayService, cfg.WebSocket.RateLimitPerMin) internalGatewayHandler := handler.NewGatewayInternalAPIHandler(gatewayService, internalGatewayService) - apiKeyInternalHandler := handler.NewAPIKeyInternalHandler(gatewayService, apiKeyService) + apiKeyHandler := handler.NewAPIKeyHandler(apiKeyService) gitHandler := handler.NewGitHandler(gitService) deploymentHandler := handler.NewDeploymentHandler(deploymentService) @@ -144,7 +144,7 @@ func StartPlatformAPIServer(cfg *config.Server) (*Server, error) { gatewayHandler.RegisterRoutes(router) wsHandler.RegisterRoutes(router) internalGatewayHandler.RegisterRoutes(router) - apiKeyInternalHandler.RegisterRoutes(router) + apiKeyHandler.RegisterRoutes(router) gitHandler.RegisterRoutes(router) deploymentHandler.RegisterRoutes(router) From 146fdbc5dcbcf379b1f9e152957d7d17f3b98703 Mon Sep 17 00:00:00 2001 From: Thushani Jayasekera Date: Thu, 29 Jan 2026 12:17:26 +0530 Subject: [PATCH 03/14] Fix documentation for retry logic --- platform-api/src/internal/service/gateway_events.go | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/platform-api/src/internal/service/gateway_events.go b/platform-api/src/internal/service/gateway_events.go index c533217b2..a12a32c51 100644 --- a/platform-api/src/internal/service/gateway_events.go +++ b/platform-api/src/internal/service/gateway_events.go @@ -213,7 +213,7 @@ func (s *GatewayEventsService) BroadcastUndeploymentEvent(gatewayID string, unde // - Looking up gateway connections by gateway ID // - Serializing event to JSON // - Broadcasting to all connections for the gateway (clustering support) -// - Retry logic for critical API key events (up to 3 attempts) +// - Current API key events are not retried // - Payload size validation // - Delivery statistics tracking func (s *GatewayEventsService) BroadcastAPIKeyCreatedEvent(gatewayID string, event *model.APIKeyCreatedEvent) error { @@ -254,7 +254,7 @@ func (s *GatewayEventsService) BroadcastAPIKeyCreatedEvent(gatewayID string, eve // - Looking up gateway connections by gateway ID // - Serializing event to JSON // - Broadcasting to all connections for the gateway (clustering support) -// - Retry logic for critical API key events (up to 3 attempts) +// - Current API key events are not retried // - Payload size validation // - Delivery statistics tracking func (s *GatewayEventsService) BroadcastAPIKeyRevokedEvent(gatewayID string, event *model.APIKeyRevokedEvent) error { From 0a1922915b34b0b0aa8f6bc00167cdac0437d8d1 Mon Sep 17 00:00:00 2001 From: Thushani Jayasekera Date: Thu, 29 Jan 2026 14:05:29 +0530 Subject: [PATCH 04/14] Move files to common folder entirely --- .../apikey}/api_key_hash_test.go | 2 +- .../pkg/api/generated/generated.go | 296 +++++++++--------- sdk/gateway/policy/v1alpha/api_key_compat.go | 74 ----- 3 files changed, 149 insertions(+), 223 deletions(-) rename {sdk/gateway/policy/v1alpha => common/apikey}/api_key_hash_test.go (99%) delete mode 100644 sdk/gateway/policy/v1alpha/api_key_compat.go diff --git a/sdk/gateway/policy/v1alpha/api_key_hash_test.go b/common/apikey/api_key_hash_test.go similarity index 99% rename from sdk/gateway/policy/v1alpha/api_key_hash_test.go rename to common/apikey/api_key_hash_test.go index 86f202a5e..cc38138c7 100644 --- a/sdk/gateway/policy/v1alpha/api_key_hash_test.go +++ b/common/apikey/api_key_hash_test.go @@ -16,7 +16,7 @@ * under the License. */ -package policyv1alpha +package apikey import ( "crypto/rand" diff --git a/gateway/gateway-controller/pkg/api/generated/generated.go b/gateway/gateway-controller/pkg/api/generated/generated.go index 88c7abd35..4fe4bb121 100644 --- a/gateway/gateway-controller/pkg/api/generated/generated.go +++ b/gateway/gateway-controller/pkg/api/generated/generated.go @@ -2981,154 +2981,154 @@ var swaggerSpec = []string{ "x8l79WEBUZ6+0my59UpIzIlyTpJy3J3M6tdKvSs9NUzJGfozQoxbXQa7Vvsi/4AeCDyISUfEIsny3EAv", "QtLymyVQSpaIzjEl3REZTkQ8eINd5LYBn2GWcthYSrsLMGEcQVdMvBZ6TKYAAoJuASWoOyIXWcYcIzCD", "bIZcMEYTGiLAOA3hFHWBec2BRLyFCYAkBko0R2TLxwT7kQ923wBnBkPocBSy7S64ZEhRJgaiaSfTZEhe", - "nOqiEdFDZ2ALdafdNpiE1AcDj0ZS659sd0d5h8cRj4Sc+kJQO3Ds7O69nsd//fjT203orC4YTsCY8hkw", - "X8pRuyBtCMAQZdYg84DDa8SEZXKQi4iDukVN92ZlpZ9SUzsO13jPWe/RZv/djN9a8B9ME1lmNB1kB/S6", - "l9CJCUdTFEqHiuAKKwnEI0t7Whsw5FDiChn1MdHA3YxGofivC2Pxn1uEruULlPAZKzh56pV6FSGJa6eD", - "t8m3YdOrEE2ubC76sRHNEE1QKNYZDI+KE27YOeVk+evwaFuKnxAOjDxX+E/UKATBZlKAZVshdITUBFEY", - "UIaYBG216OrAPxEjBjBngN4SIBZC0mYoCqFzjcl0gRS9/enHNwetBzWo1S6tdvukcq0JyIx2XRB1CR+5", - "LugUq5K6RouCzRD5EBNMpleagqs/I6oiq/wUnZkXE4aQLyZsItyy7Jy9tcnSsjFCltUTi2dGXm3LhPdS", - "O9UfUSz/bIRKp3NeRKWXGk67xSmH3kD4RBZ9Ip7pnQvjeAkrktNP5SmtZrozNM2wXYVNX9aYvKj/56b+", - "6xjkhjoLtFKtktFO7sbxrFWEXkj8kCO/dtfUusO5IDbeFISU3+2s2masgDKXgx+eD7CU2wMr7Ws1s6+X", - "kqxqFl5lBhsCunpGNisAzSZ693D/YC0Er90aiDmSGzio3l466YvNjWam9aTlDVnQdzG3bVUrCzoWDyVU", - "6HkgQzmYYA/lrOne3u7BW6uXsoydru2iocG2zZVFj1np+WyjhInIVlhLQVGWoF3bcGtR+wR927q8HB5t", - "p7snaW85p+DgoId+2u/1Omjv7bizv+vud+CPu286+/tv3hwc7O/3ej2ryGHGIhRatmsz86veAUefwZYg", - "Y4JDxiUhAE/AOCKuh/JQ3ODz309iMOi3v4j/fgmnkOC/pOy2B3+/PF8g+gXsR3ElkAiAkjkV5Jgvch1n", - "qI4Cj0IXuTIaOj86b6w2FocqVYvgxx1HbnN3HGhtmfL+hC+abpRxw8S/G066cgt3O3tvQO/NYe/Hw703", - "a+yJpMoAhSEN8+aqRlOwSIlX7Qj1S/fJUQvk/VIyR6V/nl3g0khOj086iDhU8NZv3YPe2yw/bLHtLhhA", - "IkwWh5gAP/I4Drwc07A8ntMR/3t3/H74GQyOzy6GPw8H/Ytj+euInAyHR79dDAb961+n/dvhu/50+M/+", - "x0+9y/c/+Gcf+X9O+r33g/M/358Px6+P/nX8bnB72T85vpwP/ur/89308y8j0u12R0S2dvz5yNJDcxnQ", - "2kmmhFkUUhec6DSxSL0InZAyVjQJhdEXhGaFjK/uVaNsj7zUyhHavNrBDBKCPAsHqwdgi9MAOzvoBhEO", - "VOLHNnDRBBOchEzQbO/LwRadez6jrg3P1WEjUG/IFIpuJrI5v3yXoXjRYhly5WqJxRJUZy1LiDzI8Q0C", - "nJq9YeGx51dH6n6rpC9OXiulrMl0QU4VpuuY6dT5akjqeOi6TBNUSHzbXjebzY7y69WwcoJKCYn8oH86", - "NFFOOdNDECUMv3JSgRv5AQiNP9G+n73+pQ1/YgeiCLvrJNnkJiXNuLlbNH8nmfbzc2ieSMGRE5qby/IU", - "vuQaPESuQXb9akE9iwboex4wAyjnnTTOpC0LoCWUKYZJZUpCNMWMoxC5OTPUmIpmIVW1PhQ0aF9UvhRn", - "rAVbTqsdJR9WRXWYcezY9lAj34dhDNJ3ABzTSCVgOFEYCmtWn8Er47O+dcFtMGppzRNGPaiO/tK5Xinc", - "XCrSrGWcmoAz30ll+6eVDFFs28oVy0azy4b0CbbcJBMla9sWZaOIEGQj+udYRB7VqkcGJqwqbwm54AZ6", - "2FUelX63oaz9knwoSbCJWmW8+gFPZ9pzkZ2C7ONcSJPDtDK0amvQENBS4dlG8NzjOQ+hTA9Is39swF72", - "WX7w/zz/8vkUqj3vEDGVRB+CGYIuCpUnyqnxQWPJWZxeI71HkJuev3UjQWgXkyDiF+IlKxt7Gksv0/Lr", - "DIWyuwkmbqarDIqQ8a11gm6r3VLEttqtPyMUxqcwhDrTfKb+zlnp9LP6+U/IbGfnz7YInz6d9KXQDijh", - "IfVsm0cOCiryh/TkmxeUs51kCzmqSeBTFzWVhTMacXRsWrSKgmitbPSsXSYZO55Hb6+g50lHiMTyz4L/", - "o39dmMQvWq6YSR0KlKaQlPYDxpE7RdzMuS3cgXzWHIZN+hYLYpu0SgC+AoK3RC5p7r+irXYOJB2WfSYR", - "/OSHZZbo/fFFq906/XIu/3Mp/v/o+NPxxbH4Z/9i8KHVbn05vRh++Xzearc+HPePWu3WK2uAWnKVhCAp", - "99F1scLzTjOEqaS8smoB53J6tUodYzKV3C2bQxyFTDJ6wJELxrGKMpVp7QKZKYE5Q95EppqCXHvUiXxE", - "ZOhbmsJAz1xmF8uZQS5X3UPGWtevmGyjnUx3MgNVS6ZygsK6U4mwqCQWsGNeqdy188caJzDyhIXeabXv", - "+5AjDRCBeMkzjluVhxy3/7H2McdPn06AmXKghSsl+NfzL3vgS4BIf5i8dS8nE9cFVJI8xc1DKqkqtUgz", - "R37gWZHSC/0ksfwRM8AhZrlpz814wiGWwDc9jgg9r8HJnsyJwmYv9iOhsL+uck6wckCrHhgsd/1L5vic", - "mtUkz0uIJCbTLjiPgoCGnAm5JC4MXaDP2Yn3WRuwaKxPGLYFe9xiz3XSt5hGcSdUmGhw9vOgIzUdhoTL", - "bmWvYeQh1gW/6m+ZTGWU3KgP9ZqdMA9NeMcX1HpwjDyTzfYqe5CvlJoJA9y1qomD1zWCtjUavRqNuv8/", - "FbivW/84zInf12+99pvdu8wb2/8YjbrbP+hfvn7ba98tRpKrTgQmkpA7EpjX1I1U/kqnAxMV9hyOCCbE", - "6sNshrZPnp+IUI6A7IMNnxFcpPnK1rj2pJ4eUebAXuVJvWzra53Y2+3sHax8Yq+J5rWmZgiFF5iFfLBj", - "dplJW3TczhC35pm7SvlMgGPhPV7dE9pbyqRhdK9Tt1KrHdJbkYUWAOdJqwdLtVqPcK9E6gOdpsvwSk1y", - "3L2sRFWuW4UH21AbdIKaT+6J5bMuZTO/0N38fK6ZI5dhhIvMcJrb88R7fg72PCG22p4ns1Bl1y9S9+kx", - "7Lvp/r4svGn/Ec/mb8bSG+l8FJOfWyWLWTegjIaMFyy/FfJeAT7IRbq5WEbp3YVxTBkNCKkf8PVGkRwy", - "WbcZmSZ1Ql3krd6GYve1GpHbauuMpSaOayi8izzOZXfhKi3Dqg5qokSXFPoHrezwSMUSGqqWVf22TZ9R", - "SFTHGtr8gVJFLLO47AGBjVik5300IDOLSx+vSIPCWod98Vw+yhQqF31jUziPX7ZOHmzrJBDz/fB1IgXD", - "O5R0IGOYcUj4Mljxd78pk0W+qgpD0QmAaZqe5/kgsO1eNFUtK+2XSOZ52Sz5H7hZEmRg/gVqfNXtkHn8", - "XPZC5rEdOJnHNrRkHj88RJIzqZtFR+bxk9j8sFqUpRyoefzQkMg8brAFMo83El4WpfHxNj9c6rBFq/Sy", - "CfLomyDz+EntgAwoAf06pllNITyCp/Uox0oecxdlHq8UD6+rzJ93KHwyOF1kHHwnWNM0nAxO7aahWU3a", - "k8FpTU1a3wk6ciU6uxuvSbvb2du/F02//1zOia00Aw9kQBRb+YGtHmI4lcm6rKYioqdj8+RdyXDqmLE+", - "N56NsgsljbJtlg+XJP8yYazpZLVaAnVfpy625XADn6Ew1wLADCRfJK2NKfUQVIn7mHuoZtZmeWxHvr6Y", - "TFtyus3XLyIUtdNcRVP+MM1yx9YzR5+N+lGbbPbjUUvNFcmsqGpU9qEdkqT23uqzl9ezS15UI7SsCjL1", - "K0AnpGewxUTXrgsJpp2Z0Zf2RDN6/ZEyqVMi7berrH9ziuKC5idGUo1naSxEqo7sUs2d6Y+sJ0UD5FQi", - "J2JyKnGTvBHpvens/nTRExZEGxFL7SDqLUX3BVUoe91dNPeb/F0E8i9mCIyhc42IKzmHofAGhSAKValN", - "GPFZ8bRrHR6aMp9tXqscnf/RIKfvBLuFu1+eEdSZcO5izb4S1Jky1HOAO1NqC3fEnDhBvmfxwwMCnRYb", - "uymgM2l6LaBzr7O7t9l7WWaQuB4CW2YMXSHL26W7WsSSBcaBqAjWGPVRR1j2par7ZVp+KOTUrMWytStr", - "PaT7CTar8Ky8A7UQyGoSRxc8guXt/POJYdcGpQwHNQOlnrrgPQrKpVTZZlCuxNFdLpRPYrkFQaWPfXQh", - "f6xs4WR4cmysWcOgVPiU2ajRuPjWmcd/1fUuHgvvShZFaVnrkawezRq6Gsaz7VYU4mVC8OpxF2suh7iu", - "bJsJHJbjgQ+V8IIY/yQijpohzK0iIYtTqEPk9mIY6Yn1iSrijuYBcoS0pYfWNwFkiCDLetdMxGsoTNa/", - "nlTVCGA8jBwehWjDeImg3cpd3aaVEPICnF0UK6dUIsa1s641toVvlyly3BTvSS+RrSjaYOHli4tTXTow", - "41Q3KeOgizeYag4526y+t5bDsFxWQCOuU95kTltyI8U3ye53IPCgg2bUcxXfZ7wr66VFrWIyW/fVc8vS", - "aliNMFk3ObE2jqiqZYLmyIkE7QNKVDENa2V/U45HF2SROYaO+QJ6IGkmgTFVf9lF0gcBusZp+R1GfCZ0", - "kSMs/Vfwv/4OeBih1YBwS3/qxoi0GpOdEZevJdIPx5iHMIyzpUMSTFgV49qahAh1hFcCrlG8oy4xSFTg", - "dmsTFwtbhpyUgKnIf6tOg6svElPNUZmScivYTlBlO9Px/RczGr2Uudpb1vTpxSkhyupqhRyn1OfI2vlI", - "2ePnUZeGxYwj/3SzZEMSA8kN0NP7ZGUR2Qj1K8pKpkbeg4hNuzXx6C2rEZ8FNfxN9fj6eoFZ+7XRapBr", - "ASWF0l911aMqPZFczb3mPkmzKlI2F0RWQDNc0N5k1SYbD1xW3o9vniQbB/k7LLYYJlMPAQ7DKeLC10gu", - "ARO2hRKk9zfyIZDX+nrXzv8ouOTr3ddisc4ZFXx5G2JTmMecU4CRvDSyUCxaeQUMzOitFLcPlHFTtw8z", - "7fy6qiCk3lcwxeUMut0Ff4i2/wAu8tBUXocgNyVCSYX+4Jjc0LgNbmfYmekniJV6jJjRoaZx4HgR4yiU", - "TXbBHz4kEfT+EDGDsD4MiK59KBRH2p++bg85nIn/Chkr1DjV+wymKp2aGtW2lQelSJav6DLXt3EKIAhC", - "JLUUchPqj7JayxIxW6qNH+EQOTzhnsuzT6J1eXjEXJJfvP1zxnlwuLMThNTt6O8OD3q93g4M8M7NXq4K", - "dIib6YDczlh5U8L6q66gePitSoZTTZDe6jZGMMwlg2fQOlkcstxcQVzl02Y3MRULfJaGIO/UKy/Kz/Kq", - "PXlv8aRYYzSPyQXI6RoYeZnrLgqAhCohar3qQu/kl44RSa53ICGUA8E56tdmq1242b5szlTN9po4y7wB", - "OvqK+/T21K3zy3cK4fwVjc+jsSyP37gIs67KL/E4MlSf7FqqQVflQGz8MFZ6ldUyp7EqD2OtfRRLDOfB", - "D2HlbtF6jESOcmSeRPhCGXte9hrliHjyKhkV4ruIrJ/msbQf2z8dbuS4k2VOBjK/DdxkMgLYjt7Dz17k", - "V76RAtquA7Q0p1uTjQkzEznqnkJlj0rHkvIb9hUM0B2NOhXLzyBxx3S+NGn6Oytd+llnffqKCI6YRKv5", - "aZIikNqKRMda2pKQ84SaPUqoLtpREbNKchda4FRv0oMLlftSdFeOzy/ke2KqfEjg1Nwums9hMQkb5Xbf", - "64yAEVHXyOp/A+1FeijUAS+raPb/9k8+CZ9XhovKKVGaJibQxw70vHhEzGfaP5TRSAi2pAepEgi2wQ2G", - "YH50LpiRU4d6mVyXSeR5YHB2eQQ8PEFO7HhoRMwVEwWSpM4PEfTkxpPeEUuqJ6ue5WhfvfqIYvAzglzQ", - "dfjq1Yh0wHk09jFvMFTx8lnSS6bkt3RUpakPkaAek6l4998opB2X3hL5vu0SPCZeOxVcxLi6jkbeuK0G", - "dP6vT5gj8ca/IhTGdTcyqFuAFIbfsiyn0hqJsmupcFtdZk+E9j9sve72uq9bmULJO+ZuiCniNqeZhxjd", - "IADTbN36WyPUoJIN0xE5QzwKCQNjyLCTreutbzZA0JnJdraEhLSNJm6bLMg2SM8vybtWddpRYjGGrrY0", - "2lPJYkS/l31DT/DnOJZdVuY+/qpMpp5RoXdVoXGzt3VYUBEs2bspqZ96Cmpyy2y9pq+v3KOe1p1ycmnq", - "K9m6TnXfSl1nFjEpc5+p0J4kIdi6Tj5Ie26YvVAk8Gta40Yy/V6vl6SSKAhI+iUqM2vnP0z5DGm39mtV", - "ml6UnOSy2C5IKWFQB5u4I9pimypdNEt25MGS81Nb7Cd3UYSFlKG5zVxnbqpLE+7kfXXyWhRDrokLSneX", - "cDgVQi8vfDoRNhPJbPiv0km15WVqowABQbflJo1pKavaLriYFXT9iGBmrAVy2yDQ+t5V7jkPIWGeTOEw", - "AIu0iUnSsGpSWbERGaOp8AcTEEdjCdmLnoWixQQcAH2XsjZ9adQMziIvMX9J4PFD4us61B9joi8zy90J", - "Jz6oCl3/2PlDDigXuf6x84fshAMPQcFQJAMKibfFD+n+nfG1xDc/S1fQT0OjRPMnRDmUGNOpL6vTQ2AW", - "S6CS95Ti1lth76gbN2DjRKK+6ZzI1hlivC+jJpOZmESurZ0AcWHIUaqnThE/F79os5HGNNIQmR1hDawq", - "YFQ2s/MtQHzoyo3UJJb63b5puMT2nqbKsj+XbscZFKj1W0cEaR8lyuND4kJOhcCJprIBlMHw7yTCmQxJ", - "o8SZMbXyzy9rHye4sm1Scm/m507O/w4mN4hIcutoUu/SUAxXArHPZabbSezAEP8gW2G5ljWd5pEYz28d", - "fWtm51wnXaWBh1KMahKMMSx+q36t/djOFWkatWpMoqYJ5qklpstu4XSKwi6mOzd78qt8U7mYvWlq8127", - "oSEqX9h3184phBj6XnO7ZmkuF07qhS34Hbsbs6ui/3zCssW2lm2bNZP3rt3af1iTLy1mcduldM/StqLs", - "7cNRJpbUww4HncTYKjMljagwacaMQi9E0I0BmmPGn6bXpPijys2pc5zu2ipC3PmG3TvlP3nIdovEGfKp", - "iBOJxY2ahNSvdaQ0asA4DdiIKFSi7PZgZvd7wJB0Jh6ezjjQqpABvYOIRiTL3/9HTkDyUogchG8Q2O/t", - "g8+Ug59pRFxbdHkkB61Bubrw0iRARGMvf41riisK909NYvH4jCUvTUZDOlLTRkBeDJrXLnUh2WYjntIB", - "/oWpdBWJzWUmUXNy7ycLmqhGKylSAe0/nFyXyRIe90Sw6JPUMUpGrAqgPjKrB57UvYZKmItqhYYAJoep", - "ZLfjGGDOABZSLBQLdlXGi77qWrFF7uJ+MakQyOv7bZL/HvH+6fBdPHRXFv1MzPYsJH6Br1GoVrG271Rq", - "r5GAim/Yi0wukMn3yAJ4SyFxF6AlEbcly7hQSbh0daz7EcqeK9gbZAERdQZXb/pBTvUGgpZT7QBog8x0", - "Uon6ME98E+s/IonGkH6baI16hZYKvkDEUHWvGloZ+gENOST88NUrMJwUrwNlbdlCMjl5wlWZbwagw/EN", - "sukaNb+b8zLUUB5O5yyDtTyjSG2j2rNwGK2RjrEe/XrikdqLUq5Uyk3UaOOQbEfnZjXZwvM8rXxkf+Kj", - "xDfRTpTZ0dPQwFhd/hsxEacNJ8k/pE9FAHR9TNog1B3I7BMRvdm7SNwf68bdRzGEzai9MOs6GhK+A8fr", - "I8qnMS/YzJFM8SKLix0kWe6iMHEqtiAayF96W6mEt1yj2C5reiNJC5x4zYEEjNGIyAIh4xg4Hpa1sTgF", - "Wfw59Vz0JpXoBqsRfERxxjcZEZ2Vj5OsK9Wr6E1GR6/3OuNYNAmJS311zTdAxKGuKggyQ3PoIgf7UMg3", - "cUEQogmeI735M2rBAAdXo5Ycokcd6MlJlGVKdK2lG+zq8Yl30FwvjXitW7eJo2DyTagFcxm+UQsPqhXu", - "xTX6iFSlCkyJRvvX94+sbT40nP0Rxe/Vakky6tWNlJlniGW/aOXmcLXRG4s1st052vmmtuY+Qx8tQLFv", - "6DVSxwNuMI2YFyeKw12kyb8QR6hl0YLbHhGjZ4RCJxR4lExRKPfYmc57talzmzZUVG1UGyoyH1oXtuuO", - "lprZTairoUidRrAQlK7zE3PdxBo6jRWa5qIXhfZ9KDSjVohh8jVV2E6IjFaSezRWJ/QM5VyeKb5BpIkz", - "StBtTus9c6fUrlDN8L4LF7O5WjUUPrRqvS//N1nIDfvA9nYfGixc2g8OKX/xg78ns5FolOW9YAeFXBWZ", - "RA3BQVV5G1x8OgfZj4EThSEi3IuBR6Gb1gjNvARUXpfczGEo/zkMM9VOb1CIJzEmU/Dh4uL0PHNymBKC", - "5EElVgUTDrIjukfJy/TTFHDLTfaTzpvWi+zk59JwUmbozZCuy0DwhA7V7AxkfIEyuyjsK/15REzGLybg", - "9PhEHzrqgv6EyzsPRV9te2PSaTCny9XRpBBpfhXewfnROdg6R06IODjCzKE3KIzBOQpvsIO2xddmm4VT", - "EERM7RoSdDsihbGo3O0gpHOMTNL1kToSBRS2f/jqFRjMIJkiBji8RgBNJsjhAPs+cjHkyIulk0IjDkIk", - "c6vNSfppcmjLsj0ohpNZoXUSnDNjah22OuJ/747fDz+DwfHZxfDn4aB/cSx/HZGT4fDot4vBoH/967R/", - "O3zXnw7/2f/4qXf5/gf/7CP/z0m/935w/uf78+H49dG/jt8Nbi/7J8eX88Ff/X++m37+ZUS63e6IyNaO", - "Px9Zekh9DD/uKCbqOLB5QmdmTtQkPRJ0laGjNrUww0+Kp5+Myc5QpqsQPM0dtKzSyQnEQkVWNI07SktU", - "h1Enss6EFwMe4ukUhQAC9Yk5DJczdkmq4wR7SBX/kepHKZcROT86NwV5ZM7BJPJEgBTT6L9uEPBNX9AV", - "PJFbDtGeVqU5lcSAK4tQ0DDWyugzVSpIdoOIG1Cs7p3gcaB0o/R7CEJSOTLNhEwd7kS68MqI5NRpMnw1", - "+AqcqqCh1jbTxVI2lkzCbHegpPLvqWyqvOP8SpXyLBcNFQ9VnU/DI5qqgtVNyw3t7R68fVs+79UkfdE+", - "/qI6eXIynIiVFqZlHZKSHC9KUDb5iRYPCIxjMDwyboaRgApHQ57zyotGievy3kSoUqOLrQlVMSKP5U2o", - "6ch7E7UYyPDIIAoFf0hPeBZPODjooZ/2e70O2ns77uzvuvsd+OPum87+/ps3Bwf7+71e7zlkNzccRrOM", - "5ywnmwTje9VSSyqPp5H1nCXoeeQ7r+SASBDiyo38YHFons+AVrG4PEOdQHyWUgCYOF7kYjI9lOcy68/s", - "m1e0FisV8UteCNEUM47CgimT5RWUr6M4VDK2OTan6k8UXBHt+siCwmgcTaeYTNvApwRzGsq/RRNj6FxH", - "QVpq2J6grW+CEJN5n6hA0kv9sSFrqrpc6SfJxZEfJEyVp1myWIaj1QprDp4h6KmqYlXMK4s+CO5UrxrO", - "qGTZ0sp+kN8NZsi53qwbadOiisjYXjTbF1ZVieqKt++V1sYmsgwYKvJrpCYCOGImEiGqWhjP85NbSTsc", - "+YHXEADMFfjQt2vKVkDSShUwp67/lC9fJD02LsRhmq+uxvElQKS/biGOzboK5XINr9cu19Bu5darUVUJ", - "y9RXV5lYph6EnQOeNrZZQXMqKeIFM10rFIiwtm87/5CytCwxyUaE02skS3A516bmpU9d5AE0Fz/qEwKq", - "PkSmeE5NQQez3OpAarl+w4XsMd2rZOqdiOmaRrLWkazDipK649X1FCx81rqfjT1bT+tt6dlbfFBk0ELC", - "4jPbFez2cm674bntREKKh7ef2YFtKx800mrVDsES57ntbFh3prsSbrBrkbVSLxKC7EiEuqf9GWANCaFL", - "XX9eWJRHO0O9BDkPjSjYSXsuZ6lXl/3NHayuoqEUiFvEeyMnp6sIeJJivqQbsNHj1E3abyy7j3PE+jmK", - "63vEK6xk8ah1bQDS8Mx1kyik8nTxPVtgBWTfq2h+1xHHvaqaxWeP7az1cv74u9NYTdXKSlHGWmgjW4Qw", - "LoEs5oa0AF1MxnZ/9X5z5Dxs4d9c19UVgPO6+nssANx4eeQ9/sVS+Wp+dCV66zLpzx4PhrbfXJWVzGVx", - "5bqqxfdWjjivEp4N6rwe2HwkOdiG+lgw5lSxKYxZXQpFgVj2EDp8RCTspWNIphJd2+nGcJp6bbaUWDt7", - "mEdmwEC5lOY6yrY+aKMrp3aboMX3jxJvskJMTbP1dj/vltgvT2g9EuK8JNJsAGaVDaiTBl7Q5kVocyLq", - "31Gp0CxfrOYKrooz18DLi7HlhhFtOZQtjDdzQI7RvY6y/p2g4Cg+5fKcdrJXgJifBrL89ADl54gjPyJ8", - "vAA1XgIt/h6Et6H9vi+IeElo+Ekgws8MCJb4r2HUDeDAvHjriIRQKlCcxRDwc5O17zKOuNTwanU80Xok", - "4HhJwPjp4sQvOmtlKHhZt3+OV842lZ9Wo7/68XLY7zwGeeT28XDfefw4oO88fkF8Fy7M9wb3GjFcAuyd", - "x4+J9EqCnwPOq9XQZlFeJaMWiHceN8N3zasartMVOvT5vyzqW0J4lwB05/G9ornzePMuWLnNKltdXIKn", - "A+LO48YI7jx+gW9Xhm/n8XeI3c7jVTy45WHbebwyZjuP141DZQuFINSlDutAxjDjUB6XehZgbYnqpbBa", - "aQIeF6itIuGRIrB5/Nwg2oYCu3F8VvZbAc7O400gs89ESpsY5HuAZC2N1srYo4KxT16sMkjsPBaxXlRk", - "zgdEYy2ilYVin5f9+868/wL6WowCHgF6nceNcdd5/AK6Pj/dVI24LuGs+06wKtyaRIUng1Md9+Trgagw", - "KHMM2ZRzGEOGHYCJCoVlVDSmEQcIOrNMa1vqGncdOSX3ubeziCDHPtquKihwMjhdGvAV3ecA33Km70mc", - "Enl/cG9KyMPCvWm/1XAvukFhzGfVuOt3AfneN+h6YANdfSe4WhZ41Xz+OMBrlfQ/bRS2kupUcaavLF/i", - "oaJ5U8O26gLr3MuyQlxy8WUbBEKumfwTEhfwEBLmmeJwqv7b/OgchIjJ6/ZZ9k7sERmjKSYMhDSyXImN", - "MgSXbsasRHMN290Tmmua36Q/V9Xmg1ZxSIhYCMdWsdFL8YameGyer78TTLaCLRbrroLHtwQ8W8WJm7uT", - "v0YDPdTN/BmFttZR1nQolZf0px5URyzIM7mn3051M2y5ioMeDWleiqCHDkKriHsuKPTKKmpzgHTawX1c", - "5290xVqVKYr64rloiQXezUZhbVt7S8jy4+Dbz1N83yNeaeiLJSiqo6OG9ScqOnq5+X/dm//vxYtRo3pQ", - "/fTdR5S9jQ9sMeJfJd4vxTm+Q33eXOs2Cx1Ngl/Te8AK932VC4Kn4aOTLft3Wn4Rhsg0I79R95jo/ERD", - "V3qFCYBc6PCkKLK8ziAKpA2R1y/I3EYf+eq6E+vuwakZ7T0Krhpp0+vByhP4tEHWTJ13C+kpy+n1zvOb", - "aFL2YbNdn+Tt8C66QR4N5BftVhR6rcPWjPPgcGdHXh8/o4wfvu297bXKmw1H1LlG4c7HaIxCguT9N8mm", - "Q7Exnf/aSRlKt/o1GUMJDVZ17HXRcsV2snB5UiUhNY668HaZxsHZ5RFIGJPJAKdcdj9tqHCBX7MGa5Bw", - "3axVI5QbP5OrnWYwhChicOwh+9rrtstLX25YPay8V1AMonALoLweUEtA2lfFXQp3X+/+OwAA///VvGhG", - "MDABAA==", + "nOqiEdFDZ2ALdafdNpiE1AcDj0ZS659sd0d5h8cRj4Sc+kJQO3Ds7O69nsd//fjT21YeaHqzARXWBcMJ", + "GFM+A+ZLOQkuSBsCMESZJck84PAaMWGoHOQi4qBuUfG9WdkGpNTUjsM1znTWmbS5A27GjS24E6aJLG+a", + "DrIDet1L6MSEoykKpX9FcIXRBOKRpT2tHBhyKHGZWlON481oFIr/ujAW/7lF6Fq+QAmfsYLPp16p1xiS", + "uHY6eJu4G669CtHkyuaxHxtJDdEEhWKdwfCoOOGGu1PGlr8Oj7alNApZwchzhTtFjX4QbCblWbYVQkcI", + "URCFAWWISQxXS7LGARKpYgBzBugtAWIhJG2GohA615hMFwjV259+fHPQelD7Wu3hai9Q6tqa+Mwo2wVB", + "mHCZ62JQsSqpp7Qo9gyRDzHBZHqlKbj6M6Iq0MpP0Zl5MWEI+WLCJsJLy87ZW5ssLRsyZFk9MYBm5NWm", + "TTgztVP9EcXyz0YgdTrnRZB6qeG0W5xy6A2Ei2TRJ+KZ3sgwfpgwKjn9VJ7SaqY7Q9MM21WY+GWNyYv6", + "f27qv45BbqizQCvVKhnt824c3lpF6IXEDznyazdRrRueC0LlTSFK+c3Pql3HCmRzOTTi+eBMuS2x0jZX", + "M/t6KcmqZuFVZrAhvqtnZLMC0Gyidw/3D9YC9NqtgZgjuZ+D6u2lk77Y3GhmWk9a3pAFfRdz2861sqBj", + "8VAih54HMpSDCfZQzpru7e0evLV6KcvY6douGhps21xZ9JiVns82SpgIdIW1FBRlCdq1DbcWxE/AuK3L", + "y+HRdrqZkvaWcwoODnrop/1er4P23o47+7vufgf+uPums7//5s3Bwf5+r9ezihxmLEKhZfc2M7/qHXD0", + "GWwJMiY4ZFwSAvAEjCPieiiPzA0+//0kBoN++4v475dwCgn+S8pue/D3y/MFol+AghRXAgkIKJlTQY75", + "Itdxhuoo8Ch0kSujofOj88ZqY3GoUrUIftxx5K53x4HWlinvT/ii6UYZN0z8u+GkK7dwt7P3BvTeHPZ+", + "PNx7s8YWSaoMUBjSMG+uajQFi5R41Y5Qv3SfHLVA3i8lc1T659kFLo3k9Pikg4hDBW/91j3ovc3ywxbb", + "7oIBJMJkcYgJ8COP48DLMQ3L4zkd8b93x++Hn8Hg+Oxi+PNw0L84lr+OyMlwePTbxWDQv/512r8dvutP", + "h//sf/zUu3z/g3/2kf/npN97Pzj/8/35cPz66F/H7wa3l/2T48v54K/+P99NP/8yIt1ud0Rka8efjyw9", + "NJcBrZ1khphFIXXBic4ai9SL0AkpY0WTUBh9QWhWSADrXjVK/shLrRyhzasdzCAhyLNwsHoAtjgNsLOD", + "bhDhQOWBbAMXTTDBScgEzW6/HGzRuecz6trgXR02AvWGzKjoZiKb88t3GYoXLZYhV66WWCxBddayhMiD", + "HN8gwKnZKhYee351pO63SvriXLZSBpvMHuRUQbyOmU6dvoakjoeuyzRBhTy47XWT2+ygv14NKyeoDJHI", + "D/qnQxPllBM/BFHC8CsnFbiRH4DQ+BPt+9n6X9rwJ3YgirC7Ts5NblLSBJy7RfN3kmk/P4fmiRQcOaG5", + "uSxP4UvqwUOkHmTXrxbUs2iAvucBM4ByGkrjxNqyAFpCmWKYVKYkRFPMOAqRmzNDjaloFlJV60NBg/ZF", + "5Utxxlqw5bTaUfJhVVSHGceObUs18n0YxiB9B8AxjVQ+hhOFobBm9Qm9Mj7rWxfcBqOW1jxh1IPq6C+d", + "65XCzaUizVrGqQk4851Utn9ayRDFtq1csWw0u2xIn2DLTRJTsrZtUXKKCEE2on+OReRRrXpkYMKq0piQ", + "C26gh13lUel3G8raL8mHkgSbqFXGqx/wdKY9F9kpyD7OhTQ5TCtDq7YGDQEtFZ5tBM89nvMQymyBNBnI", + "Buxln+UH/8/zL59PodrzDhFTOfUhmCHoolB5opwaHzSWnMXpNdJ7BLnp+Vs3EoR2MQkifiFesrKxp7H0", + "Mi2/zlAou5tg4ma6yqAIGd9a5+u22i1FbKvd+jNCYXwKQ6gTz2fq75yVTj+rn/+EzHZ2/myL8OnTSV8K", + "7YASHlLPtnnkoKAinUhPvnlBOdtJ8pCjmgQ+dVFTWTijEUfHpkWrKIjWykbP2mWSwON59PYKep50hEgs", + "/yz4P/rXhTn9ouWKmdShQGkKSWk/YBy5U8TNnNvCHchnzWHYpG+xILZJqwTgKyB4S+SSHgVQtNXOgaTD", + "ss8kgp/8sMwSvT++aLVbp1/O5X8uxf8fHX86vjgW/+xfDD602q0vpxfDL5/PW+3Wh+P+UavdemUNUEuu", + "khAk5T66LlZ43mmGMJWjV1Yt4FxOr1apY0ymkrtlc4ijkElGDzhywThWUaYyrV0gMyUwZ8ibyMxTkGuP", + "OpGPiAx9S1MY6JnL7GI5M8jlqnvIWOv6FZNttJPpTmagaslUTlBYd0gRFpXEAnbMK5W7dv6U4wRGnrDQ", + "O632fZ95pAEiEC955HGr8szj9j/WPvX46dMJMFMOtHClBP96/mUPfAkQ6Q+Tt+7loOK6gEqStrh5SCVV", + "pRZp5sgPPCtSeqGfJJY/YgY4xCw37bkZTzjEEvimpxOh5zU46JM5YNjsxX4kFPbXVY4NVg5o1fOD5a5/", + "yZymU7Oa5HkJkcRk2gXnURDQkDMhl8SFoQv0sTvxPmsDFo31gcO2YI9b7LlO+hbTKO6EChMNzn4edKSm", + "w5Bw2a3sNYw8xLrgV/0tk6mMkhv1GV+zE+ahCe/4gloPjpFnstleZc/1lTI1YYC7VjVx8LpG0LZGo1ej", + "Uff/pwL3desfhznx+/qt136ze5d5Y/sfo1F3+wf9y9dve+27xUhy1QHBRBJyJwTzmrqRyl/psGCiwp7D", + "icGEWH22zdD2yfMTEcoRkH2w4SODizRf2RrXHtzTI8qc36s8uJdtfa0DfLudvYOVD/A10bzW1Ayh8AKz", + "kA926i4zaYtO3xni1jyCVymfCXAsvMere0J7S5k0jO516lZqtTN7K7LQAuA8afVgqVbrEe6VSH2gw3UZ", + "XqlJjruXlajKdavwYBtqg05Q88k9sXzWpWzmF7qbn881c+QyjHCRGU5ze554z8/BnifEVtvzZBaq7PpF", + "6j49hn033d+XhTftP+JR/c1YeiOdj2Lyc6tkMesGlNGQ8YLlt0LeK8AHuUg3F8sovbswjimjASH1A77e", + "KJJDJus2I9OkTqiLvNXbUOy+ViNyW22dsdTEcQ2Fd5HHuewuXKVlWNVBTZTokkL/oIUeHql2QkPVsqrf", + "tukzConqWEObP1CqiGUWlz0gsBGL9LyPBmRmcenjFWlQWOuwL57LR5lC5aJvbArn8cvWyYNtnQRivh++", + "bKRgeIeSDmQMMw4JXwYr/u43ZbLIV1WdKDoBME3T8zwfBLbdi6aqZaX9Esk8L5sl/wM3S4IMzL9Aja+6", + "HTKPn8teyDy2Ayfz2IaWzOOHh0hyJnWz6Mg8fhKbH1aLspQDNY8fGhKZxw22QObxRsLLojQ+3uaHSx22", + "aJVeNkEefRNkHj+pHZABJaBfxzSrKYRH8LQe5VjJY+6izOOV4uF1lfnzDoVPBqeLjIPvBGuahpPBqd00", + "NCtRezI4rSlR6ztBR65EZ3fjJWp3O3v796Lp95/LObGVZuCBDIhiKz+wlUcMpzJZl9UUSPR0bJ68KxlO", + "HTPW58azUXahpFG2zfLhkuRfJow1naxWS6Du69TFthxu4DMU5loAmIHki6S1MaUegipxH3MP1czaLI/t", + "yNcXk2lLTrf5+kWEonaaq2jKH6ZZ7th65uizUT9qk81+PGqpuSKZFVWNyj60Q5LU3lt99vJ6dsl7a4SW", + "VUGmfgXohPQMtpjo2nUhwbQzM/rSnmhGrz9SJnVKpP2ylfUvUlFc0PzESKrxLI2FSJWVXaq5M/2R9aRo", + "gJxK5ERMTiVukjcivTed3Z8uesKCaCNiqR1EvaXovqAKZa+7muZ+k7+LQP7FDIExdK4RcSXnMBTeoBBE", + "oSq1CSM+K552rcNDU+azzWuVo/M/GuT0nWC3cBXMM4I6E85drNlXgjpThnoOcGdKbeHKmBMnyPcsfnhA", + "oNNiYzcFdCZNrwV07nV29zZ7TcsMEtdDYMuMoStkebt0dYtYssA4EBXBGqM+6gjLvlR1v0zLD4WcmrVY", + "tnZlrYd0P8FmFZ6Vd6AWAllN4uiCR7C8nX8+MezaoJThoGag1FMXvEdBuZQq2wzKlTi6y4XySSy3IKj0", + "sY8u5I+VLZwMT46NNWsYlAqfMhs1GhffOvP4r7rexWPhXcmiKC1rPZLVo1lDV8N4tt2KQrxMCF497mLN", + "5RDXlW0zgcNyPPChEl4Q459ExFEzhLlVJGRxCnWI3F4MIz2xPlFF3NE8QI6QtvTQ+iaADBFkWa+eiXgN", + "hcn615OqGgGMh5HDoxBtGC8RtFu5q9u0EkJegLOLYuWUSsS4dta1xrbw7TJFjpviPemdshVFGyy8fHFx", + "qksHZpzqJmUcdPEGU80hZ5vV99ZyGJbLCmjEdcqbzGlLbqT4Jtn9DgQedNCMeq7i+4x3Zb3DqFVMZuu+", + "em5ZWg2rESbrJifWxhFVtUzQHDmRoH1AiSqmYa3sb8rx6IIsMsfQMV9ADyTNJDCm6i+7SPogQNc4Lb/D", + "iM+ELnKEpf8K/tffAQ8jtBoQbulP3RiRVmOyM+LytUT64RjzEIZxtnRIggmrYlxbkxChjvBKwDWKd9Ql", + "BokK3G5t4p5hy5CTEjAV+W/VaXD1RWKqOSpTUm4F2wmqbGc6vv9iRqOXMld7y5o+vTglRFldrZDjlPoc", + "WTsfKXv8POrSsJhx5J9ulmxIYiC5AXp6n6wsIhuhfkVZydTIexCxabcmHr1lNeKzoIa/qR5fXy8wa782", + "Wg1yLaCkUPqrrnpUpSeSq7nX3CdpVkXK5oLICmiGC9qbrNpk44HLyuvyzZNk4yB/h8UWw2TqIcBhOEVc", + "+BrJJWDCtlCC9P5GPgTyWl/v2vkfBZd8vftaLNY5o4Ivb0NsCvOYcwowkndIFopFK6+AgRm9leL2gTJu", + "6vZhpp1fVxWE1PsKpricQbe74A/R9h/ARR6ayusQ5KZEKKnQHxyTGxq3we0MOzP9BLFSjxEzOtQ0Dhwv", + "YhyFssku+MOHJILeHyJmENaHAdG1D4XiSPvT1+0hhzPxXyFjhRqnep/BVKVTU6PatvKgFMnyFV3m+jZO", + "AQRBiKSWQm5C/VFWa1kiZku18SMcIocn3HN59km0Lg+PmDvzi5eBzjgPDnd2gpC6Hf3d4UGv19uBAd65", + "2ctVgQ5xMx2Q2xkrb0pYf9UVFA+/VclwqgnSW93GCIa5ZPAMWieLQ5abK4irfNrsJqZigc/SEOSdeuVF", + "+VletSevMZ4Ua4zmMbkAOV0DIy9z3UUBkFAlRK1XXeid/NIxIsn1DiSEciA4R/3abLULF92XzZmq2V4T", + "Z5k3QEffeJ9eprp1fvlOIZy/ovF5NJbl8RsXYdZV+SUeR4bqk11LNeiqHIiNH8ZKr7Ja5jRW5WGstY9i", + "ieE8+CGs3C1aj5HIUY7MkwhfKGPPy96qHBFPXiWjQnwXkfXTPJb2Y/unw40cd7LMyUDmt4GbTEYA29F7", + "+NmL/Mo3UkDbdYCW5nRrsjFhZiJH3VOo7FHpWFJ+w76CAbqjUadi+Rkk7pjOlyZNf2elSz/rrE9fEcER", + "k2g1P01SBFJbkehYS1sScp5Qs0cJ1UU7KmJWSe5CC5zqTXpwoXJfiu7K8fmFfE9MlQ8JnJrbRfM5LCZh", + "o9zue50RMCLqGln9b6C9SA+FOuBlFc3+3/7JJ+HzynBROSVK08QE+tiBnhePiPlM+4cyGgnBlvQgVQLB", + "NrjBEMyPzgUzcupQL5PrMok8DwzOLo+AhyfIiR0PjYi5YqJAktT5IYKe3HjSO2JJ9WTVsxztq1cfUQx+", + "RpALug5fvRqRDjiPxj7mDYYqXj5LesmU/JaOqjT1IRLUYzIV7/4bhbTj0lsi37ddgsfEa6eCixhX19HI", + "C7jVgM7/9QlzJN74V4TCuO5GBnULkMLwW5blVFojUXYtFW6ru+2J0P6HrdfdXvd1K1MoecfcDTFF3OY0", + "8xCjGwRgmq1bf2uEGlSyYToiZ4hHIWFgDBl2snW99c0GCDoz2c6WkJC20cRtkwXZBun5JXnXqk47SizG", + "0NWWRnsqWYzo97Jv6An+HMeyy8rcx1+VydQzKvSuKjRu9rYOCyqCJXs3JfVTT0FNbpmt1/T1lXvU07pT", + "Ti5NfSVb16nuW6nrzCImZe4zFdqTJARb18kHac8NsxeKBH5Na9xIpt/r9ZJUEgUBSb9EZWbt/IcpnyHt", + "1n6tStOLkpNcFtsFKSUM6mATd0RbbFOli2bJjjxYcn5qi/3kLoqwkDI0t5nrzE11acKdvK9OXotiyDVx", + "QenuEg6nQujlhU8nwmYimQ3/VTqptrxMbRQgIOi23KQxLWVV2wUXs4KuHxHMjLVAbhsEWt+7yj3nISTM", + "kykcBmCRNjFJGlZNKis2ImM0Ff5gAuJoLCF70bNQtJiAA6DvUtamL42awVnkJeYvCTx+SHxdh/pjTPRl", + "Zrk74cQHVaHrHzt/yAHlItc/dv6QnXDgISgYimRAIfG2+CHdvzO+lvjmZ+kK+mlolGj+hCiHEmM69WV1", + "egjMYglU8p5S3Hor7B114wZsnEjUN50T2TpDjPdl1GQyE5PItbUTIC4MOUr11Cni5+IXbTbSmEYaIrMj", + "rIFVBYzKZna+BYgPXbmRmsRSv9s3DZfY3tNUWfbn0u04gwK1fuuIIO2jRHl8SFzIqRA40VQ2gDIY/p1E", + "OJMhaZQ4M6ZW/vll7eMEV7ZNSu7N/NzJ+d/B5AYRSW4dTepdGorhSiD2ucx0O4kdGOIfZCss17Km0zwS", + "4/mto2/N7JzrpKs08FCKUU2CMYbFb9WvtR/buSJNo1aNSdQ0wTy1xHTZLZxOUdjFdOdmT36VbyoXszdN", + "bb5rNzRE5Qv77to5hRBD32tu1yzN5cJJvbAFv2N3Y3ZV9J9PWLbY1rJts2by3rVb+w9r8qXFLG67lO5Z", + "2laUvX04ysSSetjhoJMYW2WmpBEVJs2YUeiFCLoxQHPM+NP0mhR/VLk5dY7TXVtFiDvfsHun/CcP2W6R", + "OEM+FXEisbhRk5D6tY6URg0YpwEbEYVKlN0ezOx+DxiSzsTD0xkHWhUyoHcQ0Yhk+fv/yAlIXgqRg/AN", + "Avu9ffCZcvAzjYhriy6P5KA1KFcXXpoEiGjs5a9xTXFF4f6pSSwen7HkpcloSEdq2gjIi0Hz2qUuJNts", + "xFM6wL8wla4isbnMJGpO7v1kQRPVaCVFKqD9h5PrMlnC454IFn2SOkbJiFUB1Edm9cCTutdQCXNRrdAQ", + "wOQwlex2HAPMGcBCioViwa7KeNFXXSu2yF3cLyYVAnl9v03y3yPePx2+i4fuyqKfidmehcQv8DUK1SrW", + "9p1K7TUSUPENe5HJBTL5HlkAbykk7gK0JOK2ZBkXKgmXro51P0LZcwV7gywgos7g6k0/yKneQNByqh0A", + "bZCZTipRH+aJb2L9RyTRGNJvE61Rr9BSwReIGKruVUMrQz+gIYeEH756BYaT4nWgrC1bSCYnT7gq880A", + "dDi+QTZdo+Z3c16GGsrD6ZxlsJZnFKltVHsWDqM10jHWo19PPFJ7UcqVSrmJGm0cku3o3KwmW3iep5WP", + "7E98lPgm2okyO3oaGhiry38jJuK04ST5h/SpCICuj0kbhLoDmX0iojd7F4n7Y924+yiGsBm1F2ZdR0PC", + "d+B4fUT5NOYFmzmSKV5kcbGDJMtdFCZOxRZEA/lLbyuV8JZrFNtlTW8kaYETrzmQgDEaEVkgZBwDx8Oy", + "NhanIIs/p56L3qQS3WA1go8ozvgmI6Kz8nGSdaV6Fb3J6Oj1XmcciyYhcamvrvkGiDjUVQVBZmgOXeRg", + "Hwr5Ji4IQjTBc6Q3f0YtGODgatSSQ/SoAz05ibJMia61dINdPT7xDprrpRGvdes2cRRMvgm1YC7DN2rh", + "QbXCvbhGH5GqVIEp0Wj/+v6Rtc2HhrM/ovi9Wi1JRr26kTLzDLHsF63cHK42emOxRrY7Rzvf1NbcZ+ij", + "BSj2Db1G6njADaYR8+JEcbiLNPkX4gi1LFpw2yNi9IxQ6IQCj5IpCuUeO9N5rzZ1btOGiqqNakNF5kPr", + "wnbd0VIzuwl1NRSp0wgWgtJ1fmKum1hDp7FC01z0otC+D4Vm1AoxTL6mCtsJkdFKco/G6oSeoZzLM8U3", + "iDRxRgm6zWm9Z+6U2hWqGd534WI2V6uGwodWrffl/yYLuWEf2N7uQ4OFS/vBIeUvfvD3ZDYSjbK8F+yg", + "kKsik6ghOKgqb4OLT+cg+zFwojBEhHsx8Ch00xqhmZeAyuuSmzkM5T+HYaba6Q0K8STGZAo+XFycnmdO", + "DlNCkDyoxKpgwkF2RPcoeZl+mgJuucl+0nnTepGd/FwaTsoMvRnSdRkIntChmp2BjC9QZheFfaU/j4jJ", + "+MUEnB6f6ENHXdCfcHnnoeirbW9MOg3mdLk6mhQiza/COzg/Ogdb58gJEQdHmDn0BoUxOEfhDXbQtvja", + "bLNwCoKIqV1Dgm5HpDAWlbsdhHSOkUm6PlJHooDC9g9fvQKDGSRTxACH1wigyQQ5HGDfRy6GHHmxdFJo", + "xEGIZG61OUk/TQ5tWbYHxXAyK7ROgnNmTK3DVkf8793x++FnMDg+uxj+PBz0L47lryNyMhwe/XYxGPSv", + "f532b4fv+tPhP/sfP/Uu3//gn33k/znp994Pzv98fz4cvz761/G7we1l/+T4cj74q//Pd9PPv4xIt9sd", + "Edna8ecjSw+pj+HHHcVEHQc2T+jMzImapEeCrjJ01KYWZvhJ8fSTMdkZynQVgqe5g5ZVOjmBWKjIiqZx", + "R2mJ6jDqRNaZ8GLAQzydohBAoD4xh+Fyxi5JdZxgD6niP1L9KOUyIudH56Ygj8w5mESeCJBiGv3XDQK+", + "6Qu6gidyyyHa06o0p5IYcGURChrGWhl9pkoFyW4QcQOK1b0TPA6UbpR+D0FIKkemmZCpw51IF14ZkZw6", + "TYavBl+BUxU01NpmuljKxpJJmO0OlFT+PZVNlXecX6lSnuWioeKhqvNpeERTVbC6abmhvd2Dt2/L572a", + "pC/ax19UJ09OhhOx0sK0rENSkuNFCcomP9HiAYFxDIZHxs0wElDhaMhzXnnRKHFd3psIVWp0sTWhKkbk", + "sbwJNR15b6IWAxkeGUSh4A/pCc/iCQcHPfTTfq/XQXtvx539XXe/A3/cfdPZ33/z5uBgf7/X6z2H7OaG", + "w2iW8ZzlZJNgfK9aaknl8TSynrMEPY9855UcEAlCXLmRHywOzfMZ0CoWl2eoE4jPUgoAE8eLXEymh/Jc", + "Zv2ZffOK1mKlIn7JCyGaYsZRWDBlsryC8nUUh0rGNsfmVP2JgiuiXR9ZUBiNo+kUk2kb+JRgTkP5t2hi", + "DJ3rKEhLDdsTtPVNEGIy7xMVSHqpPzZkTVWXK/0kuTjyg4Sp8jRLFstwtFphzcEzBD1VVayKeWXRB8Gd", + "6lXDGZUsW1rZD/K7wQw515t1I21aVBEZ24tm+8KqKlFd8fa90trYRJYBQ0V+jdREAEfMRCJEVQvjeX5y", + "K2mHIz/wGgKAuQIf+nZN2QpIWqkC5tT1n/Lli6THxoU4TPPV1Ti+BIj01y3EsVlXoVyu4fXa5Rrardx6", + "NaoqYZn66ioTy9SDsHPA08Y2K2hOJUW8YKZrhQIR1vZt5x9SlpYlJtmIcHqNZAku59rUvPSpizyA5uJH", + "fUJA1YfIFM+pKehgllsdSC3Xb7iQPaZ7lUy9EzFd00jWOpJ1WFFSd7y6noKFz1r3s7Fn62m9LT17iw+K", + "DFpIWHxmu4LdXs5tNzy3nUhI8fD2MzuwbeWDRlqt2iFY4jy3nQ3rznRXwg12LbJW6kVCkB2JUPe0PwOs", + "ISF0qevPC4vyaGeolyDnoREFO2nP5Sz16rK/uYPVVTSUAnGLeG/k5HQVAU9SzJd0AzZ6nLpJ+41l93GO", + "WD9HcX2PeIWVLB61rg1AGp65bhKFVJ4uvmcLrIDsexXN7zriuFdVs/jssZ21Xs4ff3caq6laWSnKWAtt", + "ZIsQxiWQxdyQFqCLydjur95vjpyHLfyb67q6AnBeV3+PBYAbL4+8x79YKl/Nj65Eb10m/dnjwdD2m6uy", + "krksrlxXtfjeyhHnVcKzQZ3XA5uPJAfbUB8LxpwqNoUxq0uhKBDLHkKHj4iEvXQMyVSiazvdGE5Tr82W", + "EmtnD/PIDBgol9JcR9nWB2105dRuE7T4/lHiTVaIqWm23u7n3RL75QmtR0Kcl0SaDcCssgF10sAL2rwI", + "bU5E/TsqFZrli9VcwVVx5hp4eTG23DCiLYeyhfFmDsgxutdR1r8TFBzFp1ye0072ChDz00CWnx6g/Bxx", + "5EeEjxegxkugxd+D8Da03/cFES8JDT8JRPiZAcES/zWMugEcmBdvHZEQSgWKsxgCfm6y9l3GEZcaXq2O", + "J1qPBBwvCRg/XZz4RWetDAUv6/bP8crZpvLTavRXP14O+53HII/cPh7uO48fB/Sdxy+I78KF+d7gXiOG", + "S4C98/gxkV5J8HPAebUa2izKq2TUAvHO42b4rnlVw3W6Qoc+/5dFfUsI7xKA7jy+VzR3Hm/eBSu3WWWr", + "i0vwdEDcedwYwZ3HL/DtyvDtPP4Osdt5vIoHtzxsO49Xxmzn8bpxqGyhEIS61GEdyBhmHMrjUs8CrC1R", + "vRRWK03A4wK1VSQ8UgQ2j58bRNtQYDeOz8p+K8DZebwJZPaZSGkTg3wPkKyl0VoZe1Qw9smLVQaJncci", + "1ouKzPmAaKxFtLJQ7POyf9+Z919AX4tRwCNAr/O4Me46j19A1+enm6oR1yWcdd8JVoVbk6jwZHCq4558", + "PRAVBmWOIZtyDmPIsAMwUaGwjIrGNOIAQWeWaW1LXeOuI6fkPvd2FhHk2EfbVQUFTganSwO+ovsc4FvO", + "9D2JUyLvD+5NCXlYuDfttxruRTcojPmsGnf9LiDf+wZdD2ygq+8EV8sCr5rPHwd4rZL+p43CVlKdKs70", + "leVLPFQ0b2rYVl1gnXtZVohLLr5sg0DINZN/QuICHkLCPFMcTtV/mx+dgxAxed0+y96JPSJjNMWEgZBG", + "liuxUYbg0s2YlWiuYbt7QnNN85v056rafNAqDgkRC+HYKjZ6Kd7QFI/N8/V3gslWsMVi3VXw+JaAZ6s4", + "cXN38tdooIe6mT+j0NY6ypoOpfKS/tSD6ogFeSb39NupboYtV3HQoyHNSxH00EFoFXHPBYVeWUVtDpBO", + "O7iP6/yNrlirMkVRXzwXLbHAu9korG1rbwlZfhx8+3mK73vEKw19sQRFdXTUsP5ERUcvN/+ve/P/vXgx", + "alQPqp+++4iyt/GBLUb8q8T7pTjHd6jPm2vdZqGjSfBreg9Y4b6vckHwNHx0smX/TssvwhCZZuQ36h4T", + "nZ9o6EqvMAGQCx2eFEWW1xlEgbQh8voFmdvoI19dd2LdPTg1o71HwVUjbXo9WHkCnzbImqnzbiE9ZTm9", + "3nl+E03KPmy265O8Hd5FN8ijgfyi3YpCr3XYmnEeHO7syOvjZ5Txw7e9t71WebPhiDrXKNz5GI1RSJC8", + "/ybZdCg2pvNfOylD6Va/JmMoocGqjr0uWq7YThYuT6okpMZRF94u0zg4uzwCCWMyGeCUy+6nDRUu8GvW", + "YA0Srpu1aoRy42dytdMMhhBFDI49ZF973XZ56csNq4eV9wqKQRRuAZTXA2oJSPuquEvh7uvdfwcAAP//", + "U87KUT8wAQA=", } // GetSwagger returns the content of the embedded swagger specification file diff --git a/sdk/gateway/policy/v1alpha/api_key_compat.go b/sdk/gateway/policy/v1alpha/api_key_compat.go deleted file mode 100644 index 9c33c9260..000000000 --- a/sdk/gateway/policy/v1alpha/api_key_compat.go +++ /dev/null @@ -1,74 +0,0 @@ -/* - * Copyright (c) 2026, WSO2 LLC. (https://www.wso2.com). - * - * WSO2 LLC. licenses this file to you under the Apache License, - * Version 2.0 (the "License"); you may not use this file except - * in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, - * software distributed under the License is distributed on an - * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY - * KIND, either express or implied. See the License for the - * specific language governing permissions and limitations - * under the License. - */ - -package policyv1alpha - -import ( - "github.com/wso2/api-platform/common/apikey" -) - -// DEPRECATED: This file provides backward compatibility for code that imports API key types from the SDK. -// New code should import directly from github.com/wso2/api-platform/common/apikey instead. -// These re-exports will be removed in a future version. - -// APIKey is deprecated. Use apikey.APIKey instead. -// Deprecated: Use github.com/wso2/api-platform/common/apikey.APIKey -type APIKey = apikey.APIKey - -// APIKeyStatus is deprecated. Use apikey.APIKeyStatus instead. -// Deprecated: Use github.com/wso2/api-platform/common/apikey.APIKeyStatus -type APIKeyStatus = apikey.APIKeyStatus - -// ParsedAPIKey is deprecated. Use apikey.ParsedAPIKey instead. -// Deprecated: Use github.com/wso2/api-platform/common/apikey.ParsedAPIKey -type ParsedAPIKey = apikey.ParsedAPIKey - -// APIkeyStore is deprecated. Use apikey.APIkeyStore instead. -// Deprecated: Use github.com/wso2/api-platform/common/apikey.APIkeyStore -type APIkeyStore = apikey.APIkeyStore - -// Status constants - deprecated -// Deprecated: Use github.com/wso2/api-platform/common/apikey constants -const ( - Active = apikey.Active - Expired = apikey.Expired - Revoked = apikey.Revoked -) - -// APIKeySeparator is deprecated. Use apikey.APIKeySeparator instead. -// Deprecated: Use github.com/wso2/api-platform/common/apikey.APIKeySeparator -const APIKeySeparator = apikey.APIKeySeparator - -// Errors - deprecated -// Deprecated: Use github.com/wso2/api-platform/common/apikey errors -var ( - ErrNotFound = apikey.ErrNotFound - ErrConflict = apikey.ErrConflict -) - -// NewAPIkeyStore is deprecated. Use apikey.NewAPIkeyStore instead. -// Deprecated: Use github.com/wso2/api-platform/common/apikey.NewAPIkeyStore -func NewAPIkeyStore() *APIkeyStore { - return apikey.NewAPIkeyStore() -} - -// GetAPIkeyStoreInstance is deprecated. Use apikey.GetAPIkeyStoreInstance instead. -// Deprecated: Use github.com/wso2/api-platform/common/apikey.GetAPIkeyStoreInstance -func GetAPIkeyStoreInstance() *APIkeyStore { - return apikey.GetAPIkeyStoreInstance() -} From 52d821043f2807a867c6cbc859b71cb08e1f0b5c Mon Sep 17 00:00:00 2001 From: Thushani Jayasekera Date: Thu, 29 Jan 2026 14:21:02 +0530 Subject: [PATCH 05/14] Fix coderabbit review comments --- gateway/gateway-controller/pkg/storage/sqlite.go | 9 +++------ gateway/gateway-controller/pkg/utils/api_key.go | 5 ++++- platform-api/src/internal/handler/api_key.go | 1 - 3 files changed, 7 insertions(+), 8 deletions(-) diff --git a/gateway/gateway-controller/pkg/storage/sqlite.go b/gateway/gateway-controller/pkg/storage/sqlite.go index fe1e10b82..957321642 100644 --- a/gateway/gateway-controller/pkg/storage/sqlite.go +++ b/gateway/gateway-controller/pkg/storage/sqlite.go @@ -255,18 +255,15 @@ func (s *SQLiteStorage) initSchema() error { return fmt.Errorf("failed to add external_ref_id column to api_keys: %w", err) } } - // Backfill legacy keys: treat empty or 'null' source as 'local' (DB + local cache consistency) + // Backfill legacy keys: treat NULL, empty, or 'null' source as 'local' (DB + local cache consistency) if _, err := s.db.Exec(` UPDATE api_keys SET source = 'local' - WHERE source != 'local' - AND ( + WHERE source IS NULL OR trim(source) = '' OR lower(trim(source)) = 'null' - ) - `); - err != nil { + `); err != nil { s.logger.Warn("Failed to backfill api_keys.source for legacy keys", slog.Any("error", err)) } if _, err := s.db.Exec(`CREATE INDEX IF NOT EXISTS idx_api_key_source ON api_keys(source);`); err != nil { diff --git a/gateway/gateway-controller/pkg/utils/api_key.go b/gateway/gateway-controller/pkg/utils/api_key.go index 0ffa1c9dc..e4323c8ba 100644 --- a/gateway/gateway-controller/pkg/utils/api_key.go +++ b/gateway/gateway-controller/pkg/utils/api_key.go @@ -1651,7 +1651,10 @@ func (s *APIKeyService) CreateExternalAPIKeyFromEvent( key.UpdatedAt = time.Now() key.ExpiresAt = expiresAt key.Source = "external" - key.ExternalRefId = externalRefId + // Only update ExternalRefId when a new value is provided; avoid clearing an existing reference on nil + if externalRefId != nil { + key.ExternalRefId = externalRefId + } if s.db != nil { if err := s.db.UpdateAPIKey(key); err != nil { diff --git a/platform-api/src/internal/handler/api_key.go b/platform-api/src/internal/handler/api_key.go index 94faee085..7bc969266 100644 --- a/platform-api/src/internal/handler/api_key.go +++ b/platform-api/src/internal/handler/api_key.go @@ -114,7 +114,6 @@ func (h *APIKeyHandler) CreateAPIKey(c *gin.Context) { c.JSON(http.StatusCreated, dto.CreateAPIKeyResponse{ Status: "success", Message: "API key registered and broadcasted to gateways successfully", - KeyId: req.Name, }) } From ae1e57dc2167273dd7da1a70cd92edee0dbf7833 Mon Sep 17 00:00:00 2001 From: Thushani Jayasekera Date: Fri, 30 Jan 2026 01:04:38 +0530 Subject: [PATCH 06/14] Add support to update with a new regenerated API Key --- gateway/gateway-controller/api/openapi.yaml | 66 ++- .../gateway-controller/cmd/controller/main.go | 1 + .../pkg/api/generated/generated.go | 398 ++++++++++-------- .../pkg/api/handlers/handlers.go | 71 ++++ .../pkg/controlplane/client.go | 90 ++++ .../pkg/controlplane/events.go | 16 + .../gateway-controller/pkg/utils/api_key.go | 391 +++++++++++++++++ platform-api/src/internal/dto/apikey.go | 22 + platform-api/src/internal/handler/api_key.go | 77 ++++ .../src/internal/model/apikey_event.go | 16 + platform-api/src/internal/service/apikey.go | 75 ++++ .../src/internal/service/gateway_events.go | 116 +++++ 12 files changed, 1163 insertions(+), 176 deletions(-) diff --git a/gateway/gateway-controller/api/openapi.yaml b/gateway/gateway-controller/api/openapi.yaml index bd5c097a0..55b2dafe6 100644 --- a/gateway/gateway-controller/api/openapi.yaml +++ b/gateway/gateway-controller/api/openapi.yaml @@ -503,6 +503,66 @@ paths: $ref: "#/components/schemas/ErrorResponse" /apis/{id}/api-keys/{apiKeyName}: + put: + summary: Update an API key with a new regenerated value + description: | + Update an existing API key with a new regenerated value. This allows + rotating API keys with a predetermined value instead of auto-generating one. + The new key must be at least 16 characters long. + operationId: updateAPIKey + tags: + - API Management + parameters: + - name: id + in: path + required: true + description: | + Unique public identifier of the API + schema: + type: string + example: weather-api-v1.0 + - name: apiKeyName + in: path + required: true + description: | + Name of the API key to update + schema: + type: string + example: weather-api-key + requestBody: + required: true + content: + application/yaml: + schema: + $ref: "#/components/schemas/APIKeyRegenerationRequest" + application/json: + schema: + $ref: "#/components/schemas/APIKeyRegenerationRequest" + responses: + '200': + description: API key updated successfully + content: + application/json: + schema: + $ref: "#/components/schemas/APIKeyGenerationResponse" + "400": + description: Invalid request (validation failed) + content: + application/json: + schema: + $ref: "#/components/schemas/ErrorResponse" + "404": + description: API or API key not found + content: + application/json: + schema: + $ref: "#/components/schemas/ErrorResponse" + "500": + description: Internal server error + content: + application/json: + schema: + $ref: "#/components/schemas/ErrorResponse" delete: summary: Revoke an API key description: | @@ -2353,6 +2413,11 @@ components: APIKeyRegenerationRequest: type: object properties: + api_key: + type: string + description: The new API key value to set (minimum 16 characters) + example: "apip_1234567890abcdef1234567890abcdef1234567890abcdef1234567890abcdef" + minLength: 16 expires_in: type: object description: Expiration duration for the API key @@ -2423,7 +2488,6 @@ components: type: string format: date-time example: 2025-10-11T10:30:00Z - APIDetailResponse: type: object properties: diff --git a/gateway/gateway-controller/cmd/controller/main.go b/gateway/gateway-controller/cmd/controller/main.go index edf19f98c..212a44a0b 100644 --- a/gateway/gateway-controller/cmd/controller/main.go +++ b/gateway/gateway-controller/cmd/controller/main.go @@ -465,6 +465,7 @@ func generateAuthConfig(config *config.Config) commonmodels.AuthConfig { "POST /apis/:id/api-keys": {"admin", "consumer"}, "GET /apis/:id/api-keys": {"admin", "consumer"}, + "PUT /apis/:id/api-keys/:apiKeyName": {"admin", "consumer"}, "POST /apis/:id/api-keys/:apiKeyName/regenerate": {"admin", "consumer"}, "DELETE /apis/:id/api-keys/:apiKeyName": {"admin", "consumer"}, diff --git a/gateway/gateway-controller/pkg/api/generated/generated.go b/gateway/gateway-controller/pkg/api/generated/generated.go index 4fe4bb121..5d6a86eb0 100644 --- a/gateway/gateway-controller/pkg/api/generated/generated.go +++ b/gateway/gateway-controller/pkg/api/generated/generated.go @@ -470,6 +470,9 @@ type APIKeyListResponse struct { // APIKeyRegenerationRequest defines model for APIKeyRegenerationRequest. type APIKeyRegenerationRequest struct { + // ApiKey The new API key value to set (minimum 16 characters) + ApiKey *string `json:"api_key,omitempty" yaml:"api_key,omitempty"` + // ExpiresAt Expiration timestamp ExpiresAt *time.Time `json:"expires_at,omitempty" yaml:"expires_at,omitempty"` @@ -1390,6 +1393,9 @@ type UpdateAPIJSONRequestBody = APIConfiguration // CreateAPIKeyJSONRequestBody defines body for CreateAPIKey for application/json ContentType. type CreateAPIKeyJSONRequestBody = APIKeyCreationRequest +// UpdateAPIKeyJSONRequestBody defines body for UpdateAPIKey for application/json ContentType. +type UpdateAPIKeyJSONRequestBody = APIKeyRegenerationRequest + // RegenerateAPIKeyJSONRequestBody defines body for RegenerateAPIKey for application/json ContentType. type RegenerateAPIKeyJSONRequestBody = APIKeyRegenerationRequest @@ -1908,6 +1914,9 @@ type ServerInterface interface { // Revoke an API key // (DELETE /apis/{id}/api-keys/{apiKeyName}) RevokeAPIKey(c *gin.Context, id string, apiKeyName string) + // Update an API key with a specific value + // (PUT /apis/{id}/api-keys/{apiKeyName}) + UpdateAPIKey(c *gin.Context, id string, apiKeyName string) // Regenerate API key for an API // (POST /apis/{id}/api-keys/{apiKeyName}/regenerate) RegenerateAPIKey(c *gin.Context, id string, apiKeyName string) @@ -2219,6 +2228,39 @@ func (siw *ServerInterfaceWrapper) RevokeAPIKey(c *gin.Context) { siw.Handler.RevokeAPIKey(c, id, apiKeyName) } +// UpdateAPIKey operation middleware +func (siw *ServerInterfaceWrapper) UpdateAPIKey(c *gin.Context) { + + var err error + + // ------------- Path parameter "id" ------------- + var id string + + err = runtime.BindStyledParameterWithOptions("simple", "id", c.Param("id"), &id, runtime.BindStyledParameterOptions{Explode: false, Required: true}) + if err != nil { + siw.ErrorHandler(c, fmt.Errorf("Invalid format for parameter id: %w", err), http.StatusBadRequest) + return + } + + // ------------- Path parameter "apiKeyName" ------------- + var apiKeyName string + + err = runtime.BindStyledParameterWithOptions("simple", "apiKeyName", c.Param("apiKeyName"), &apiKeyName, runtime.BindStyledParameterOptions{Explode: false, Required: true}) + if err != nil { + siw.ErrorHandler(c, fmt.Errorf("Invalid format for parameter apiKeyName: %w", err), http.StatusBadRequest) + return + } + + for _, middleware := range siw.HandlerMiddlewares { + middleware(c) + if c.IsAborted() { + return + } + } + + siw.Handler.UpdateAPIKey(c, id, apiKeyName) +} + // RegenerateAPIKey operation middleware func (siw *ServerInterfaceWrapper) RegenerateAPIKey(c *gin.Context) { @@ -2921,6 +2963,7 @@ func RegisterHandlersWithOptions(router gin.IRouter, si ServerInterface, options router.GET(options.BaseURL+"/apis/:id/api-keys", wrapper.ListAPIKeys) router.POST(options.BaseURL+"/apis/:id/api-keys", wrapper.CreateAPIKey) router.DELETE(options.BaseURL+"/apis/:id/api-keys/:apiKeyName", wrapper.RevokeAPIKey) + router.PUT(options.BaseURL+"/apis/:id/api-keys/:apiKeyName", wrapper.UpdateAPIKey) router.POST(options.BaseURL+"/apis/:id/api-keys/:apiKeyName/regenerate", wrapper.RegenerateAPIKey) router.GET(options.BaseURL+"/certificates", wrapper.ListCertificates) router.POST(options.BaseURL+"/certificates", wrapper.UploadCertificate) @@ -2954,181 +2997,186 @@ func RegisterHandlersWithOptions(router gin.IRouter, si ServerInterface, options // Base64 encoded, gzipped, json marshaled Swagger object var swaggerSpec = []string{ - "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", - "U87KUT8wAQA=", + "H4sIAAAAAAAC/+x9e3PbOLbnV8Fqp+raaUmWHTvd8dbUlGO705rEiceP6dlpZdMQCUkYkwAbAG2xs/7u", + "t/DiE6QoWX7lev6YdkQSOADO84eDg28dj4YRJYgI3tn/1uHeDIVQ/XlwOjykZIKnR1BA+UPEaISYwEg9", + "9igRaC7knz7iHsORwJR09jvvIEcggmIGJpQBGATg4HQIGI0F4mAjjLkAXEAmwA0WM7DVBYQCwSAOMJkC", + "HkA+2+x0O2gOwyhAnf3O1g2CYoZYp9sJ4fwjIlMx6+zvDAbdToiJ/fd2txNBIRCTJPy/0WjrN9j786D3", + "70Hv7dfRqDcabX159Zv8/ctfOt2OSCLZNBcMk2nnttvxMY8CmHyCIaqO6Jc4hKTHEPThOEBqOASGyAxm", + "jMDl2cfehGFE/CABPUBJkIAASWp4F5A4HKs/eAQ9xLtglkQzRHgXxMRHjHuUyV8h8YFPBZczRm+QX5wE", + "Mwc9GOHiPGw3zkM2CaNR7+to1AdffnCOX64slMPl1eF/xFwAOgG/XFycguzFLb2knW4HCxSq7/7C0KSz", + "3/nfWxlTbRmO2vpsP5TdhZgM9UfbKTGQMZjIhxENsGe4zE3JwemwF6BrFAD7LoBRFGDkA0EVy2VkgpgE", + "iHNArxFj2PcRaUvxqWxbUVSmMI64YAiGVQozyuw7wFNCFJvBd0tiFEJMFhFyabu77XY4JP6Yztt/ctvt", + "MPRHjBnyO/u/6f6+pEOi4/8gT8iGrxHjagzlIZ2jEBKBPWDekAsgZkoMCix6vd0fdArcdz0a+T+MRn35", + "HyfXXc8oF451Poy5oCG4xkzEMADqrS2fStq50ipZ/+7ZXNicaU01FjHqx558V+qhyQR7hXHBCPfNv/oe", + "DTu1AtYfjXo14pVbtaVIM9856TLPenenrx2LlN7Ka8yMe7qpXchJSUG9uHgvNTVWSirWBkb4n3UMKvUx", + "j5CHJ9hTn4OMGkTiUFI7hQLdwKQPI9yLAigmlIX9G0535JRtXW/DIJrBbUlcNsEtv3Es9xUmvptO9WpG", + "1hni4kCp9F/R+Dwey78LNGQvVDoJkYC+Mc1NquDEvif5MEKemk6SfJ509n9r/rLoAdx2m9/+FY1nlF4d", + "nA7161+6jvEXlCGIYBJQ6IONs+PzC0AZOOAJ8ZSBvYYMQyL4ZoXxcqyQmwQz6WaIdUzGEBToDPGIEo4c", + "Po167n+Fyq3JVmFnsLPX2x70trcvtgf7rwf7g8G/O92OZAj5aseHAvUEVpJQWSfsYIVLgv+IEcB+qs1M", + "16AySXVuQM/oWwdfcA6nqDiC6tzbDnnseYjzSRwEiVN1CShiXmzNfOPUJK55P0IC4qB+3qVX43Iwixqh", + "FavGmZPRbuLXMeGZID4AP/koCmjSqtW99q3mltnopggRXz7MepSNQRwgv6ijco8rzcaRv+4ZuHVZpgrX", + "rYNtP6CkykGal7n0giBR3HOFkoojAiM8rGe/KB4H2APYR0TgCUYs51MBMYNC/eMKJQBzADmnHlayKiOm", + "pdkTRvjrlWsk7xGRVtkoHdmbishghKOvIGJogudlRyj6ur3zenfvzY8/vR3AseejybL/dlFYFJMikRc4", + "RFzAMAI3M0TSSVLUQg6mdgwFSjV37fQGP60gX5aasWPKhpUVizli4GZGM0ryNJbn76tHCY9DFcxWOkbz", + "CDPEndNwLJ9pxS3SGdkgcRAAPJERNEpf2Fx5KmRzMsLt7AsWIweFxBkeSxcwz8DlcYdJL8+n+vFKEahs", + "PRfZWSG5wUEAZvAaAagEHAhaIOC398cXYOubR2MiWPLVoz663frmYZHcdsHp5/MLsCX19xenXqQx8xyD", + "Ple/l4YNNgLqwUB6MmguXXAYbOa8PfVQUaafFdWofdqgmkskqN8dM2+6g57A13JdGbqmV0ZEIuVGFTpO", + "32sOC4j29LVmK6xXSmJBlAuSVODudFa/1Opd5alhSs7QHzHiwukyuLXaZ/UHDEAUQEx6MhZJl+caBjFS", + "lt8ugVayRHaOKemPyHAi48Fr7CO/C8QM84zDxkrafYAJFwj6cuKN0GMyBRAQdAMoQf0Rucgz5hiBGeQz", + "5IMxmlCGABeUwSnqA/uaB4l8CxMASQK0aI7IRogJDuMQbL8B3gwy6AnE+GYfXHKkKZMDMbSTaTqkIMl0", + "0YiYoXOwgfrTfhdMGA3BYUBjpfVPNvujosPjyUdSTkMpqD049rZ3Xs+TP3/86W2nCDS9WYMK64PhBIyp", + "mAH7pZoEH2QNAchQbklyDwS8QlwaKg/5iHioX1Z8b1a2ARk1jePwrTOddyZd7oCfc2NL7oRtIs+btoP8", + "gF4PUjoxEWiKmPKvCK4xmkA+crRnlANHHiU+12tqcLwZjZn8rw8T+Z8bhK7UC5SIGS/5fPqVZo2hiOtm", + "g3eJu+XarwxNvro89mMrqQxNEJPrDIZH5Qm33J0xtvp1eLSppFHKCkaBL90pavWDZDMlz6otBj0pRFHM", + "IsoRVxiukWSDA6RSxQEWHNAbAuRCKNosRQx6V5hMFwjV259+fLPXeVD7Wu/hGi9Q6dqG+Mwq2wVBmHSZ", + "m2JQuSqZp7Qo9mQohJhgMv1qKPj6R0x1oFWcojP7YsoQ6sWUTaSXlp+zty5ZWjZkyLN6agDtyOtNm3Rm", + "Gqf6A0rUn61A6mzOyyD1UsPpdgQVMDiULpJDn8hnZiPD+mHSqBT0U3VK65nuDE1zbLesiZdCKY1t0awL", + "CjgSoMZs3ksks25z+GLAnpsBa2Lxa+ot0KuNatJ47WsH6FZRW1JnDQUKG7eBnVu2C4L9dWFixe3bun3T", + "Gmx2OTzl+SBlhU29ykZdOw/hUpFVz8KrzGBLhNrMyHoFoN1Eb+/v7t0Jkux2DuUcqR0p1GzxvezF9mY/", + "13ra8pp8gHeJcO29ax9gLB8q7DMIQI5yMMEBKvgDOzvbe2+dftYynkZjFy1dDtdcOfSYk55PLkq4DNWl", + "tZQU5Qnadg23cRsihRM3Li+HR5vZdlDWW8Ep2NsboJ92B4Me2nk77u1u+7s9+OP2m97u7ps3e3u7u4PB", + "wClymPMYMcf+c25+9Tvg6BPYkGRMMONCEQLwBIxj4geo6EYdfvrrSQIOD7qf5X8/sykk+E8lu93Dv16e", + "LxD9EpiluRIoSEPLnA7T7BeFjnNUx1FAoY98Fc+dH523VhuLg626RQiTnqf27XsedLZMxcFELJpulHPD", + "5L9bTrp2C7d7O2/A4M3+4Mf9nTd32OTJlAFijLKiuWrQFDzW4tU4QvPSfXLUAnm/VMxRG2HkF7gyktPj", + "kx4iHpW89a/+3uBtnh82+GYfHEIiTZaAmIAwDgSOggLT8CIi1ZP/e3f8fvgJHB6fXQx/Hh4eXByrX0fk", + "ZDg8+tfF4eHB1a/Tg5vhu4Pp8O8HHz4OLt//EJ59EP85ORi8Pzz/4/35cPz66B/H7w5vLg9Oji/nh38e", + "/P3d9NM/R6Tf74+Iau3405Gjh/YyYLSTynFzKKQ+ODF5b7F+EXqMcl42CaXRl4RmhRS2/tdW6StFqVUj", + "dHm1hzNICAocHKwfgA1BI+xtoWtEBNCZLJvARxNMcBoyQZuvoAZbdu7FjPougNoEvkC/oXJC+rnI5vzy", + "XY7iRYtlyVWrJRdLUp23LAwFUOBrFSAbJ0t67MXVUbrfKemLs/EqOXgq/1FQDVJ7djpNAh5SOh76PjcE", + "lTL5Nu+anufetjCr4eQEneMSh9HB6dBGOdXUFUmUNPzaSQV+HEaAWX+iez/JC0sb/tQOxDH275I1VJiU", + "LIXodtH8neTaL86hfaIER01oYS6rU/iSPPEQyRP59WuEJR0a4CAIgB1ANZGmdWpwVQAdoUw5TKpSwtAU", + "c4EY8gtmqDUV7UKqen0oaTC+qHopyVkLvpxWO0o/rIvqMBfYc20Kx2EIWQKydwAc01hnlHgxY9KaNack", + "q/jswLngLiC4suYpo+7VR3/ZXK8Ubi4VaTYyTkPAWeyktv3TWoYot+3kimWj2WVD+hRbbpNak7dti9Jr", + "ZAiyFv1zLCOPetWjAhNel4iFfHANA+xrj8q821LW/pl+qEhwiVptvPoLns6M56I6BfnHhZCmgGnlaDXW", + "oCWgpcOzteC5x3PBoMp3yNKZXMBe/llx8H8///zpFOpde4a4PhXAwAxBHzHtiQpqfdBEcZagV8jsERSm", + "5y/9WBLaxySKxYV8ycnGgcHSq7T8OkNMdTfBxM91lUMRcr61yTjudDua2E6380eMWHIKGTSp8zP9d8FK", + "Z581z39KZjc/f65F+Pjx5EAJ7SElgtHAwfdzD0U1CVFm8u0L2tlO05883SQIqY/aysIZjQU6ti06RUG2", + "VjV6zi7TFKQgoDdfYRAoR4gk6s+S/2N+XXgqQbZcM5MmFKhMIansB4xjf4qEnXNXuAPFrD0Mm/YtF8Q1", + "abUAfA0E74hcssMMmrbGOVB0OPaZZPBTHJZdovfHF51u5/TzufrPpfz/o+OPxxfH8p8HF4e/dLqdz6cX", + "w8+fzjvdzi/HB0edbueVM0CtuEpSkLT76PtY43mnOcJ0lmFVtYBzNb1GpY4xmSruVs0hgRhXjB4J5INx", + "oqNMbVr7QOV6YMFRMFG5s6DQHvXiEBEV+lamMDIzl9vF8mZQqFUPkLXWzSum2uim053OQN2S6awm1nTM", + "EpaVxAJ2LCqV227xnOYExoG00Fud7n2f2qQRIhAveWhzo/bU5ubf7nxu8+PHE2CnHBjhygj+9fzzDvgc", + "IXIwTN+6l6OWdwVU0sTL9UMqmSp1SLNAYRQ4kdIL8yS1/DG3wCHmhWkvzHjKIY7ANztfCYOgxVGl3BHJ", + "di8exFJhf1nl4GPtgFY9AVnt+p+584B6VtNMNSmSmEz74DyOIsoEl3JJfMh8YA4Oyvd5F/B4bI5MdiV7", + "3ODA97K3uEFxJ1SaaHD282FPaToMiVDdql5ZHCDeB7+ab7lKxlTcaE4p252wAE1EL5TUBnCMApuP9yp/", + "MrGSawoj3Heqib3XDYK2MRq9Go36/z8TuC8bf9sviN+Xb4Pum+3b3BubfxuN+ps/mF++fNvp3i5GkuuO", + "OKaSUDjjWNTUrVT+SscdUxX2HM48psSa03mWto9BmIpQgYD8gzUfelyk+arWuPHooRlR7gRi7dHDfOt3", + "OoK43dvZW/kIYhvN60zNkAovsgv5YOcGc5O26PygJe6Ohwhr5TMFjqX3+PWe0N5KJg2nO72mlVrt1OGK", + "LLQAOE9b3Vuq1WaEeyVSH+h4YI5XGpLj7mUl6nLdajzYltqgFzV8ck8sn3cp2/mF/vrn8445cjlGuMgN", + "p709T73n52DPU2Lr7Xk6C3V2/SJznx7Dvtvu78vC2/YfsdjAeiy9lc5HMfmFVXKYdQvKGMh4wfI7Ie8V", + "4INCpFuIZbTeXRjHVNEARsNI3G0U6TGZuzaj0qROqI+C1dvQ7H6nRtS22l3G0hDHtRTeRR7nsrtwtZZh", + "VQc1VaJLCv2Dlqp4pOoPLVXLqn7bus8opKrjDtr8gVJFHLO47AGBtVik5300IDeLSx+vyILCRod98Vw+", + "yhRqF31tUzhPXrZOHmzrJJLz/fCFLyXDe5T0IOeYC0jEMljxd78pk0e+6ipd0QmAWZpeEIQgcu1etFUt", + "K+2XKOZ52Sz5H7hZEuVg/gVqfNXtkHnyXPZC5okbOJknLrRknjw8RFIwqetFR+bJk9j8cFqUpRyoefLQ", + "kMg8abEFMk/WEl6WpfHxNj986vFFq/SyCfLomyDz5EntgBxSAg6amGY1hfAIntajHCt5zF2UebJSPHxX", + "Zf68Q+GTw9NFxiH0ojuahpPDU7dpaFdk9+TwtKHIbuhFPbUSve21F9nd7u3s3oum330u58RWmoEHMiCa", + "rcLIVf2JTVWyLm8o8RiY2Dx9VzGcPmZszo3no+xSSaN8m9XDJem/bBhrO1mtlkDT15mL7TjcIGaIFVoA", + "mIP0i7S1MaUBgjpxH4sANczarIjtqNcXk+lKTnf5+mWEonGa62gqHqZZ7th67uizVT96k819PGqpuSK5", + "FdWNqj6MQ5JWD1x99op6dsmbd6SW1UGmeQWYhPQctpjq2rtCgllndvSVPdGcXn+kTOqMSPd1MXe/CkZz", + "QfsTI5nGczTGkC6Mu1RzZ+Yj50nRCHm1yImcnFrcpGhEBm962z9dDKQFMUbEUTuIBkvRfUE1yt50uc79", + "Jn93HXUFx9C7QsRXnMMRu0YMxEwXC4WxmJVPuzbhoRnzuea1ztH5Hw1yhl60XbrM5hlBnSnnLtbsK0Gd", + "GUM9B7gzo7Z06c2JFxV7lj88INDpsLHrAjrTpu8EdO70tnfWe9HMDBI/QGDDjqEvZXmzcvmMXLLIOhA1", + "wRqnIepJy75Udb9cyw+FnNq1WLZ2ZaOHdD/BZh2eVXSgFgJZbeLokkewvJ1/PjHsnUEpy0HtQKmnLniP", + "gnJpVbYelCt1dJcL5dNYbkFQGeIQXagfa1s4GZ4cW2vWMihVtapzUaN18Z0zj/9s6l0+lt6VKorScdYj", + "WT2atXS1jGe7nZjhZULw+nGXay4z3FS2zQYOy/HAL7Xwghz/JCaeniEsnCKhilPoQ+TuYhjZifWJLkOP", + "5hHypLRlh9bXAWTIIMt5eU4sGihM17+ZVN0I4ILFnogZWjNeIml3cle/bSWEogDnF8XJKbWIceOsG43t", + "4Ntlihy3xXuyW3FrijY4ePni4tSUDsw51W3KOJjiDbaaQ8E26++d5TAc1y3QWJiUN5XTlt6p8U2x+y2I", + "AuihGQ18zfc578p5C1OnnMzWf/XcsrRaViNM101NrIsj6mqZoDnyYkn7ISW6mIazsr8tx2MKsqgcQ89+", + "AQOQNpPCmLq//CKZgwB967T8BmMxk7rIk5b+C/hffwWCxWg1INzRn77zIqvG5GbE5WuJHLAxFgyyJF86", + "JMWEdTGujQlDqCe9EnCFki19iUGqAjc767gp2THktARMTf5bfRpcc5GYeo7KlZRbwXaCOtuZje+/uNXo", + "lczVwbKmzyxOBVHWVysUOKU5R9bNR9oeP4+6NDzhAoWn6yUbkgQoboCB2SerishaqF9RVnI18h5EbLqd", + "SUBveIP4LKjhb6vHN9cLzNuvtVaDvBNQUir91VQ9qtYTKdTca++TtKsi5XJBVAU0ywXddVZtcvHAZe2F", + "//ZJunFQvMNig2MyDRAQkE2RkL5Geo2ZtC2UILO/UQyBgs6X227xR8klX26/lIt1zqjkyxuGbWEee04B", + "xuoWzFKxaO0VcDCjN0rcfqFc2Lp9mBvn19cFIc2+gi0uZ9HtPvhdtv078FGApuo6BLUpwRQV5oNjck2T", + "LriZYW9mniBe6THmVofaxoEXxFwgpprsg99DSGIY/C5jBml9OJBdh1Aqjqw/c2Eg8gSX/5UyVqpxavYZ", + "bFU6PTW6bScPKpGsXjJmL6ATFEAQMaS0FPJT6o/yWssRMTuqjR9hhjyRcs/l2UfZujo8Ym/9L19nOhMi", + "2t/aihj1e+a7/b3BYLAFI7x1vVOoAs1wOx1Q2Bmrbko4fzUVFPe/1clwpgmye+nGCLJCMngOrVPFIavN", + "lcRVPW13E1O5wGdlCOpWwOqi/KwuC1QXMU/KNUaLmFyEvL6FkZe57qIESOgSos6rLsxOfuUYkeJ6DxJC", + "BZCco39tt9qlq/qr5kzXbG+Is+wboGfu7M+ug904v3ynEc5f0fg8Hqvy+K2LMJuq/AqPI0P9ybajGnRd", + "DsTaD2NlV1ktcxqr9jDWnY9iyeE8+CGswi1aj5HIUY3M0whfKuMgyN8LHZNAXSWjQ3wfkbuneSztxx6c", + "Dtdy3MkxJ4cqvw1c5zIC+JbZw89f5Fe9kQK6rgN0NGdaU41JMxN7+p5CbY8qx5KKG/Y1DNAfjXo1y88h", + "8cd0vjRp5jsnXeZZ7+70lREcOYlO89MmRSCzFamOdbSlIOcJtXuUUF+0oyNmneQutcCp2aQHFzr3peyu", + "HJ9fqPfkVIWQwKm9H7WYw2ITNqrtvjcZASOiL8I1/wbGiwwQMwEvr2n2/x6cfJQ+rwoXtVOiNU1CYIg9", + "GATJiNjPjH+oohEGNpQHqRMINsE1hmB+dC6ZUVCPBrlcl0kcBODw7PIIBHiCvMQL0IjYKyZKJCmdzxAM", + "1MaT2RFLqyfrntVoX736gBLwM4JC0rX/6tWI9MB5PA6xaDFU+fJZ2kuu5LdyVJWpZ0hSj8lUvvtvxGjP", + "pzdEve+6BI/L104lF3Ghr6NRV4jrAZ3/4yMWSL7xjxixpOlGBn0LkMbwO47l1FojVXYdHW7r2/mJ1P77", + "ndf9Qf91J1coecveDTFFwuU0C4bRNQIwy9ZtvjVCDyrdMB2RMyRiRjgYQ469fF1vc7MBgt5MtbMhJaRr", + "NXHXZkF2QXZ+Sd21atKOUosx9I2lMZ5KHiP6reobBpI/x4nqsjb38VdtMs2MSr2rC43bva39korg6d5N", + "Rf00U9CQW+bqNXt95R7NtG5Vk0szX8nVdab7Vuo6t4hpmftchfY0CcHVdfpB1nPL7IUygV+yGjeK6XcG", + "gzSVRENAyi/RmVlb/+HaZ8i6dV+r0vaq5zSXxXVBSgWD2lvHLdcO21TrojmyI/eWnJ/GYj+FiyIcpAzt", + "fewmc1NfmnCr7qtT16JYcm1cULm7RMCpFHp14dOJtJlIZcN/UU6qKy/TGAWYXkhdVN/GtFRVbR9czEq6", + "fkQwt9YC+V0QGX3va/dcMEh4oFI4LMCibGKaNKyb1FZsRMZoKv3BFMQxWEL+omepaDEBe8DcpWxMXxY1", + "g7M4SM1fGnj8kPq6Hg3HmJjLzAp3wskP6kLX37d+VwMqRK6/b/2uOhEgQFAyFMmBQvJt+UO2f2d9LfnN", + "z8oVDLPQKNX8KVEeJdZ0msvqzBC4wxLo5D2tuM1W2DvqJy3YOJWobyYnsnOGuDhQUZPNTEwj185WhIQ0", + "5CjTU6dInMtfjNnIYhpliOyOsAFWNTCqmtn6FiEx9NVGahpL/ebeNFxie89Q5dify7bjLArU+VdPBmkf", + "FMoTQuJDQaXAyabyAZTF8G8VwpkOyaDEuTF1is8vGx+nuLJrUgpvFudOzf8WJteIKHKbaNLvUiaHq4DY", + "5zLT3TR24Ej8olrhhZYNnfaRHM+/eubWzN65SbrKAg+tGPUkWGNY/lb/2vixmyuyNGrdmEJNU8zTSEyf", + "38DpFLE+plvXO+qrYlOFmL1tavNtt6Uhql7Yd9stKIQEhkF7u+ZorhBOmoUt+R3ba7Orsv9iwrLDtlZt", + "mzOT97bb2X1Yk68sZnnbpXLP0qam7O3DUSaXNMCeAL3U2GozpYyoNGnWjMKAIegnAM0xF0/Ta9L8Uefm", + "NDlOt10dIW59w/6t9p8C5LpF4gyFVMaJxOFGTRgNGx0pgxpwQSM+IhqVqLo9mLv9HjAkvUmApzMBjCrk", + "wOwgohHJ8/f/UROQvsSQh/A1AruDXfCJCvAzjYnvii6P1KANKNcUXtoEiHgcFK9xzXBF6f7pSSwfn3Hk", + "paloyERqxgioi0GL2qUpJFtvxFM5wL8wla4msbnKJHpO7v1kQRvV6CRFKaDdh5PrKlnS455IFn2SOkbL", + "iFMBNEdmzcCTvtdQC3NZrVAGYHqYSnU7TgAWHGApxVKxYF9nvJirrjVbFC7ul5MKgbq+3yX575E4OB2+", + "S4b+yqKfi9mehcQv8DVK1Sru7DtV2msloPIb/iKTC2TyPXIA3kpI/AVoSSxcyTI+1BKuXB3nfoS25xr2", + "BnlARJ/BNZt+UFCzgWDk1DgAxiBzk1SiPywS38b6j0iqMZTfJlujQamlki8Qc1Tfq4FWhmFEmYBE7L96", + "BYaT8nWgvKtaSCenSLgu880B9AS+Ri5do+d3fV6GHsrD6ZxlsJZnFKmtVXuWDqO10jHOo19PPFJ7Ucq1", + "SrmNGm0dkm2Z3Kw2W3hBYJSP6k9+lPomxomyO3oGGhjry39jLuO04ST9h/KpCIB+iEkXMNOByj6R0Zu7", + "i9T9cW7cfZBDWI/aY3nX0ZLwHTheH1AxjXnBZo5iihdZXOwgqXIXpYnTsQUxQP7S20oVvOUKJW5ZMxtJ", + "RuDkax4kYIxGRBUIGSfAC7CqjSUoyOPPmediNqlkN1iP4ANKcr7JiJisfJxmXeleZW8qOnq90xsnsklI", + "fBrqa74BIh71dUGQGZpDH3k4hFK+iQ8ihiZ4jszmz6gDIxx9HXXUEAPqwUBNoipTYmotXWPfjE++g+Zm", + "aeRr/aZNHA2Tr0Mt2MvwrVp4UK1wL67RB6QrVWBKDNp/d//I2eZDw9kfUPJer5Yio1ndKJl5hlj2i1Zu", + "D1dbvbFYI7udo61vemvuEwzRAhT7ml4hfTzgGtOYB0mqOPxFmvwz8aRali343RGxekYqdEJBQMkUMbXH", + "zk3eq0udu7Shpmqt2lCT+dC6sNt0tNTObkpdA0X6NIKDoGydn5jrJtfQa63QDBe9KLTvQ6FZtUIsk68P", + "fZPcorywHBKe+lvKkzMHQNXRAD4ijAqY/5rbz9WJKIFYqA5FaScQEy4Q1KWxY0F7RhfKzylBBrwruHc2", + "ZwfaVKDtN8CbQQY9dThVasFG7Gs9Ku4pKTMN3zy0Mrsvj/MMTXOu2fq8Tne7Dw3PLe15Pilszqz6k1HR", + "Jo1fztNzgeTqVKo+3Xg3x3OLWR7XZ9Cd0EEqB9o5m+JrRNpACATdFHzVZw4luN1gO7zvAhho7wxbCl9s", + "yHdpQ5RH9uLsfz/OfqpRlscuPMSELg2MWm7p6PsSwMXHc5D/GHgxY4iIIAEBhX5W2Tn3EtDZuMqL56j4", + "OWS5GtXXiOFJIr3+Xy4uTs9z9R4oIUgdL+V1mzuH+RHdo+Tl+mm7TVKY7Cd92sUsslecS8tJuaG325+4", + "jCRPGIDNzUDWF6iyi96xyH4eERvzYQJOj0/MUdE+OJgIdVOt7Kvrbkw5DbYmiD5QypDhV+kdnB+dg41z", + "5DEkwBHmHr1GLAHniF1jD23Kr60DLiiIYq5zPQi6GZHSWPSJm4jROUb2qMyRPsgKtPu3/+oVOJxBMkUc", + "CHiFAJpMkCcADkPkYyhQoN1CGgvAkDoRY+ufTNOjto7AVg4nt0J3OZaSG1Nnv9OT/3t3/H74CRwen10M", + "fx4eHlwcq19H5GQ4PPrXxeHhwdWv04Ob4buD6fDvBx8+Di7f/xCefRD/OTkYvD88/+P9+XD8+ugfx+8O", + "by4PTo4v54d/Hvz93fTTP0ek3++PiGrt+NORo4fMxwiTnmaingfbp+Hn5kRP0iNtOOToaEwIz/GT5ukn", + "Y7JzlJnaMU8zyMornYJALFRkZdO4pbVEfRh1oqoDBQkQDE+niAEI9Cf2CHPB2KUJ6hMcIF2yTakfC92c", + "H53bMmoqU2wSBzJASmj8X9cIhLYv6EueKCyHbM+o0oJK4sBXpYMoS4wy+kS1ClLdIOJHFOvbgkQSad2o", + "/B6CkFKO3DAh10fykSmXNSIFdZoOXw++ZnehpKHubKbLBcgc+d/57kBF5d9TsWtBBQy+6gLM1VLP8qGu", + "zmx5xFBVsrpZkbid7b23b6undNsknbvHX1YnT06GU7EywrSsQ1KR40XHSmxWucMDAuMEDI+sm2EloMbR", + "UKdzi6JR4bqiN8H0gZZya1JVjMhjeRN6OoreRCMGMjyyiELJHzITnscT9vYG6KfdwaCHdt6Oe7vb/m4P", + "/rj9pre7++bN3t7u7mAweA5nUloOo905lTwn22Mh96qlllQeT+OsSp6g53FKZSUHRIEQX/04jBaH5sVz", + "KzoWV5UvUojPUcAFEy+IfUym++o0fXOlFfuK0WKV0qvpCwxNMReIlUyZKoqjfR3NoYqx7WFnXTWo5IoY", + "10eVgUfjeDrFZNoFISVYUKb+lk2MoXcVR1mBePexGnN/j5zM+0QF0l6aD3s6DxiplX6SXByHUcpURZoV", + "i+U4Wq+w4eAZgoGuBVnHvKpUj+RO/arljFqWrazsL+q7wxnyrtbrRrq0qCYycV91EEqrqkV1xTtTK2vj", + "ElkOLBXFNdITATw5E6kQ1S1MEITpXdI9gcIoaAkAFsoymTuRVSsgbaUOmNOXNquXL9IeW5dPss3X11D6", + "HCFycNfySet1FapFdl7fuchOt1NYr1a1gBxTX18baJkqPm4OeNrYZg3NmaTIF+x0rVDWx9m+69RaxtKq", + "MDAfEUGvkCqc6F3ZSsUh9VEA0Fz+aM516ao+uZJnDWV47HLrMgLVqjsXqsdsr5Lrd2JuKtGpCnWqejZK", + "b4uor4Lj4LPO/WzsuXq625aeu8UHRQYdJCyutFHDbi/VNlpW20glpFxy45mV2XDyQSutVu8QLFGFw82G", + "TZU4auEGtxa5U+pFSpAbiVD1KvEzwBpSQtuhCe5FebTKF0uQ89CIgpu051IBY3XZX185jDoaKoG4Q7zX", + "Uu+ijoAnKeZLugFrLYLRpv3Wsvs4hTGeo7i+R6LGSpYLZDQGIC1z9dtEIbV58fdsgTWQfa+i+V1HHPeq", + "ahZXjHCz1kvViO9OY7VVKytFGXdCG/kihHEJZLEwpAXoYjq2+6vSXiDnYcu1F7qur9te1NXfY9n21ssz", + "o1xULjjR82PuD3Euk/ns8WBo932DeclcFlduqjV/b0Xkiyrh2aDOdwObjxQHu1AfB8acKTaNMeur/CiQ", + "y86gJ0ZEnzzSMSTXia7dbGM4S722W0q8mz/MozJgoFpKe4lw1xy0MfWu+23Q4vtHiddZ16uh2Wa7X3RL", + "3FfedB4JcV4SabYAs84GNEkDL2jzIrQ5FfXvqMBzni9WcwVXxZkb4OXF2HLLiLYaypbGmzsgx+lOT1v/", + "XlRyFJ9yUWU32StAzE8DWX56gPJzxJEfET5egBovgRZ/D8Lb0n7fF0S8JDT8JBDhZwYEK/zXMuoacGBR", + "vitKQSg1KM5iCPi5ydp3GUdcGni1Pp7oPBJwvCRg/HRx4hedtTIUvKzbP8crZ5uqT+vRX/N4Oex3noAi", + "cvt4uO88eRzQd568IL4LF+Z7g3utGC4B9s6Tx0R6FcHPAec1ami9KK+WUQfEO0/a4bv2VQPXmQod5vxf", + "HvWtILxLALrz5F7R3Hmyfhes2madrS4vwdMBcedJawR3nrzAtyvDt/PkO8Ru58kqHtzysO08WRmznSd3", + "jUNVC6Ug1Kce70HOMRdQHZd6FmBtheqlsFplAh4XqK0j4ZEisHny3CDalgK7dnxW9VsDzs6TdSCzz0RK", + "2xjke4BkHY02ytijgrFPXqxySOw8kbFeXGbOB0RjHaKVh2Kfl/37zrz/EvpajgIeAXqdJ61x13nyAro+", + "P91Uj7gu4ayHXrQq3JpGhSeHpybuKdYD0WFQ7hiyLecwhhx7ABMdCquoaExjARD0ZrnWNqR66drIqWvh", + "x24eERQ4RJt1BQVODk+XBnxl9wXAt5rpe5JkRN4f3JsR8rBwb9ZvPdyLrhFLxKwed/0uIN/7Bl33XKBr", + "6EVflwVeDZ8/DvBaJ/1PG4WtpTpTnNkry5d4qGne1rB1HHbWRWzzL6sKcel1xV0QSbnm6k9IfCAYJDyw", + "xeF0/bf50TlgiNOYeYjrJvVFxiMyRlNMOGA01mXdGJzIqM2ejcwIrtxnXIvmWra7JzTXNr9Of66uzQet", + "4pASsRCOrWOjl+INbfHYIl9/J5hsDVss1l0lj28JeLaOE5vqN+T0D+CCRnxEoOehyKGAMG/SQI4b1VNQ", + "akTyQlC6Q91c3w52B7vgExXgZ+nM19eVyCm0Ox1lzYaSVpMoX3qeeVA9uSBP5srfZmzZTXU7bLmOgx4N", + "aV6KoIcOQuuIey4o9Moqan2AdNbBOAFYcIB9cz8c9vXdQAbM00yTl+MNOc0QXF4OjzZrqkVaXXGnyhRl", + "ffFctMQC72atsLarvSVk+XHw7ecpvu+RqDX05RIU9dFRy/oTNR1pr0HDkSAfACmPzdzxBaCgoa6abUTa", + "uBnG4OuKoRbTLA6kjXcxIqmKUW6jbI0GpZZKvkbMUX2vpgbfMIwoE5CI/VevwHACSr4y17XC0ykqEs5Q", + "CGUIBz2Br1F9bY578WL0qB5UP333EeVg7QNbjPjXifdLcY7vUJ+317rtQkeb4Nf2HrDSfV/VguBZ+Ojl", + "y/6dVl+EDNlm1Df6HhOTn2jpyq4wAVBIHZ4WRVbXGcSRsiHq+gWV2xiiUF934tw9OLWjvUfB1SNtez1Y", + "dQKfNsiaq/PuID1jObPeRX6TTao+XLbrI/VgAHx0jQIaqS+6nZgFnf3OTIhof2srkC/MKBf7bwdvB53q", + "ZsMR9a4Q2/oQjxEjSN1/k246lBsz+a+9jKFMq1/SMVTQYF3H3hQt12ynCpenVRIy42gKb1dpPDy7PAIp", + "Y3IV4FTL7mcNlS7wa9dgAxJumnVqhGrjZ2q1swwGhmIOxwFyr71pu7r01Yb1w9p7BeUgSrcAqusBjQRk", + "fdXcpXD75fa/AwAA//9RcyYFtzYBAA==", } // GetSwagger returns the content of the embedded swagger specification file diff --git a/gateway/gateway-controller/pkg/api/handlers/handlers.go b/gateway/gateway-controller/pkg/api/handlers/handlers.go index ad49986d1..68f8833bb 100644 --- a/gateway/gateway-controller/pkg/api/handlers/handlers.go +++ b/gateway/gateway-controller/pkg/api/handlers/handlers.go @@ -2485,6 +2485,77 @@ func (s *APIServer) RevokeAPIKey(c *gin.Context, id string, apiKeyName string) { c.JSON(http.StatusOK, result.Response) } +// UpdateAPIKey implements ServerInterface.UpdateAPIKey +// (PUT /apis/{id}/api-keys/{apiKeyName}) +func (s *APIServer) UpdateAPIKey(c *gin.Context, id string, apiKeyName string) { + // Get correlation-aware logger from context + log := middleware.GetLogger(c, s.logger) + handle := id + correlationID := middleware.GetCorrelationID(c) + + // Extract authenticated user from context + user, ok := s.extractAuthenticatedUser(c, "UpdateAPIKey", correlationID) + if !ok { + return // Error response already sent by extractAuthenticatedUser + } + + log.Debug("Starting API key rotation", + slog.String("handle", handle), + slog.String("key name", apiKeyName), + slog.String("user", user.UserID), + slog.String("correlation_id", correlationID)) + + // Parse and validate request body + var request api.APIKeyRegenerationRequest + if err := c.ShouldBindJSON(&request); err != nil { + log.Warn("Invalid request body for API key rotation", + slog.Any("error", err), + slog.String("handle", handle), + slog.String("correlation_id", correlationID)) + c.JSON(http.StatusBadRequest, api.ErrorResponse{ + Status: "error", + Message: fmt.Sprintf("Invalid request body: %v", err), + }) + return + } + + // Prepare parameters + params := utils.APIKeyUpdateParams{ + Handle: handle, + APIKeyName: apiKeyName, + Request: request, + User: user, + CorrelationID: correlationID, + Logger: log, + } + + result, err := s.apiKeyService.UpdateAPIKey(params) + if err != nil { + // Check error type to determine appropriate status code + if strings.Contains(err.Error(), "not found") { + c.JSON(http.StatusNotFound, api.ErrorResponse{ + Status: "error", + Message: err.Error(), + }) + } else { + c.JSON(http.StatusInternalServerError, api.ErrorResponse{ + Status: "error", + Message: err.Error(), + }) + } + return + } + + log.Info("API key rotation completed", + slog.String("handle", handle), + slog.String("key_name", apiKeyName), + slog.String("user", user.UserID), + slog.String("correlation_id", correlationID)) + + // Return the response using the generated schema + c.JSON(http.StatusOK, result.Response) +} + // RegenerateAPIKey implements ServerInterface.RegenerateAPIKey // (POST /apis/{id}/api-keys/{apiKeyName}/regenerate) func (s *APIServer) RegenerateAPIKey(c *gin.Context, id string, apiKeyName string) { diff --git a/gateway/gateway-controller/pkg/controlplane/client.go b/gateway/gateway-controller/pkg/controlplane/client.go index 8ec9bb59d..75c22ff4d 100644 --- a/gateway/gateway-controller/pkg/controlplane/client.go +++ b/gateway/gateway-controller/pkg/controlplane/client.go @@ -523,6 +523,8 @@ func (c *Client) handleMessage(messageType int, message []byte) { c.handleAPIUndeployedEvent(event) case "apikey.created": c.handleAPIKeyCreatedEvent(event) + case "apikey.updated": + c.handleAPIKeyUpdatedEvent(event) case "apikey.revoked": c.handleAPIKeyRevokedEvent(event) default: @@ -781,6 +783,94 @@ func (c *Client) handleAPIKeyRevokedEvent(event map[string]interface{}) { ) } +// handleAPIKeyUpdatedEvent handles API key updated events from platform-api +func (c *Client) handleAPIKeyUpdatedEvent(event map[string]interface{}) { + c.logger.Info("API Key Updated Event", + slog.Any("payload", event["payload"]), + slog.Any("timestamp", event["timestamp"]), + slog.Any("correlationId", event["correlationId"]), + ) + + // Parse the event into structured format + eventBytes, err := json.Marshal(event) + if err != nil { + c.logger.Error("Failed to marshal event for parsing", + slog.Any("error", err), + ) + return + } + + var keyUpdatedEvent APIKeyUpdatedEvent + if err := json.Unmarshal(eventBytes, &keyUpdatedEvent); err != nil { + c.logger.Error("Failed to parse API key updated event", + slog.Any("error", err), + ) + return + } + + // Extract event payload + payload := keyUpdatedEvent.Payload + + // Validate required fields + if payload.ApiId == "" { + c.logger.Error("API ID is empty in API key updated event") + return + } + if payload.KeyName == "" { + c.logger.Error("Key name is empty in API key updated event") + return + } + if payload.ApiKey == "" { + c.logger.Error("API key is empty in API key updated event") + return + } + + c.logger.Info("Processing API key update", + slog.String("api_id", payload.ApiId), + slog.String("key_name", payload.KeyName), + slog.String("correlation_id", keyUpdatedEvent.CorrelationID), + ) + + // Parse expiration time if provided + var expiresAt *time.Time + if payload.ExpiresAt != nil && *payload.ExpiresAt != "" { + parsedTime, err := time.Parse(time.RFC3339, *payload.ExpiresAt) + if err != nil { + c.logger.Warn("Failed to parse expiration time, proceeding without expiry", + slog.String("expires_at", *payload.ExpiresAt), + slog.Any("error", err), + ) + } else { + expiresAt = &parsedTime + } + } + + // Update the external API key + err = c.apiKeyService.UpdateExternalAPIKeyFromEvent( + payload.ApiId, + payload.KeyName, + payload.ApiKey, // Plain text API key from platform-api + expiresAt, + c.logger, + ) + + if err != nil { + c.logger.Error("Failed to update external API key", + slog.String("api_id", payload.ApiId), + slog.String("key_name", payload.KeyName), + slog.String("correlation_id", keyUpdatedEvent.CorrelationID), + slog.Any("error", err), + ) + return + } + + c.logger.Info("Successfully processed API key updated event", + slog.String("api_id", payload.ApiId), + slog.String("key_name", payload.KeyName), + slog.String("correlation_id", keyUpdatedEvent.CorrelationID), + ) +} + // calculateNextRetryDelay calculates the next retry delay with exponential backoff and jitter func (c *Client) calculateNextRetryDelay() { // Exponential backoff: initial * 2^retries diff --git a/gateway/gateway-controller/pkg/controlplane/events.go b/gateway/gateway-controller/pkg/controlplane/events.go index 4e242ef21..3d6d4fd23 100644 --- a/gateway/gateway-controller/pkg/controlplane/events.go +++ b/gateway/gateway-controller/pkg/controlplane/events.go @@ -75,3 +75,19 @@ type APIKeyRevokedEvent struct { Timestamp string `json:"timestamp"` CorrelationID string `json:"correlationId"` } + +// APIKeyUpdatedEventPayload represents the payload of an API key updated event +type APIKeyUpdatedEventPayload struct { + ApiId string `json:"apiId"` + KeyName string `json:"keyName"` + ApiKey string `json:"apiKey"` // Plain text API key (will be hashed by gateway) + ExpiresAt *string `json:"expiresAt,omitempty"` // ISO 8601 format +} + +// APIKeyUpdatedEvent represents the complete API key updated event +type APIKeyUpdatedEvent struct { + Type string `json:"type"` + Payload APIKeyUpdatedEventPayload `json:"payload"` + Timestamp string `json:"timestamp"` + CorrelationID string `json:"correlationId"` +} diff --git a/gateway/gateway-controller/pkg/utils/api_key.go b/gateway/gateway-controller/pkg/utils/api_key.go index e4323c8ba..f6ae232da 100644 --- a/gateway/gateway-controller/pkg/utils/api_key.go +++ b/gateway/gateway-controller/pkg/utils/api_key.go @@ -90,6 +90,16 @@ type APIKeyRegenerationResult struct { IsRetry bool // Whether this was a retry due to collision } +// APIKeyUpdateParams contains parameters for API key update operations +type APIKeyUpdateParams struct { + Handle string // API handle/ID + APIKeyName string // Name of the API key to update + Request api.APIKeyRegenerationRequest // Request body with update details + User *commonmodels.AuthContext // User who initiated the request + CorrelationID string // Correlation ID for tracking + Logger *slog.Logger // Logger instance +} + // ListAPIKeyParams contains parameters for listing API keys type ListAPIKeyParams struct { Handle string // API handle/ID @@ -486,6 +496,145 @@ func (s *APIKeyService) RevokeAPIKey(params APIKeyRevocationParams) (*APIKeyRevo return result, nil } +// UpdateAPIKey updates an existing API key with a specific provided value +func (s *APIKeyService) UpdateAPIKey(params APIKeyUpdateParams) (*APIKeyRegenerationResult, error) { + logger := params.Logger + if logger == nil { + logger = slog.Default() + } + user := params.User + + logger.Info("Starting API key update", + slog.String("handle", params.Handle), + slog.String("api_key_name", params.APIKeyName), + slog.String("user", user.UserID), + slog.String("correlation_id", params.CorrelationID)) + + // Get the API configuration + config, err := s.store.GetByHandle(params.Handle) + if err != nil { + logger.Warn("API configuration not found for API Key update", + slog.String("handle", params.Handle), + slog.String("correlation_id", params.CorrelationID)) + return nil, fmt.Errorf("API configuration handle '%s' not found", params.Handle) + } + + // Get the existing API key by name + existingKey, err := s.store.GetAPIKeyByName(config.ID, params.APIKeyName) + if err != nil { + logger.Warn("API key not found for update", + slog.String("handle", params.Handle), + slog.String("api_key_name", params.APIKeyName), + slog.String("correlation_id", params.CorrelationID)) + return nil, fmt.Errorf("API key '%s' not found for API '%s'", params.APIKeyName, params.Handle) + } + + err = s.canRegenerateAPIKey(user, existingKey, logger) + if err != nil { + logger.Warn("User attempting to update API key is not the creator", + slog.String("handle", params.Handle), + slog.String("api_key_name", params.APIKeyName), + slog.String("creator", existingKey.CreatedBy), + slog.String("requesting_user", user.UserID), + slog.String("correlation_id", params.CorrelationID)) + return nil, fmt.Errorf("API key regeneration failed for API: '%s'", params.Handle) + } + + // Update API key using the extracted helper method + updatedKey, err := s.updateAPIKey(existingKey, params.Request, user.UserID, logger) + if err != nil { + logger.Error("Failed to update API key", + slog.Any("error", err), + slog.String("handle", params.Handle), + slog.String("correlation_id", params.CorrelationID)) + return nil, fmt.Errorf("failed to update API key: %w", err) + } + + result := &APIKeyRegenerationResult{ + IsRetry: false, + } + + // Save updated API key to database (only if persistent mode) + if s.db != nil { + if err := s.db.SaveAPIKey(updatedKey); err != nil { + if errors.Is(err, storage.ErrConflict) { + logger.Error("API key already exists in the system", + slog.Any("error", err), + slog.String("correlation_id", params.CorrelationID)) + return nil, fmt.Errorf("API key already exists in the system: %w", err) + } + + logger.Error("Failed to save updated API key to database", + slog.Any("error", err), + slog.String("handle", params.Handle), + slog.String("correlation_id", params.CorrelationID)) + return nil, fmt.Errorf("failed to save updated API key to database: %w", err) + } + } + + plainAPIKey := updatedKey.PlainAPIKey // Store plain API key for response + updatedKey.PlainAPIKey = "" // Clear plain API key from the struct for security + + // Store the generated API key in the ConfigStore + if err := s.store.StoreAPIKey(updatedKey); err != nil { + logger.Error("Failed to store the updated API key in ConfigStore", + slog.Any("error", err), + slog.String("handle", params.Handle), + slog.String("correlation_id", params.CorrelationID)) + + // Rollback database save to maintain consistency + if s.db != nil { + if delErr := s.db.RemoveAPIKeyAPIAndName(updatedKey.APIId, updatedKey.Name); delErr != nil { + logger.Error("Failed to rollback API key from database", + slog.Any("error", delErr), + slog.String("correlation_id", params.CorrelationID)) + } + } + return nil, fmt.Errorf("failed to store updated API key in ConfigStore: %w", err) + } + + apiConfig, err := config.Configuration.Spec.AsAPIConfigData() + if err != nil { + logger.Error("Failed to parse API configuration data", + slog.Any("error", err), + slog.String("handle", params.Handle), + slog.String("correlation_id", params.CorrelationID)) + return nil, fmt.Errorf("failed to parse API configuration data: %w", err) + } + + apiId := config.ID + apiName := apiConfig.DisplayName + apiVersion := apiConfig.Version + logger.Info("Storing API key in policy engine", + slog.String("handle", params.Handle), + slog.String("name", updatedKey.Name), + slog.String("api_name", apiName), + slog.String("api_version", apiVersion), + slog.String("user", user.UserID), + slog.String("correlation_id", params.CorrelationID)) + + // Update xDS snapshot if needed + if s.xdsManager != nil { + if err := s.xdsManager.StoreAPIKey(apiId, apiName, apiVersion, updatedKey, params.CorrelationID); err != nil { + logger.Error("Failed to send updated API key to policy engine", + slog.Any("error", err), + slog.String("correlation_id", params.CorrelationID)) + return nil, fmt.Errorf("failed to send updated API key to policy engine: %w", err) + } + } + + // Build and return the response + result.Response = s.buildAPIKeyResponse(updatedKey, params.Handle, plainAPIKey, false) + + logger.Info("API key update completed successfully", + slog.String("handle", params.Handle), + slog.String("api_key_name", params.APIKeyName), + slog.String("new_key_id", updatedKey.ID), + slog.String("correlation_id", params.CorrelationID)) + + return result, nil +} + // RegenerateAPIKey regenerates an existing API key func (s *APIKeyService) RegenerateAPIKey(params APIKeyRegenerationParams) (*APIKeyRegenerationResult, error) { logger := params.Logger @@ -974,6 +1123,132 @@ func (s *APIKeyService) buildAPIKeyResponse(key *models.APIKey, handle string, p } } +// UpdateAPIKey updates an existing API key with a specific provided value +func (s *APIKeyService) updateAPIKey(existingKey *models.APIKey, request api.APIKeyRegenerationRequest, + user string, logger *slog.Logger) (*models.APIKey, error) { + // Generate new API key value + plainAPIKeyValue := strings.TrimSpace(*request.ApiKey) + + // Hash the new API key for storage + hashedAPIKeyValue, err := s.hashAPIKey(plainAPIKeyValue) + if err != nil { + return nil, fmt.Errorf("failed to hash regenerated API key: %w", err) + } + + // Generate masked API key for display purposes + maskedAPIKeyValue := s.MaskAPIKey(plainAPIKeyValue) + + now := time.Now() + + // Determine expiration settings based on request and existing key + var expiresAt *time.Time + var unit *string + var duration *int + + if request.ExpiresAt != nil { + // If expires_at is explicitly provided, use it + expiresAt = request.ExpiresAt + logger.Info("Using provided expires_at for update", slog.Time("expires_at", *expiresAt)) + } else if request.ExpiresIn != nil { + // If expires_in is provided, calculate expires_at from now + unitStr := string(request.ExpiresIn.Unit) + unit = &unitStr + duration = &request.ExpiresIn.Duration + + timeDuration := time.Duration(request.ExpiresIn.Duration) + switch request.ExpiresIn.Unit { + case api.APIKeyRegenerationRequestExpiresInUnitSeconds: + timeDuration *= time.Second + case api.APIKeyRegenerationRequestExpiresInUnitMinutes: + timeDuration *= time.Minute + case api.APIKeyRegenerationRequestExpiresInUnitHours: + timeDuration *= time.Hour + case api.APIKeyRegenerationRequestExpiresInUnitDays: + timeDuration *= 24 * time.Hour + case api.APIKeyRegenerationRequestExpiresInUnitWeeks: + timeDuration *= 7 * 24 * time.Hour + case api.APIKeyRegenerationRequestExpiresInUnitMonths: + timeDuration *= 30 * 24 * time.Hour + default: + return nil, fmt.Errorf("unsupported expiration unit: %s", request.ExpiresIn.Unit) + } + expiry := now.Add(timeDuration) + expiresAt = &expiry + logger.Info("Using provided expires_in for update", + slog.String("unit", unitStr), + slog.Int("duration", *duration), + slog.Time("calculated_expires_at", *expiresAt)) + } else { + // No expiration provided in request, use existing key's logic + if existingKey.Unit != nil && existingKey.Duration != nil { + // Existing key has duration/unit, apply same duration from now + unit = existingKey.Unit + duration = existingKey.Duration + + timeDuration := time.Duration(*existingKey.Duration) + switch *existingKey.Unit { + case string(api.APIKeyRegenerationRequestExpiresInUnitSeconds): + timeDuration *= time.Second + case string(api.APIKeyRegenerationRequestExpiresInUnitMinutes): + timeDuration *= time.Minute + case string(api.APIKeyRegenerationRequestExpiresInUnitHours): + timeDuration *= time.Hour + case string(api.APIKeyRegenerationRequestExpiresInUnitDays): + timeDuration *= 24 * time.Hour + case string(api.APIKeyRegenerationRequestExpiresInUnitWeeks): + timeDuration *= 7 * 24 * time.Hour + case string(api.APIKeyRegenerationRequestExpiresInUnitMonths): + timeDuration *= 30 * 24 * time.Hour + default: + return nil, fmt.Errorf("unsupported existing expiration unit: %s", *existingKey.Unit) + } + expiry := now.Add(timeDuration) + expiresAt = &expiry + logger.Info("Using existing key's duration settings for update", + slog.String("unit", *unit), + slog.Int("duration", *duration), + slog.Time("calculated_expires_at", *expiresAt)) + } else if existingKey.ExpiresAt != nil { + // Existing key has absolute expiry, use same expiry + expiresAt = existingKey.ExpiresAt + logger.Info("Using existing key's expires_at for update", slog.Time("expires_at", *expiresAt)) + } else { + // Existing key has no expiry, new key also has no expiry + expiresAt = nil + logger.Info("No expiry set for updated key (matching existing key)") + } + } + + // Validate that expiresAt is in the future (if set) + if expiresAt != nil && expiresAt.Before(now) { + return nil, fmt.Errorf("API key expiration time must be in the future, got: %s (current time: %s)", + expiresAt.Format(time.RFC3339), now.Format(time.RFC3339)) + } + + // Create the regenerated API key + updatedKey := &models.APIKey{ + ID: existingKey.ID, + Name: existingKey.Name, + APIKey: hashedAPIKeyValue, // Store hashed key + MaskedAPIKey: maskedAPIKeyValue, // Store masked key for display + APIId: existingKey.APIId, + Operations: existingKey.Operations, + Status: models.APIKeyStatusActive, + CreatedAt: existingKey.CreatedAt, + CreatedBy: existingKey.CreatedBy, + UpdatedAt: now, + ExpiresAt: expiresAt, + Unit: unit, + Duration: duration, + Source: existingKey.Source, // Preserve source from original key + } + + // Temporarily store the plain key for response generation + updatedKey.PlainAPIKey = plainAPIKeyValue + + return updatedKey, nil +} + // regenerateAPIKey creates a new API key for regeneration based on existing key and request parameters func (s *APIKeyService) regenerateAPIKey(existingKey *models.APIKey, request api.APIKeyRegenerationRequest, user string, logger *slog.Logger) (*models.APIKey, error) { @@ -1883,3 +2158,119 @@ func (s *APIKeyService) RevokeExternalAPIKeyFromEvent( return nil } + +// UpdateExternalAPIKeyFromEvent updates an API key from an external event (websocket). +// This is used when platform-api broadcasts an apikey.updated event. +func (s *APIKeyService) UpdateExternalAPIKeyFromEvent( + apiId string, + keyName string, + plainAPIKey string, + expiresAt *time.Time, + logger *slog.Logger, +) error { + logger.Info("Updating external API key from event", + slog.String("api_id", apiId), + slog.String("key_name", keyName), + slog.Bool("has_expiry", expiresAt != nil), + ) + + // Validate inputs + if apiId == "" { + return fmt.Errorf("API ID cannot be empty") + } + if keyName == "" { + return fmt.Errorf("key name cannot be empty") + } + if plainAPIKey == "" { + return fmt.Errorf("API key cannot be empty") + } + + // Validate API key length + if len(plainAPIKey) < 16 { + return fmt.Errorf("API key is too short (minimum 16 characters required)") + } + + // Check if API exists + config, err := s.store.Get(apiId) + if err != nil { + return fmt.Errorf("API not found: %s", apiId) + } + + // Get API keys for this API + var apiKeys []*models.APIKey + if s.db != nil { + apiKeys, err = s.db.GetAPIKeysByAPI(apiId) + if err != nil { + if errors.Is(err, storage.ErrNotFound) { + return fmt.Errorf("no API keys found for API: %s", apiId) + } + return fmt.Errorf("failed to get API keys: %w", err) + } + } else { + apiKeys, err = s.store.GetAPIKeysByAPI(apiId) + if err != nil { + return fmt.Errorf("failed to get API keys from store: %w", err) + } + } + + // Find the key by name + var targetKey *models.APIKey + for _, key := range apiKeys { + if key.Name == keyName { + targetKey = key + break + } + } + + if targetKey == nil { + return fmt.Errorf("API key not found: %s", keyName) + } + + // Hash the new API key + hashedAPIKey, err := s.hashAPIKey(plainAPIKey) + if err != nil { + return fmt.Errorf("failed to hash API key: %w", err) + } + + // Update the key with new values + targetKey.APIKey = hashedAPIKey + targetKey.MaskedAPIKey = s.MaskAPIKey(plainAPIKey) + targetKey.Status = models.APIKeyStatusActive + targetKey.UpdatedAt = time.Now() + targetKey.ExpiresAt = expiresAt + // Preserve source and other metadata + if targetKey.Source == "" { + targetKey.Source = "external" + } + + // Update in database + if s.db != nil { + if err := s.db.UpdateAPIKey(targetKey); err != nil { + return fmt.Errorf("failed to update API key: %w", err) + } + } + + // Update in-memory ConfigStore + if err := s.store.StoreAPIKey(targetKey); err != nil { + return fmt.Errorf("failed to update API key in config store: %w", err) + } + + // Trigger xDS snapshot update via xdsManager (log only, do not fail) + if s.xdsManager != nil { + if err := s.xdsManager.StoreAPIKey(apiId, config.GetDisplayName(), config.GetVersion(), targetKey, "external-update"); err != nil { + logger.Error("Failed to update xDS snapshot after API key update", + slog.String("api_id", apiId), + slog.Any("error", err), + ) + // Don't return error - key is already updated in DB/store + } + } + + logger.Info("Successfully updated external API key", + slog.String("api_id", apiId), + slog.String("key_name", keyName), + slog.String("key_id", targetKey.ID), + ) + + return nil +} diff --git a/platform-api/src/internal/dto/apikey.go b/platform-api/src/internal/dto/apikey.go index 7d9917d47..52732a662 100644 --- a/platform-api/src/internal/dto/apikey.go +++ b/platform-api/src/internal/dto/apikey.go @@ -48,6 +48,28 @@ type CreateAPIKeyResponse struct { KeyId string `json:"key_id,omitempty"` } +// UpdateAPIKeyRequest represents the request to update/regenerate an API key. +// This is used when Cloud APIM rotates API keys on hybrid gateways. +type UpdateAPIKeyRequest struct { + // ApiKey is the new plain text API key value that will be hashed before storage + ApiKey string `json:"api_key" binding:"required"` + + // ExpiresAt is the optional expiration time in ISO 8601 format + ExpiresAt *string `json:"expires_at,omitempty"` +} + +// UpdateAPIKeyResponse represents the response after updating an API key. +type UpdateAPIKeyResponse struct { + // Status indicates the result of the operation ("success" or "error") + Status string `json:"status"` + + // Message provides additional details about the operation result + Message string `json:"message"` + + // KeyId is the internal ID of the updated key + KeyId string `json:"key_id,omitempty"` +} + // RevokeAPIKeyResponse represents the response after revoking an API key. type RevokeAPIKeyResponse struct { // Status indicates the result of the operation ("success" or "error") diff --git a/platform-api/src/internal/handler/api_key.go b/platform-api/src/internal/handler/api_key.go index 7bc969266..2ae335d6c 100644 --- a/platform-api/src/internal/handler/api_key.go +++ b/platform-api/src/internal/handler/api_key.go @@ -117,6 +117,82 @@ func (h *APIKeyHandler) CreateAPIKey(c *gin.Context) { }) } +// UpdateAPIKey handles PUT /api/v1/apis/{apiId}/api-keys/{keyName} +// This endpoint allows Cloud APIM to update/regenerate external API keys on hybrid gateways +func (h *APIKeyHandler) UpdateAPIKey(c *gin.Context) { + // Extract organization from JWT token + orgId, exists := middleware.GetOrganizationFromContext(c) + if !exists { + c.JSON(http.StatusUnauthorized, utils.NewErrorResponse(401, "Unauthorized", + "Organization claim not found in token")) + return + } + + // Extract API ID and key name from path parameters + apiID := c.Param("apiId") + if apiID == "" { + c.JSON(http.StatusBadRequest, utils.NewErrorResponse(400, "Bad Request", + "API ID is required")) + return + } + + keyName := c.Param("keyName") + if keyName == "" { + c.JSON(http.StatusBadRequest, utils.NewErrorResponse(400, "Bad Request", + "API key name is required")) + return + } + + // Parse and validate request body + var req dto.UpdateAPIKeyRequest + if err := c.ShouldBindJSON(&req); err != nil { + log.Printf("[WARN] Invalid API key update request: orgId=%s apiId=%s keyName=%s error=%v", + orgId, apiID, keyName, err) + c.JSON(http.StatusBadRequest, utils.NewErrorResponse(400, "Bad Request", + "Invalid request body: "+err.Error())) + return + } + + // Validate new API key value + if req.ApiKey == "" { + c.JSON(http.StatusBadRequest, utils.NewErrorResponse(400, "Bad Request", + "API key value is required")) + return + } + + // Update the API key and broadcast to gateways + err := h.apiKeyService.UpdateAPIKey(c.Request.Context(), apiID, orgId, keyName, &req) + if err != nil { + // Handle specific error cases + if errors.Is(err, constants.ErrAPINotFound) { + c.JSON(http.StatusNotFound, utils.NewErrorResponse(404, "Not Found", + "API not found")) + return + } + if errors.Is(err, constants.ErrGatewayUnavailable) { + c.JSON(http.StatusServiceUnavailable, utils.NewErrorResponse(503, "Service Unavailable", + "No gateway connections available for API")) + return + } + + log.Printf("[ERROR] Failed to update API key: apiId=%s orgId=%s keyName=%s error=%v", + apiID, orgId, keyName, err) + c.JSON(http.StatusInternalServerError, utils.NewErrorResponse(500, "Internal Server Error", + "Failed to update API key")) + return + } + + log.Printf("[INFO] Successfully updated API key: apiId=%s orgId=%s keyName=%s", + apiID, orgId, keyName) + + // Return success response + c.JSON(http.StatusOK, dto.UpdateAPIKeyResponse{ + Status: "success", + Message: "API key updated and broadcasted to gateways successfully", + KeyId: keyName, + }) +} + // RevokeAPIKey handles DELETE /api/v1/apis/{apiId}/api-keys/{keyName} // This endpoint allows Cloud APIM to revoke external API keys on hybrid gateways func (h *APIKeyHandler) RevokeAPIKey(c *gin.Context) { @@ -177,6 +253,7 @@ func (h *APIKeyHandler) RegisterRoutes(r *gin.Engine) { apiKeyGroup := r.Group("/api/v1/apis/:apiId/api-keys") { apiKeyGroup.POST("", h.CreateAPIKey) + apiKeyGroup.PUT("/:keyName", h.UpdateAPIKey) apiKeyGroup.DELETE("/:keyName", h.RevokeAPIKey) } } diff --git a/platform-api/src/internal/model/apikey_event.go b/platform-api/src/internal/model/apikey_event.go index 4cafafbbb..3529458db 100644 --- a/platform-api/src/internal/model/apikey_event.go +++ b/platform-api/src/internal/model/apikey_event.go @@ -48,3 +48,19 @@ type APIKeyRevokedEvent struct { // KeyName is the unique name of the API key that was revoked KeyName string `json:"keyName"` } + +// APIKeyUpdatedEvent represents the payload for "apikey.updated" event type. +// This event is sent when an API key is updated/regenerated on hybrid gateways. +type APIKeyUpdatedEvent struct { + // ApiId identifies the API this key belongs to + ApiId string `json:"apiId"` + + // KeyName is the unique name of the API key being updated + KeyName string `json:"keyName"` + + // ApiKey is the new plain API key value (hashing happens in the gateway) + ApiKey string `json:"apiKey"` + + // ExpiresAt is the optional new expiration time in ISO 8601 format + ExpiresAt *string `json:"expiresAt,omitempty"` +} diff --git a/platform-api/src/internal/service/apikey.go b/platform-api/src/internal/service/apikey.go index ce9e3c2ac..d57d41e6c 100644 --- a/platform-api/src/internal/service/apikey.go +++ b/platform-api/src/internal/service/apikey.go @@ -121,6 +121,81 @@ func (s *APIKeyService) CreateAPIKey(ctx context.Context, apiId, orgId string, r return nil } +// UpdateAPIKey updates/regenerates an API key and broadcasts it to all gateways where the API is deployed. +// This method is used when Cloud APIM rotates/regenerates API keys on hybrid gateways. +func (s *APIKeyService) UpdateAPIKey(ctx context.Context, apiId, orgId, keyName string, req *dto.UpdateAPIKeyRequest) error { + // Validate API exists and get its deployments + api, err := s.apiRepo.GetAPIByUUID(apiId, orgId) + if err != nil { + log.Printf("[ERROR] Failed to get API for API key update: apiId=%s error=%v", apiId, err) + return fmt.Errorf("failed to get API: %w", err) + } + if api == nil { + log.Printf("[WARN] API not found for API key update: apiId=%s", apiId) + return constants.ErrAPINotFound + } + + // Get all deployments for this API to find target gateways + deployments, err := s.apiRepo.GetDeploymentsByAPIUUID(apiId, orgId, nil, nil) + if err != nil { + log.Printf("[ERROR] Failed to get deployments for API key update: apiId=%s error=%v", apiId, err) + return fmt.Errorf("failed to get API deployments: %w", err) + } + + if len(deployments) == 0 { + log.Printf("[WARN] No gateway deployments found for API: apiId=%s", apiId) + return constants.ErrGatewayUnavailable + } + + // Build the API key updated event + // Note: API key is sent as plain text - hashing happens in the gateway/policy-engine + event := &model.APIKeyUpdatedEvent{ + ApiId: apiId, + KeyName: keyName, + ApiKey: req.ApiKey, // Send plain API key (no hashing in platform-api) + ExpiresAt: req.ExpiresAt, + } + + // Track delivery statistics + successCount := 0 + failureCount := 0 + var lastError error + + // Broadcast event to all gateways where API is deployed + for _, deployment := range deployments { + gatewayID := deployment.GatewayID + + log.Printf("[INFO] Broadcasting API key updated event: apiId=%s gatewayId=%s keyName=%s", + apiId, gatewayID, keyName) + + // Broadcast with retries + err := s.gatewayEventsService.BroadcastAPIKeyUpdatedEvent(gatewayID, event) + if err != nil { + failureCount++ + lastError = err + log.Printf("[ERROR] Failed to broadcast API key updated event: apiId=%s gatewayId=%s keyName=%s error=%v", + apiId, gatewayID, keyName, err) + } else { + successCount++ + log.Printf("[INFO] Successfully broadcast API key updated event: apiId=%s gatewayId=%s keyName=%s", + apiId, gatewayID, keyName) + } + } + + // Log summary + log.Printf("[INFO] API key update broadcast summary: apiId=%s keyName=%s total=%d success=%d failed=%d", + apiId, keyName, len(deployments), successCount, failureCount) + + // Return error if all deliveries failed + if successCount == 0 { + log.Printf("[ERROR] Failed to deliver API key update to any gateway: apiId=%s keyName=%s", apiId, keyName) + return fmt.Errorf("failed to deliver API key update event to any gateway: %w", lastError) + } + + // Partial success is still considered success (some gateways received the event) + return nil +} + // RevokeAPIKey broadcasts API key revocation to all gateways where the API is deployed func (s *APIKeyService) RevokeAPIKey(ctx context.Context, apiId, orgId, keyName string) error { // Validate API exists and get its deployments diff --git a/platform-api/src/internal/service/gateway_events.go b/platform-api/src/internal/service/gateway_events.go index a12a32c51..5afca7b8e 100644 --- a/platform-api/src/internal/service/gateway_events.go +++ b/platform-api/src/internal/service/gateway_events.go @@ -439,3 +439,119 @@ func (s *GatewayEventsService) broadcastAPIKeyRevoked(gatewayID string, event *m return nil } + +// BroadcastAPIKeyUpdatedEvent sends an API key updated event to target gateway with retries. +// This method handles: +// - Looking up gateway connections by gateway ID +// - Serializing event to JSON +// - Broadcasting to all connections for the gateway (clustering support) +// - Current API key events are not retried +// - Payload size validation +// - Delivery statistics tracking +func (s *GatewayEventsService) BroadcastAPIKeyUpdatedEvent(gatewayID string, event *model.APIKeyUpdatedEvent) error { + const maxRetries = 1 + const retryDelay = 1 * time.Second + + var lastError error + + // Retry loop for critical API key events + for attempt := 0; attempt < maxRetries; attempt++ { + if attempt > 0 { + log.Printf("[INFO] Retrying API key updated event broadcast: gatewayID=%s attempt=%d/%d", + gatewayID, attempt+1, maxRetries) + time.Sleep(retryDelay * time.Duration(attempt)) // Linear backoff + } + + err := s.broadcastAPIKeyUpdated(gatewayID, event) + if err == nil { + if attempt > 0 { + log.Printf("[INFO] API key updated event delivered after retry: gatewayID=%s attempts=%d", + gatewayID, attempt+1) + } + return nil + } + + lastError = err + log.Printf("[WARN] API key updated event delivery failed: gatewayID=%s attempt=%d/%d error=%v", + gatewayID, attempt+1, maxRetries, err) + } + + log.Printf("[ERROR] API key updated event delivery failed after all retries: gatewayID=%s retries=%d error=%v", + gatewayID, maxRetries, lastError) + return fmt.Errorf("failed to deliver API key updated event after %d retries: %w", maxRetries, lastError) +} + +// broadcastAPIKeyUpdated is the internal implementation for broadcasting API key updated events +func (s *GatewayEventsService) broadcastAPIKeyUpdated(gatewayID string, event *model.APIKeyUpdatedEvent) error { + // Create correlation ID for tracing + correlationID := uuid.New().String() + + // Serialize payload + payloadJSON, err := json.Marshal(event) + if err != nil { + log.Printf("[ERROR] Failed to serialize API key updated event: gatewayID=%s error=%v", gatewayID, err) + return fmt.Errorf("failed to serialize API key updated event: %w", err) + } + + // Validate payload size + if len(payloadJSON) > MaxEventPayloadSize { + err := fmt.Errorf("event payload exceeds maximum size: %d bytes (limit: %d bytes)", len(payloadJSON), MaxEventPayloadSize) + log.Printf("[ERROR] Payload size validation failed: gatewayID=%s size=%d error=%v", gatewayID, len(payloadJSON), err) + return err + } + + // Create gateway event DTO + eventDTO := dto.GatewayEventDTO{ + Type: "apikey.updated", + Payload: event, + Timestamp: time.Now().Format(time.RFC3339), + CorrelationID: correlationID, + } + + // Serialize complete event + eventJSON, err := json.Marshal(eventDTO) + if err != nil { + log.Printf("[ERROR] Failed to marshal API key updated event DTO: gatewayID=%s correlationId=%s error=%v", + gatewayID, correlationID, err) + return fmt.Errorf("failed to marshal event: %w", err) + } + + // Get all connections for this gateway + connections := s.manager.GetConnections(gatewayID) + if len(connections) == 0 { + log.Printf("[WARN] No active connections for gateway: gatewayID=%s correlationId=%s", gatewayID, correlationID) + return fmt.Errorf("no active connections for gateway: %s", gatewayID) + } + + // Broadcast to all connections + successCount := 0 + failureCount := 0 + var lastError error + + for _, conn := range connections { + err := conn.Send(eventJSON) + if err != nil { + failureCount++ + lastError = err + log.Printf("[ERROR] Failed to send API key updated event: gatewayID=%s connectionID=%s correlationId=%s error=%v", + gatewayID, conn.ConnectionID, correlationID, err) + conn.DeliveryStats.IncrementFailed(fmt.Sprintf("send error: %v", err)) + } else { + successCount++ + log.Printf("[INFO] API key updated event sent: gatewayID=%s connectionID=%s correlationId=%s keyName=%s", + gatewayID, conn.ConnectionID, correlationID, event.KeyName) + conn.DeliveryStats.IncrementTotalSent() + } + } + + // Log broadcast summary + log.Printf("[INFO] Broadcast summary: gatewayID=%s correlationId=%s type=apikey.updated total=%d success=%d failed=%d", + gatewayID, correlationID, len(connections), successCount, failureCount) + + // Return error if all deliveries failed + if successCount == 0 { + return fmt.Errorf("failed to deliver event to any connection: %w", lastError) + } + + return nil +} From 2bb3ba64fd29e0f13276774c89121cb8e8b35cb4 Mon Sep 17 00:00:00 2001 From: Thushani Jayasekera Date: Fri, 30 Jan 2026 12:42:14 +0530 Subject: [PATCH 07/14] Fix coderabbit review comments and improve external api key validation --- common/apikey/api_key_hash_test.go | 5 +- common/apikey/store.go | 134 +- .../gateway/policies/apikey-authentication.md | 242 +++- gateway/gateway-builder/Dockerfile | 11 +- gateway/gateway-controller/api/openapi.yaml | 65 +- .../pkg/api/generated/generated.go | 388 +++--- .../pkg/api/handlers/handlers.go | 64 +- .../pkg/apikeyxds/apikey_snapshot.go | 8 +- .../pkg/constants/constants.go | 4 + .../pkg/controlplane/client.go | 325 +++-- .../pkg/controlplane/events.go | 50 +- .../gateway-controller/pkg/models/api_key.go | 6 +- .../pkg/storage/apikey_store.go | 26 + .../pkg/storage/gateway-controller-db.sql | 9 +- .../gateway-controller/pkg/storage/memory.go | 55 +- .../gateway-controller/pkg/storage/sqlite.go | 230 ++-- .../gateway-controller/pkg/utils/api_key.go | 1112 +++++++---------- .../pkg/utils/api_key_validation.go | 118 ++ .../tests/integration/schema_test.go | 2 +- gateway/policies/policy-manifest.yaml | 2 +- gateway/policy-engine/Dockerfile | 25 +- .../internal/xdsclient/api_key_handler.go | 31 +- .../internal/xdsclient/handler.go | 7 +- platform-api/src/internal/handler/api_key.go | 18 +- platform-api/src/internal/service/apikey.go | 19 +- .../src/internal/service/gateway_events.go | 107 +- sdk/gateway/policy/v1alpha/api_key.go | 564 +++++++++ .../policy/v1alpha/api_key_hash_test.go | 197 +++ sdk/gateway/policyengine/v1/api_key_xds.go | 3 + sdk/go.mod | 3 - 30 files changed, 2556 insertions(+), 1274 deletions(-) create mode 100644 gateway/gateway-controller/pkg/utils/api_key_validation.go create mode 100644 sdk/gateway/policy/v1alpha/api_key.go create mode 100644 sdk/gateway/policy/v1alpha/api_key_hash_test.go diff --git a/common/apikey/api_key_hash_test.go b/common/apikey/api_key_hash_test.go index cc38138c7..426e2ee93 100644 --- a/common/apikey/api_key_hash_test.go +++ b/common/apikey/api_key_hash_test.go @@ -1,5 +1,5 @@ /* - * Copyright (c) 2025, WSO2 LLC. (https://www.wso2.com). + * Copyright (c) 2026, WSO2 LLC. (https://www.wso2.com). * * WSO2 LLC. licenses this file to you under the Apache License, * Version 2.0 (the "License"); you may not use this file except @@ -51,6 +51,7 @@ func TestAPIKeyHashedValidation(t *testing.T) { apiKey := &APIKey{ ID: "test-id-1", Name: "test-key", + Source: "local", APIKey: hashedAPIKey, // Store hashed key APIId: "api-123", Operations: "[\"*\"]", @@ -99,6 +100,7 @@ func TestAPIKeyHashedValidationFailures(t *testing.T) { ID: "test-id-2", Name: "test-key-2", APIKey: hashedAPIKey, + Source: "local", APIId: "api-456", Operations: "[\"*\"]", Status: Active, @@ -157,6 +159,7 @@ func TestAPIKeyHashedRevocation(t *testing.T) { ID: "test-id-3", Name: "revoke-test-key", APIKey: hashedAPIKey, + Source: "local", APIId: "api-789", Operations: "[\"*\"]", Status: Active, diff --git a/common/apikey/store.go b/common/apikey/store.go index 8b6be38c3..a9142af50 100644 --- a/common/apikey/store.go +++ b/common/apikey/store.go @@ -37,8 +37,10 @@ import ( type APIKey struct { // ID of the API Key ID string `json:"id" yaml:"id"` - // Name of the API key + // Name of the API key (URL-safe identifier, auto-generated, immutable) Name string `json:"name" yaml:"name"` + // DisplayName is the human-readable name (user-provided, mutable) + DisplayName string `json:"display_name" yaml:"display_name"` // ApiKey API key with apip_ prefix APIKey string `json:"api_key" yaml:"api_key"` // APIId Unique identifier of the API that the key is associated with @@ -57,6 +59,8 @@ type APIKey struct { ExpiresAt *time.Time `json:"expires_at" yaml:"expires_at"` // Source tracking for external key support ("local" | "external") Source string `json:"source" yaml:"source"` + // IndexKey Pre-computed hash for O(1) lookup (external plain text keys only) + IndexKey string `json:"index_key" yaml:"index_key"` } // APIKeyStatus Status of the API key @@ -77,16 +81,6 @@ const ( const APIKeySeparator = "_" -// effectiveSource returns the effective source for matching: empty or "null" is treated as "local" for legacy keys. -// Persisted storage (e.g. gateway-controller SQLite) is migrated to set source = 'local' by default; this -// fallback covers the in-memory store and any key that arrives with empty/null source (e.g. via xDS/sync). -func effectiveSource(source string) string { - if source == "" { - return "local" - } - return source -} - // Common storage errors - implementation agnostic var ( // ErrNotFound is returned when an API key is not found @@ -94,6 +88,9 @@ var ( // ErrConflict is returned when an API Key with the same name/version or key value already exists ErrConflict = errors.New("API key already exists") + + // ErrInvalidInput is returned when input validation fails (e.g. external key missing IndexKey) + ErrInvalidInput = errors.New("invalid input") ) // Singleton instance @@ -109,14 +106,14 @@ type APIkeyStore struct { apiKeysByAPI map[string]map[string]*APIKey // Key: "API ID" → Value: map[API key ID]*APIKey // Fast lookup index for external keys: Key: "API ID:SHA256(plain key)" → Value: API key ID // This avoids O(n) iteration through all keys for external key validation - externalKeyIndex map[string]string + externalKeyIndex map[string]map[string]*string } // NewAPIkeyStore creates a new in-memory API key store func NewAPIkeyStore() *APIkeyStore { return &APIkeyStore{ apiKeysByAPI: make(map[string]map[string]*APIKey), - externalKeyIndex: make(map[string]string), + externalKeyIndex: make(map[string]map[string]*string), } } @@ -134,6 +131,11 @@ func (aks *APIkeyStore) StoreAPIKey(apiId string, apiKey *APIKey) error { return fmt.Errorf("API key cannot be nil") } + // Require non-empty IndexKey for external keys before any writes (no replacement from hashed APIKey) + if apiKey.Source == "external" && strings.TrimSpace(apiKey.IndexKey) == "" { + return fmt.Errorf("%w: external API key requires non-empty IndexKey", ErrInvalidInput) + } + aks.mu.Lock() defer aks.mu.Unlock() @@ -151,6 +153,18 @@ func (aks *APIkeyStore) StoreAPIKey(apiId string, apiKey *APIKey) error { } if existingKeyID != "" { + // Remove old external key index entry if it exists (cleanup only; use IndexKey or compute from old key) + oldKey := aks.apiKeysByAPI[apiId][existingKeyID] + if oldKey != nil && oldKey.Source == "external" && aks.externalKeyIndex[apiId] != nil { + oldIndexKey := oldKey.IndexKey + if oldIndexKey == "" { + oldIndexKey = computeExternalKeyIndexKey(oldKey.APIKey) + } + if oldIndexKey != "" { + delete(aks.externalKeyIndex[apiId], oldIndexKey) + } + } + // Update the existing entry in apiKeysByAPI aks.apiKeysByAPI[apiId][existingKeyID] = apiKey } else { @@ -169,6 +183,14 @@ func (aks *APIkeyStore) StoreAPIKey(apiId string, apiKey *APIKey) error { aks.apiKeysByAPI[apiId][apiKey.ID] = apiKey } + // For external keys with non-empty IndexKey, add to fast lookup index (never insert empty index entry) + if apiKey.Source == "external" && apiKey.IndexKey != "" { + if aks.externalKeyIndex[apiId] == nil { + aks.externalKeyIndex[apiId] = make(map[string]*string) + } + aks.externalKeyIndex[apiId][apiKey.IndexKey] = &apiKey.ID + } + return nil } @@ -185,21 +207,25 @@ func (aks *APIkeyStore) ValidateAPIKey(apiId, apiOperation, operationMethod, pro if ok { // Optimized O(1) lookup for local keys using ID apiKey, exists := aks.apiKeysByAPI[apiId][parsedAPIkey.ID] - if exists && effectiveSource(apiKey.Source) == "local" && compareAPIKeys(parsedAPIkey.APIKey, apiKey.APIKey) { + if exists && apiKey.Source == "local" && compareAPIKeys(parsedAPIkey.APIKey, apiKey.APIKey) { targetAPIKey = apiKey } } - - // If not found via local key lookup, check all keys (handles both external keys and edge cases) + // If not found via local key lookup, try external key index for O(1) lookup if targetAPIKey == nil { - apiKeys, exists := aks.apiKeysByAPI[apiId] + // Compute the index key for external key lookup + indexKey := computeExternalKeyIndexKey(providedAPIKey) + if indexKey == "" { + return false, fmt.Errorf("API key is empty") + } + trimmedAPIKey := strings.TrimSpace(providedAPIKey) + keyID, exists := aks.externalKeyIndex[apiId][indexKey] if exists { - for _, apiKey := range apiKeys { - // For external keys, compare the full provided key directly (no parsing) - if effectiveSource(apiKey.Source) == "external" && compareAPIKeys(providedAPIKey, apiKey.APIKey) { + // Found in index, retrieve the key + if apiKey, ok := aks.apiKeysByAPI[apiId][*keyID]; ok { + if apiKey.Source == "external" && compareAPIKeys(trimmedAPIKey, apiKey.APIKey) { targetAPIKey = apiKey - break } } } @@ -265,21 +291,18 @@ func (aks *APIkeyStore) RevokeAPIKey(apiId, providedAPIKey string) error { parsedAPIkey, ok := parseAPIKey(providedAPIKey) if ok { apiKey, exists := aks.apiKeysByAPI[apiId][parsedAPIkey.ID] - if exists && effectiveSource(apiKey.Source) == "local" && compareAPIKeys(parsedAPIkey.APIKey, apiKey.APIKey) { + if exists && apiKey.Source == "local" && compareAPIKeys(parsedAPIkey.APIKey, apiKey.APIKey) { matchedKey = apiKey } } - // If not found via local key lookup, check all keys + // If not found via local key lookup, try external key index for O(1) lookup if matchedKey == nil { - apiKeys, exists := aks.apiKeysByAPI[apiId] - if exists { - for _, apiKey := range apiKeys { - // For external keys, compare the full provided key directly - // Also catches local keys that failed parsing (edge case) - if compareAPIKeys(providedAPIKey, apiKey.APIKey) { + indexKey := computeExternalKeyIndexKey(providedAPIKey) + if keyID, exists := aks.externalKeyIndex[apiId][indexKey]; exists { + if apiKey, ok := aks.apiKeysByAPI[apiId][*keyID]; ok { + if apiKey.Source == "external" && compareAPIKeys(providedAPIKey, apiKey.APIKey) { matchedKey = apiKey - break } } } @@ -303,11 +326,27 @@ func (aks *APIkeyStore) RemoveAPIKeysByAPI(apiId string) error { aks.mu.Lock() defer aks.mu.Unlock() - _, exists := aks.apiKeysByAPI[apiId] + apiKeys, exists := aks.apiKeysByAPI[apiId] if !exists { return nil // No keys to remove } + // Remove from external key index + for _, apiKey := range apiKeys { + if apiKey.Source == "external" { + var indexKey string + if apiKey.IndexKey != "" { + indexKey = apiKey.IndexKey + } else { + indexKey = computeExternalKeyIndexKey(apiKey.APIKey) + if indexKey == "" { + return fmt.Errorf("failed to compute index key") + } + } + delete(aks.externalKeyIndex[apiKey.APIId], indexKey) + } + } + // Remove from API-specific map delete(aks.apiKeysByAPI, apiId) @@ -321,6 +360,8 @@ func (aks *APIkeyStore) ClearAll() error { // Clear the API-specific keys map aks.apiKeysByAPI = make(map[string]map[string]*APIKey) + // Clear the external key index + aks.externalKeyIndex = make(map[string]map[string]*string) return nil } @@ -477,6 +518,20 @@ func parseAPIKey(value string) (ParsedAPIKey, bool) { }, true } +// computeExternalKeyIndexKey computes a SHA-256 hash of the plain-text API key for fast lookup +// Returns the index key as "hash_hex" (SHA-256 of the plain key) +func computeExternalKeyIndexKey(plainAPIKey string) string { + trimmedAPIKey := strings.TrimSpace(plainAPIKey) + if trimmedAPIKey == "" { + return "" + } + + hasher := sha256.New() + hasher.Write([]byte(trimmedAPIKey)) + hash := hasher.Sum(nil) + return hex.EncodeToString(hash) +} + // removeFromAPIMapping removes an API key from the API mapping func (aks *APIkeyStore) removeFromAPIMapping(apiKey *APIKey) { apiKeys, apiIdExists := aks.apiKeysByAPI[apiKey.APIId] @@ -487,4 +542,21 @@ func (aks *APIkeyStore) removeFromAPIMapping(apiKey *APIKey) { delete(aks.apiKeysByAPI, apiKey.APIId) } } + + // Remove from external key index if it's an external key + if apiKey.Source == "external" { + if aks.externalKeyIndex[apiKey.APIId] == nil { + return + } + var indexKey string + if apiKey.IndexKey != "" { + indexKey = apiKey.IndexKey + } else { + indexKey = computeExternalKeyIndexKey(apiKey.APIKey) + if indexKey == "" { + return + } + } + delete(aks.externalKeyIndex[apiKey.APIId], indexKey) + } } diff --git a/docs/gateway/policies/apikey-authentication.md b/docs/gateway/policies/apikey-authentication.md index de9435eaa..2c98c15c2 100644 --- a/docs/gateway/policies/apikey-authentication.md +++ b/docs/gateway/policies/apikey-authentication.md @@ -202,7 +202,7 @@ spec: ## API Key Management -The gateway controller provides REST APIs to manage API keys for APIs that use the API Key Authentication policy. These endpoints allow you to generate, view, regenerate, and revoke API keys programmatically. +The gateway controller provides REST APIs to manage API keys for APIs that use the API Key Authentication policy. These endpoints allow you to generate, inject, update, view, regenerate, and revoke API keys programmatically. ### Base URL @@ -256,7 +256,7 @@ Generate a new API key for a specific API. ```json { - "name": "weather-api-key", + "displayName": "weather-api-key", "expires_in": { "duration": 30, "unit": "days" @@ -268,7 +268,7 @@ Generate a new API key for a specific API. | Field | Type | Required | Description | |----------------------|------|----------|-------------| -| `name` | string | No | Custom name for the API key. If not provided, a default name will be generated | +| `displayName` | string | No | Custom name for the API key. If not provided, a default name will be generated | | `expires_at` | string (ISO 8601) | No | Specific expiration timestamp for the API key. If both `expires_in` and `expires_at` are provided, `expires_at` takes precedence | | `expires_in` | object | No | Relative expiration time from creation | | `expires_in.duration` | integer | Yes (if expiresIn used) | Duration value | @@ -282,7 +282,7 @@ curl -X POST "http://localhost:9090/apis/weather-api-v1.0/api-keys" \ -H "Content-Type: application/json" \ -u "username:password" \ -d '{ - "name": "production-key", + "displayName": "production-key", "expires_in": { "duration": 90, "unit": "days" @@ -296,7 +296,7 @@ curl -X POST "http://localhost:9090/apis/weather-api-v1.0/api-keys" \ -H "Content-Type: application/json" \ -H "Authorization: Bearer " \ -d '{ - "name": "production-key", + "displayName": "production-key", "expires_in": { "duration": 90, "unit": "days" @@ -313,6 +313,7 @@ curl -X POST "http://localhost:9090/apis/weather-api-v1.0/api-keys" \ "remaining_api_key_quota": 9, "api_key": { "name": "production-key", + "displayName": "production-key", "api_key": "apip_<64_hex>_<22_chars>", "apiId": "weather-api-v1.0", "operations": "[\"*\"]", @@ -331,7 +332,117 @@ curl -X POST "http://localhost:9090/apis/weather-api-v1.0/api-keys" \ | `status` | string | Operation status (`success`) | | `message` | string | Detailed message of the status | | `remaining_api_key_quota` | integer | Remaining API key quota for the user | -| `api_key.name` | string | Name of the generated API key | +| `api_key.displayName` | string | Name of the generated API key | +| `api_key.name` | string | Identifier of the generated API key | +| `api_key.apiId` | string | API identifier | +| `api_key.api_key` | string | The actual API key value (starts with `apip_`) | +| `api_key.status` | string | Key status (`active`) | +| `api_key.created_at` | string | ISO 8601 timestamp of creation | +| `api_key.created_by` | string | User who created the key | +| `api_key.expires_at` | string | ISO 8601 expiration timestamp (if set) | +| `api_key.operations` | string | Allowed operations (currently `["*"]` for all) | + +### Inject API Key + +This operation uses the same endpoint as [Generate API Key](#generate-api-key) (`POST /apis/{id}/api-keys`). The behavior is determined by the presence of the `api_key` field in the request body: omit `api_key` to generate a system key, or include `api_key` to inject an external key. See the request body examples in each section for the differing payloads. + +Inject an externally generated API key for a specific API. + +**Endpoint**: `POST /apis/{id}/api-keys` + +#### Request Parameters + +| Parameter | Type | Location | Required | Description | +|-----------|------|----------|----------|-------------| +| `id` | string | path | Yes | Unique public identifier of the API (e.g., `weather-api-v1.0`) | + +#### Request Body + +```json +{ + "name": "weather-api-key", + "expires_in": { + "duration": 30, + "unit": "days" + }, + "api_key": "apip_1234567890abcdef1234567890abcdef1234567890abcdef1234567890abcdef" +} +``` + +**Request Body Schema:** + +| Field | Type | Required | Description | +|----------------------|------|----------|-------------| +| `displayName` | string | No | Custom name for the API key. If not provided, a default name will be generated | +| `name` | string | No | Identifier of the API key. If not provided, a default identifier will be generated | +| `api_key` | string | No | The API key value to inject. Injected keys can be externally generated and are not required to use the platform `apip_` prefix; platform-generated keys do use the `apip_` prefix. See [Update API Key](#update-api-key) for the same `api_key` semantics when updating. | +| `expires_at` | string (ISO 8601) | No | Specific expiration timestamp for the API key. If both `expires_in` and `expires_at` are provided, `expires_at` takes precedence | +| `expires_in` | object | No | Relative expiration time from creation | +| `expires_in.duration` | integer | Yes (if expiresIn used) | Duration value | +| `expires_in.unit` | string | Yes (if expiresIn used) | Time unit: `seconds`, `minutes`, `hours`, `days`, `weeks`, `months` | + +#### Example Request + +**Using Basic Authentication:** +```bash +curl -X POST "http://localhost:9090/apis/weather-api-v1.0/api-keys" \ + -H "Content-Type: application/json" \ + -u "username:password" \ + -d '{ + "displayName": "production-key", + "api_key": "apip_1234567890abcdef1234567890abcdef1234567890abcdef1234567890abcdef", + "expires_in": { + "duration": 90, + "unit": "days" + } + }' +``` + +**Using JWT Authentication:** +```bash +curl -X POST "http://localhost:9090/apis/weather-api-v1.0/api-keys" \ + -H "Content-Type: application/json" \ + -H "Authorization: Bearer " \ + -d '{ + "displayName": "production-key", + "api_key": "apip_1234567890abcdef1234567890abcdef1234567890abcdef1234567890abcdef", + "expires_in": { + "duration": 90, + "unit": "days" + } + }' +``` + +#### Successful Response (201 Created) + +```json +{ + "status": "success", + "message": "API key generated successfully", + "remaining_api_key_quota": 9, + "api_key": { + "displayName": "production-key", + "name": "production-key", + "apiId": "weather-api-v1.0", + "operations": "[\"*\"]", + "status": "active", + "created_at": "2025-12-22T13:02:24.504957558Z", + "created_by": "john", + "expires_at": "2025-12-23T13:02:24.504957558Z" + } +} +``` + +#### Response Schema + + +| Field | Type | Description | +|-------|------|------------------------------------------------| +| `status` | string | Operation status (`success`) | +| `message` | string | Detailed message of the status | +| `remaining_api_key_quota` | integer | Remaining API key quota for the user | +| `api_key.name` | string | Identifier of the generated API key | +| `api_key.displayName` | string | Display name of the generated API key | | `api_key.apiId` | string | API identifier | | `api_key.api_key` | string | The actual API key value (starts with `apip_`) | | `api_key.status` | string | Key status (`active`) | @@ -340,6 +451,121 @@ curl -X POST "http://localhost:9090/apis/weather-api-v1.0/api-keys" \ | `api_key.expires_at` | string | ISO 8601 expiration timestamp (if set) | | `api_key.operations` | string | Allowed operations (currently `["*"]` for all) | +### Update API Key + +Update an existing API key with a new externally provided API key value and optionally update the display name and expiration settings. This endpoint is useful when you need to migrate an existing external API key into the platform or update a key's value while maintaining its identity and metadata. + +**Key Differences from Regenerate:** +- **Update**: Replace the API key with a specific value you provide (for external key migration or synchronization) +- **Regenerate**: Generate a new random API key value automatically (for security rotation) + +**Endpoint**: `PUT /apis/{id}/api-keys/{apiKeyName}` + +#### Request Parameters + +| Parameter | Type | Location | Required | Description | +|-----------|------|----------|----------|-------------| +| `id` | string | path | Yes | Unique public identifier of the API (e.g., `weather-api-v1.0`) | +| `apiKeyName` | string | path | Yes | Name of the API key to update | + +#### Request Body + +```json +{ + "displayName": "updated-weather-key", + "api_key": "apip_newvalue1234567890abcdef1234567890abcdef1234567890abcdef1234567890", + "expires_in": { + "duration": 60, + "unit": "days" + } +} +``` + +**Request Body Schema:** + +| Field | Type | Required | Description | +|-------|------|----------|-------------| +| `displayName` | string | No | Display name for the API key | +| `api_key` | string | Yes | The new API key value to set. Must meet minimum length requirements and can be any format (not restricted to platform-generated format) | +| `expires_at` | string (ISO 8601) | No | Specific expiration timestamp. If both `expires_at` and `expires_in` are provided, `expires_at` takes precedence. Omitting both `expires_at` and `expires_in` clears the key's expiration (no expiry). | +| `expires_in` | object | No | Relative expiration time from now. Omitting both `expires_at` and `expires_in` removes the API key's expiration (UpdateAPIKey clears expiry when `request.ExpiresAt` and `request.ExpiresIn` are both nil). | +| `expires_in.duration` | integer | Yes (if expiresIn used) | Duration value | +| `expires_in.unit` | string | Yes (if expiresIn used) | Time unit: `seconds`, `minutes`, `hours`, `days`, `weeks`, `months` | + +#### Example Request + +**Using Basic Authentication:** +```bash +curl -X PUT "http://localhost:9090/apis/weather-api-v1.0/api-keys/production-key" \ + -H "Content-Type: application/json" \ + -u "username:password" \ + -d '{ + "displayName": "updated-production-key", + "api_key": "apip_abc123def456789abc123def456789abc123def456789abc123def456789abc12", + "expires_in": { + "duration": 60, + "unit": "days" + } + }' +``` + +**Using JWT Authentication:** +```bash +curl -X PUT "http://localhost:9090/apis/weather-api-v1.0/api-keys/production-key" \ + -H "Content-Type: application/json" \ + -H "Authorization: Bearer " \ + -d '{ + "displayName": "updated-production-key", + "api_key": "apip_abc123def456789abc123def456789abc123def456789abc123def456789abc12", + "expires_in": { + "duration": 60, + "unit": "days" + } + }' +``` + +#### Successful Response (200 OK) + +```json +{ + "status": "success", + "message": "API key updated successfully", + "remaining_api_key_quota": 9, + "api_key": { + "name": "production-key", + "displayName": "updated-production-key", + "api_key": "apip_abc123def456789abc123def456789abc123def456789abc123def456789abc12", + "apiId": "weather-api-v1.0", + "operations": "[\"*\"]", + "status": "active", + "created_at": "2025-12-22T12:26:47.626109914Z", + "created_by": "john", + "updated_at": "2025-12-22T14:30:15.123456789Z", + "updated_by": "john", + "expires_at": "2026-02-20T14:30:15.123456789Z" + } +} +``` + +#### Response Schema + +| Field | Type | Description | +|-------|------|-------------| +| `status` | string | Operation status (`success`) | +| `message` | string | Detailed message of the status | +| `remaining_api_key_quota` | integer | Remaining API key quota for the user (unchanged by update) | +| `api_key.name` | string | Identifier of the API key (unchanged) | +| `api_key.displayName` | string | Updated display name of the API key | +| `api_key.api_key` | string | The new API key value | +| `api_key.apiId` | string | API identifier (unchanged) | +| `api_key.status` | string | Key status (`active`) | +| `api_key.created_at` | string | Original ISO 8601 timestamp of creation (unchanged) | +| `api_key.created_by` | string | Original user who created the key (unchanged) | +| `api_key.updated_at` | string | ISO 8601 timestamp of the update | +| `api_key.updated_by` | string | User who updated the key | +| `api_key.expires_at` | string | Updated ISO 8601 expiration timestamp (if provided) | +| `api_key.operations` | string | Allowed operations (currently `["*"]` for all) | + ### List API Keys Retrieve all active API keys for the specified API created by the user. @@ -619,6 +845,8 @@ For security reasons, API keys are masked when displayed in list operations: 1. **Secure Storage**: Store API keys securely and never expose them in client-side code or version control 2. **Regular Regeneration**: Regenerate API keys periodically for enhanced security using the regenerate endpoint + - Use **Regenerate** for routine security rotation with automatically generated random values + - Use **Update** when you need to set a specific key value (e.g., migrating from external systems) 3. **Descriptive Naming**: Use descriptive names for API keys to identify their purpose (e.g., "production-app-key", "staging-webhook") 4. **Appropriate Expiration**: Set appropriate expiration times based on your security requirements and usage patterns 5. **Immediate Revocation**: Revoke API keys immediately if they are compromised or no longer needed @@ -648,7 +876,7 @@ API keys used with this policy are managed by the platform's key management syst - **Generation**: Keys are generated through the gateway, management portal, or developer portal - **Validation**: The policy validates incoming keys against the policy engine's key store -- **Lifecycle**: Keys can be created, regenerated, revoked, and expired through platform APIs +- **Lifecycle**: Keys can be created, injected, updated, regenerated, revoked, and expired through platform APIs - **Security**: Keys are securely stored and managed by the platform infrastructure in the gateway environment ## Security Considerations diff --git a/gateway/gateway-builder/Dockerfile b/gateway/gateway-builder/Dockerfile index e3511b72a..2cc30f321 100644 --- a/gateway/gateway-builder/Dockerfile +++ b/gateway/gateway-builder/Dockerfile @@ -34,12 +34,16 @@ RUN apk add --no-cache \ # Set working directory WORKDIR /workspace -# Copy SDK (needed for go.mod replace directive) -COPY --from=sdk . /workspace/sdk - # Copy common (needed for go.mod replace directive) COPY --from=common . /workspace/common +# Pre-download Go dependencies for common +WORKDIR /workspace/common +RUN go mod download + +# Copy SDK (needed for go.mod replace directive) +COPY --from=sdk . /workspace/sdk + # Pre-download Go dependencies for SDK WORKDIR /workspace/sdk RUN go mod download @@ -79,6 +83,7 @@ COPY --from=builder /usr/local/bin/gateway-builder /usr/local/bin/gateway-builde # Copy Policy Engine framework source code COPY --from=policy-engine . /api-platform/gateway/policy-engine COPY --from=system-policies . /api-platform/gateway/system-policies +COPY --from=common . /api-platform/common COPY --from=sdk . /api-platform/sdk # Set working directory diff --git a/gateway/gateway-controller/api/openapi.yaml b/gateway/gateway-controller/api/openapi.yaml index 69e392fa3..dd49dbe7f 100644 --- a/gateway/gateway-controller/api/openapi.yaml +++ b/gateway/gateway-controller/api/openapi.yaml @@ -351,10 +351,10 @@ paths: post: summary: Create a new API key for an API description: | - Create a new API key for the specified API. The created key can be - used by clients to authenticate requests to the API if API Key validation - policy is applied. The key is a 32-byte random value encoded in hexadecimal - and prefixed with "apip_" for local keys, or the provided key for external keys. + Generates a new API key for the specified API. + Use the created key to authenticate client requests when the API Key validation policy is enabled. + The generated key is a 32-byte random value, encoded in hexadecimal, and prefixed with apip_. + If you are using an external API key, you may provide it directly without modification. operationId: createAPIKey tags: - API Management @@ -382,7 +382,7 @@ paths: content: application/json: schema: - $ref: "#/components/schemas/APIKeyGenerationResponse" + $ref: "#/components/schemas/APIKeyCreationResponse" "400": description: Invalid configuration (validation failed) content: @@ -482,7 +482,7 @@ paths: content: application/json: schema: - $ref: "#/components/schemas/APIKeyGenerationResponse" + $ref: "#/components/schemas/APIKeyCreationResponse" "400": description: Invalid configuration (validation failed) content: @@ -508,7 +508,7 @@ paths: description: | Update an existing API key with a new regenerated value. This allows rotating API keys with a predetermined value instead of auto-generating one. - The new key must be at least 16 characters long. + The new key must be at least 20 characters long. operationId: updateAPIKey tags: - API Management @@ -534,17 +534,17 @@ paths: content: application/yaml: schema: - $ref: "#/components/schemas/APIKeyRegenerationRequest" + $ref: "#/components/schemas/APIKeyUpdateRequest" application/json: schema: - $ref: "#/components/schemas/APIKeyRegenerationRequest" + $ref: "#/components/schemas/APIKeyUpdateRequest" responses: '200': description: API key updated successfully content: application/json: schema: - $ref: "#/components/schemas/APIKeyGenerationResponse" + $ref: "#/components/schemas/APIKeyCreationResponse" "400": description: Invalid request (validation failed) content: @@ -2294,19 +2294,21 @@ components: APIKeyCreationRequest: type: object properties: - name: + displayName: type: string - description: Name of the API key - example: my-weather-api-key + description: Human-readable name for the API key (1-100 characters) + minLength: 1 + maxLength: 100 + example: My Production Key api_key: type: string - minLength: 16 + minLength: 20 description: | Optional plain-text API key value for external key injection. If provided, this key will be used instead of generating a new one. The key will be hashed before storage. The key can be in any format - (minimum 16 characters). Use this for injecting externally generated - API keys (e.g., from Cloud APIM). + (minimum 20 characters). Use this for injecting externally generated + API keys. example: "cloud-apim-key-abc123xyz789" expires_in: type: object @@ -2338,11 +2340,11 @@ components: external_ref_id: type: string description: | - External reference ID for the API key (e.g., Cloud APIM key ID). + External reference ID for the API key. This field is optional and used for tracing purposes only. The gateway generates its own internal ID for tracking. example: "cloud-apim-key-98765" - APIKeyGenerationResponse: + APIKeyCreationResponse: type: object properties: status: @@ -2366,8 +2368,17 @@ components: properties: name: type: string - description: Name of the API key - example: my-weather-api-key + description: URL-safe identifier for the API key (auto-generated from displayName, immutable, used as path parameter) + pattern: "^[a-z0-9]+(-[a-z0-9]+)*$" + minLength: 3 + maxLength: 63 + example: my-production-key + displayName: + type: string + description: Human-readable name for the API key (user-provided, mutable) + minLength: 1 + maxLength: 100 + example: My Production Key api_key: type: string description: Generated API key with apip_ prefix @@ -2410,6 +2421,10 @@ components: - local - external example: local + external_ref_id: + type: string + description: External reference ID for the API key + example: "cloud-apim-key-98765" required: - name - apiId @@ -2422,11 +2437,6 @@ components: APIKeyRegenerationRequest: type: object properties: - api_key: - type: string - description: The new API key value to set (minimum 16 characters) - example: "apip_1234567890abcdef1234567890abcdef1234567890abcdef1234567890abcdef" - minLength: 16 expires_in: type: object description: Expiration duration for the API key @@ -2454,6 +2464,11 @@ components: format: date-time description: Expiration timestamp example: "2026-12-08T10:30:00Z" + APIKeyUpdateRequest: + allOf: + - $ref: "#/components/schemas/APIKeyCreationRequest" + required: + - api_key APIKeyRevocationResponse: type: object properties: diff --git a/gateway/gateway-controller/pkg/api/generated/generated.go b/gateway/gateway-controller/pkg/api/generated/generated.go index 4c087499c..d98fe5e5d 100644 --- a/gateway/gateway-controller/pkg/api/generated/generated.go +++ b/gateway/gateway-controller/pkg/api/generated/generated.go @@ -393,10 +393,16 @@ type APIKey struct { // CreatedBy Identifier of the user who generated the API key CreatedBy string `json:"created_by" yaml:"created_by"` + // DisplayName Human-readable name for the API key (user-provided, mutable) + DisplayName *string `json:"displayName,omitempty" yaml:"displayName,omitempty"` + // ExpiresAt Expiration timestamp (null if no expiration) ExpiresAt *time.Time `json:"expires_at" yaml:"expires_at"` - // Name Name of the API key + // ExternalRefId External reference ID for the API key + ExternalRefId *string `json:"external_ref_id,omitempty" yaml:"external_ref_id,omitempty"` + + // Name URL-safe identifier for the API key (auto-generated from displayName, immutable, used as path parameter) Name string `json:"name" yaml:"name"` // Operations List of API operations the key will have access to @@ -420,10 +426,13 @@ type APIKeyCreationRequest struct { // ApiKey Optional plain-text API key value for external key injection. // If provided, this key will be used instead of generating a new one. // The key will be hashed before storage. The key can be in any format - // (minimum 16 characters). Use this for injecting externally generated - // API keys (e.g., from Cloud APIM). + // (minimum 20 characters). Use this for injecting externally generated + // API keys. ApiKey *string `json:"api_key,omitempty" yaml:"api_key,omitempty"` + // DisplayName Human-readable name for the API key (1-100 characters) + DisplayName *string `json:"displayName,omitempty" yaml:"displayName,omitempty"` + // ExpiresAt Expiration timestamp. If both expires_in and expires_at are provided, expires_at takes precedence. ExpiresAt *time.Time `json:"expires_at,omitempty" yaml:"expires_at,omitempty"` @@ -436,20 +445,17 @@ type APIKeyCreationRequest struct { Unit APIKeyCreationRequestExpiresInUnit `json:"unit" yaml:"unit"` } `json:"expires_in,omitempty" yaml:"expires_in,omitempty"` - // ExternalRefId External reference ID for the API key (e.g., Cloud APIM key ID). + // ExternalRefId External reference ID for the API key. // This field is optional and used for tracing purposes only. // The gateway generates its own internal ID for tracking. ExternalRefId *string `json:"external_ref_id,omitempty" yaml:"external_ref_id,omitempty"` - - // Name Name of the API key - Name *string `json:"name,omitempty" yaml:"name,omitempty"` } // APIKeyCreationRequestExpiresInUnit Time unit for expiration type APIKeyCreationRequestExpiresInUnit string -// APIKeyGenerationResponse defines model for APIKeyGenerationResponse. -type APIKeyGenerationResponse struct { +// APIKeyCreationResponse defines model for APIKeyCreationResponse. +type APIKeyCreationResponse struct { // ApiKey Details of an API key ApiKey *APIKey `json:"api_key,omitempty" yaml:"api_key,omitempty"` Message string `json:"message" yaml:"message"` @@ -470,9 +476,6 @@ type APIKeyListResponse struct { // APIKeyRegenerationRequest defines model for APIKeyRegenerationRequest. type APIKeyRegenerationRequest struct { - // ApiKey The new API key value to set (minimum 16 characters) - ApiKey *string `json:"api_key,omitempty" yaml:"api_key,omitempty"` - // ExpiresAt Expiration timestamp ExpiresAt *time.Time `json:"expires_at,omitempty" yaml:"expires_at,omitempty"` @@ -495,6 +498,9 @@ type APIKeyRevocationResponse struct { Status string `json:"status" yaml:"status"` } +// APIKeyUpdateRequest defines model for APIKeyUpdateRequest. +type APIKeyUpdateRequest = APIKeyCreationRequest + // APIListItem defines model for APIListItem. type APIListItem struct { Context *string `json:"context,omitempty" yaml:"context,omitempty"` @@ -1397,7 +1403,7 @@ type UpdateAPIJSONRequestBody = APIConfiguration type CreateAPIKeyJSONRequestBody = APIKeyCreationRequest // UpdateAPIKeyJSONRequestBody defines body for UpdateAPIKey for application/json ContentType. -type UpdateAPIKeyJSONRequestBody = APIKeyRegenerationRequest +type UpdateAPIKeyJSONRequestBody = APIKeyUpdateRequest // RegenerateAPIKeyJSONRequestBody defines body for RegenerateAPIKey for application/json ContentType. type RegenerateAPIKeyJSONRequestBody = APIKeyRegenerationRequest @@ -1917,7 +1923,7 @@ type ServerInterface interface { // Revoke an API key // (DELETE /apis/{id}/api-keys/{apiKeyName}) RevokeAPIKey(c *gin.Context, id string, apiKeyName string) - // Update an API key with a specific value + // Update an API key with a new regenerated value // (PUT /apis/{id}/api-keys/{apiKeyName}) UpdateAPIKey(c *gin.Context, id string, apiKeyName string) // Regenerate API key for an API @@ -3000,176 +3006,190 @@ func RegisterHandlersWithOptions(router gin.IRouter, si ServerInterface, options // Base64 encoded, gzipped, json marshaled Swagger object var swaggerSpec = []string{ - "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", + "IwFZXzV3Kdx+vv3vAAAA//8YqttxMjkBAA==", } // GetSwagger returns the content of the embedded swagger specification file diff --git a/gateway/gateway-controller/pkg/api/handlers/handlers.go b/gateway/gateway-controller/pkg/api/handlers/handlers.go index e3344299f..e828c2f17 100644 --- a/gateway/gateway-controller/pkg/api/handlers/handlers.go +++ b/gateway/gateway-controller/pkg/api/handlers/handlers.go @@ -2386,8 +2386,8 @@ func (s *APIServer) CreateAPIKey(c *gin.Context, id string) { // Parse and validate request body var request api.APIKeyCreationRequest - if err := c.ShouldBindJSON(&request); err != nil { - log.Warn("Invalid request body for API key creation", + if err := s.bindRequestBody(c, &request); err != nil { + log.Error("Failed to parse request body for API key creation", slog.Any("error", err), slog.String("handle", handle), slog.String("correlation_id", correlationID)) @@ -2415,6 +2415,11 @@ func (s *APIServer) CreateAPIKey(c *gin.Context, id string) { Status: "error", Message: err.Error(), }) + } else if storage.IsConflictError(err) || strings.Contains(err.Error(), "already exists") { + c.JSON(http.StatusConflict, api.ErrorResponse{ + Status: "error", + Message: err.Error(), + }) } else { c.JSON(http.StatusInternalServerError, api.ErrorResponse{ Status: "error", @@ -2503,16 +2508,16 @@ func (s *APIServer) UpdateAPIKey(c *gin.Context, id string, apiKeyName string) { return // Error response already sent by extractAuthenticatedUser } - log.Debug("Starting API key rotation", + log.Debug("Starting API key update", slog.String("handle", handle), - slog.String("key name", apiKeyName), + slog.String("key_name", apiKeyName), slog.String("user", user.UserID), slog.String("correlation_id", correlationID)) // Parse and validate request body - var request api.APIKeyRegenerationRequest - if err := c.ShouldBindJSON(&request); err != nil { - log.Warn("Invalid request body for API key rotation", + var request api.APIKeyCreationRequest + if err := s.bindRequestBody(c, &request); err != nil { + log.Warn("Invalid request body for API key update", slog.Any("error", err), slog.String("handle", handle), slog.String("correlation_id", correlationID)) @@ -2523,6 +2528,15 @@ func (s *APIServer) UpdateAPIKey(c *gin.Context, id string, apiKeyName string) { return } + // If API key is not provided, return an error + if request.ApiKey == nil { + c.JSON(http.StatusBadRequest, api.ErrorResponse{ + Status: "error", + Message: "API key value is required", + }) + return + } + // Prepare parameters params := utils.APIKeyUpdateParams{ Handle: handle, @@ -2541,6 +2555,11 @@ func (s *APIServer) UpdateAPIKey(c *gin.Context, id string, apiKeyName string) { Status: "error", Message: err.Error(), }) + } else if storage.IsConflictError(err) || strings.Contains(err.Error(), "already exists") { + c.JSON(http.StatusConflict, api.ErrorResponse{ + Status: "error", + Message: err.Error(), + }) } else { c.JSON(http.StatusInternalServerError, api.ErrorResponse{ Status: "error", @@ -2550,13 +2569,12 @@ func (s *APIServer) UpdateAPIKey(c *gin.Context, id string, apiKeyName string) { return } - log.Info("API key rotation completed", + log.Info("API key updated successfully", slog.String("handle", handle), slog.String("key_name", apiKeyName), slog.String("user", user.UserID), slog.String("correlation_id", correlationID)) - // Return the response using the generated schema c.JSON(http.StatusOK, result.Response) } @@ -2582,7 +2600,7 @@ func (s *APIServer) RegenerateAPIKey(c *gin.Context, id string, apiKeyName strin // Parse and validate request body var request api.APIKeyRegenerationRequest - if err := c.ShouldBindJSON(&request); err != nil { + if err := s.bindRequestBody(c, &request); err != nil { log.Warn("Invalid request body for API key rotation", slog.Any("error", err), slog.String("handle", handle), @@ -2623,11 +2641,10 @@ func (s *APIServer) RegenerateAPIKey(c *gin.Context, id string, apiKeyName strin log.Info("API key rotation completed", slog.String("handle", handle), - slog.String("key name", apiKeyName), + slog.String("key_name", apiKeyName), slog.String("user", user.UserID), slog.String("correlation_id", correlationID)) - // Return the response using the generated schema c.JSON(http.StatusOK, result.Response) } @@ -2724,6 +2741,29 @@ func (s *APIServer) extractAuthenticatedUser(c *gin.Context, operationName strin return &user, true } +// bindRequestBody binds the request body based on Content-Type header. +// Supports both JSON and YAML content types. +// Handles Content-Type headers case-insensitively and strips parameters (e.g., charset). +func (s *APIServer) bindRequestBody(c *gin.Context, request interface{}) error { + contentType := c.GetHeader("Content-Type") + + // Normalize the Content-Type: trim whitespace, split off parameters, and convert to lowercase + contentType = strings.TrimSpace(contentType) + if idx := strings.Index(contentType, ";"); idx != -1 { + contentType = contentType[:idx] + } + contentType = strings.TrimSpace(contentType) + contentType = strings.ToLower(contentType) + + // Check for YAML content types (case-insensitive, normalized) + if contentType == "application/yaml" || contentType == "text/yaml" { + return c.ShouldBindYAML(request) + } + + // Default to JSON for application/json or when no content type is specified + return c.ShouldBindJSON(request) +} + // getLLMProviderTemplate extracts the template name from sourceConfig and retrieves the template. // Returns the template configuration if found, nil otherwise. func (s *APIServer) getLLMProviderTemplate(sourceConfig any) (*api.LLMProviderTemplate, error) { diff --git a/gateway/gateway-controller/pkg/apikeyxds/apikey_snapshot.go b/gateway/gateway-controller/pkg/apikeyxds/apikey_snapshot.go index 621012af8..f6036ecde 100644 --- a/gateway/gateway-controller/pkg/apikeyxds/apikey_snapshot.go +++ b/gateway/gateway-controller/pkg/apikeyxds/apikey_snapshot.go @@ -198,7 +198,8 @@ type APIKeyData struct { CreatedBy string `json:"createdBy"` UpdatedAt time.Time `json:"updatedAt"` ExpiresAt *time.Time `json:"expiresAt"` - Source string `json:"source"` // "local" | "external" + Source string `json:"source"` // "local" | "external" + IndexKey string `json:"indexKey"` // Pre-computed SHA-256 hash for O(1) lookup (external plain text keys only) } // TranslateAPIKeys translates API key configurations to xDS resources @@ -208,6 +209,10 @@ func (t *APIKeyTranslator) TranslateAPIKeys(apiKeys []*models.APIKey) (map[strin // Convert all API keys to a single state resource apiKeyData := make([]APIKeyData, 0, len(apiKeys)) for _, apiKey := range apiKeys { + var indexKey string + if apiKey.IndexKey != nil { + indexKey = *apiKey.IndexKey + } data := APIKeyData{ ID: apiKey.ID, Name: apiKey.Name, @@ -220,6 +225,7 @@ func (t *APIKeyTranslator) TranslateAPIKeys(apiKeys []*models.APIKey) (map[strin UpdatedAt: apiKey.UpdatedAt, ExpiresAt: apiKey.ExpiresAt, Source: apiKey.Source, + IndexKey: indexKey, } apiKeyData = append(apiKeyData, data) } diff --git a/gateway/gateway-controller/pkg/constants/constants.go b/gateway/gateway-controller/pkg/constants/constants.go index d88e65672..0bb306bc3 100644 --- a/gateway/gateway-controller/pkg/constants/constants.go +++ b/gateway/gateway-controller/pkg/constants/constants.go @@ -138,6 +138,10 @@ const ( APIKeyLen = 32 // Length of the random part of the API key in bytes APIKeySeparator = "_" + // API Key length constants + MIN_API_KEY_LENGTH = 20 + MAX_API_KEY_LENGTH = 128 + // HashingAlgorithm constants HashingAlgorithmSHA256 = "sha256" HashingAlgorithmBcrypt = "bcrypt" diff --git a/gateway/gateway-controller/pkg/controlplane/client.go b/gateway/gateway-controller/pkg/controlplane/client.go index 75c22ff4d..61c1301e0 100644 --- a/gateway/gateway-controller/pkg/controlplane/client.go +++ b/gateway/gateway-controller/pkg/controlplane/client.go @@ -29,6 +29,7 @@ import ( "sync/atomic" "time" + api "github.com/wso2/api-platform/gateway/gateway-controller/pkg/api/generated" "github.com/wso2/api-platform/gateway/gateway-controller/pkg/models" "github.com/gorilla/websocket" @@ -627,16 +628,19 @@ func (c *Client) handleAPIUndeployedEvent(event map[string]interface{}) { // handleAPIKeyCreatedEvent handles API key created events from platform-api func (c *Client) handleAPIKeyCreatedEvent(event map[string]interface{}) { - c.logger.Info("API Key Created Event", - slog.Any("payload", event["payload"]), + baseLogger := c.logger + if baseLogger == nil { + baseLogger = slog.Default() + } + baseLogger.Info("API Key Created Event received", + slog.Any("correlation_id", event["correlationId"]), slog.Any("timestamp", event["timestamp"]), - slog.Any("correlationId", event["correlationId"]), ) - // Parse the event into structured format eventBytes, err := json.Marshal(event) if err != nil { - c.logger.Error("Failed to marshal event for parsing", + baseLogger.Error("Failed to marshal API key created event for parsing", + slog.Any("correlation_id", event["correlationId"]), slog.Any("error", err), ) return @@ -644,231 +648,306 @@ func (c *Client) handleAPIKeyCreatedEvent(event map[string]interface{}) { var keyCreatedEvent APIKeyCreatedEvent if err := json.Unmarshal(eventBytes, &keyCreatedEvent); err != nil { - c.logger.Error("Failed to parse API key created event", + baseLogger.Error("Failed to parse API key created event", + slog.Any("correlation_id", event["correlationId"]), slog.Any("error", err), ) return } - // Extract event payload - payload := keyCreatedEvent.Payload - - // Validate required fields - if payload.ApiId == "" { - c.logger.Error("API ID is empty in API key created event") - return - } - if payload.KeyName == "" { - c.logger.Error("Key name is empty in API key created event") + // Defensive nil/empty checks on required fields before logging or proceeding + if keyCreatedEvent.Payload.ApiId == "" { + baseLogger.Error("API key created event missing required api_id", + slog.Any("correlation_id", event["correlationId"]), + ) return } - if payload.ApiKey == "" { - c.logger.Error("API key is empty in API key created event") + if keyCreatedEvent.Payload.ApiKey == "" { + baseLogger.Error("API key created event missing required api_key", + slog.Any("correlation_id", event["correlationId"]), + ) return } - c.logger.Info("Processing API key creation", - slog.String("api_id", payload.ApiId), - slog.String("key_name", payload.KeyName), + logger := baseLogger.With( slog.String("correlation_id", keyCreatedEvent.CorrelationID), + slog.String("user_id", keyCreatedEvent.UserId), + slog.String("api_id", keyCreatedEvent.Payload.ApiId), ) - // Parse expiration time if provided + payload := keyCreatedEvent.Payload + var expiresAt *time.Time - if payload.ExpiresAt != nil && *payload.ExpiresAt != "" { - parsedTime, err := time.Parse(time.RFC3339, *payload.ExpiresAt) + var duration *int + now := time.Now() + + + apiKeyCreationRequest := api.APIKeyCreationRequest{ + ApiKey: &payload.ApiKey, + DisplayName: payload.DisplayName, + ExternalRefId: payload.ExternalRefId, + } + if payload.ExpiresAt != nil { + // payload.ExpiresAt is likely a *string (RFC3339). Attempt to parse it to time.Time + parsedExpiresAt, err := time.Parse(time.RFC3339, *payload.ExpiresAt) if err != nil { - c.logger.Warn("Failed to parse expiration time, proceeding without expiry", - slog.String("expires_at", *payload.ExpiresAt), + logger.Error("Invalid expires_at format for API key, expected RFC3339", + slog.Any("expires_at", *payload.ExpiresAt), slog.Any("error", err), ) - } else { - expiresAt = &parsedTime + return } + if parsedExpiresAt.Before(now) { + logger.Error("API key expiration time must be in the future", + slog.String("expires_at", parsedExpiresAt.Format(time.RFC3339)), + slog.String("now", now.Format(time.RFC3339))) + return + } + // If expires_at is explicitly provided, use it + expiresAt = &parsedExpiresAt + apiKeyCreationRequest.ExpiresAt = expiresAt + } else if payload.ExpiresIn != nil { + duration = &payload.ExpiresIn.Duration + timeDuration := time.Duration(*duration) + switch payload.ExpiresIn.Unit { + case string(api.APIKeyCreationRequestExpiresInUnitSeconds): + timeDuration *= time.Second + case string(api.APIKeyCreationRequestExpiresInUnitMinutes): + timeDuration *= time.Minute + case string(api.APIKeyCreationRequestExpiresInUnitHours): + timeDuration *= time.Hour + case string(api.APIKeyCreationRequestExpiresInUnitDays): + timeDuration *= 24 * time.Hour + case string(api.APIKeyCreationRequestExpiresInUnitWeeks): + timeDuration *= 7 * 24 * time.Hour + case string(api.APIKeyCreationRequestExpiresInUnitMonths): + timeDuration *= 30 * 24 * time.Hour // Approximate month as 30 days + default: + logger.Error("Unsupported expiration unit", slog.Any("expires_in.unit", payload.ExpiresIn.Unit)) + return + } + expiry := now.Add(timeDuration) + expiresAt = &expiry + apiKeyCreationRequest.ExpiresAt = expiresAt } - // Create the external API key - err = c.apiKeyService.CreateExternalAPIKeyFromEvent( + result, err := c.apiKeyService.CreateExternalAPIKeyFromEvent( payload.ApiId, - payload.KeyName, - payload.ApiKey, // Plain text API key from platform-api - payload.ExternalRefId, - payload.Operations, - expiresAt, - c.logger, + keyCreatedEvent.UserId, + &apiKeyCreationRequest, + keyCreatedEvent.CorrelationID, + logger, ) - if err != nil { - c.logger.Error("Failed to create external API key", - slog.String("api_id", payload.ApiId), - slog.String("key_name", payload.KeyName), - slog.String("correlation_id", keyCreatedEvent.CorrelationID), - slog.Any("error", err), - ) + logger.Error("Failed to create external API key", slog.Any("error", err)) return } - c.logger.Info("Successfully processed API key created event", - slog.String("api_id", payload.ApiId), - slog.String("key_name", payload.KeyName), - slog.String("correlation_id", keyCreatedEvent.CorrelationID), + logger.Info("Successfully processed API key created event", + slog.String("api_key_name", result.Response.ApiKey.Name), ) } // handleAPIKeyRevokedEvent handles API key revoked events from platform-api func (c *Client) handleAPIKeyRevokedEvent(event map[string]interface{}) { - c.logger.Info("API Key Revoked Event", - slog.Any("payload", event["payload"]), + baseLogger := c.logger + if baseLogger == nil { + baseLogger = slog.Default() + } + baseLogger.Info("API Key Revoked Event received", + slog.Any("correlation_id", event["correlationId"]), slog.Any("timestamp", event["timestamp"]), - slog.Any("correlationId", event["correlationId"]), ) - // Parse the event into structured format eventBytes, err := json.Marshal(event) if err != nil { - c.logger.Error("Failed to marshal event for parsing", + baseLogger.Error("Failed to marshal API key revoked event for parsing", + slog.Any("correlation_id", event["correlationId"]), slog.Any("error", err), ) return } - var keyRevokedEvent APIKeyRevokedEvent - if err := json.Unmarshal(eventBytes, &keyRevokedEvent); err != nil { - c.logger.Error("Failed to parse API key revoked event", + var evt APIKeyRevokedEvent + if err := json.Unmarshal(eventBytes, &evt); err != nil { + baseLogger.Error("Failed to parse API key revoked event", + slog.Any("correlation_id", event["correlationId"]), slog.Any("error", err), ) return } - // Extract event payload - payload := keyRevokedEvent.Payload - - // Validate required fields - if payload.ApiId == "" { - c.logger.Error("API ID is empty in API key revoked event") + // Defensive nil/empty checks on required fields before logging or proceeding + if evt.Payload.ApiId == "" { + baseLogger.Error("API key revoked event missing required api_id", + slog.Any("correlation_id", event["correlationId"]), + ) return } - if payload.KeyName == "" { - c.logger.Error("Key name is empty in API key revoked event") + if evt.Payload.KeyName == "" { + baseLogger.Error("API key revoked event missing required key_name", + slog.Any("correlation_id", event["correlationId"]), + ) return } - c.logger.Info("Processing API key revocation", - slog.String("api_id", payload.ApiId), - slog.String("key_name", payload.KeyName), - slog.String("correlation_id", keyRevokedEvent.CorrelationID), + logger := baseLogger.With( + slog.String("correlation_id", evt.CorrelationID), + slog.String("user_id", evt.UserId), + slog.String("api_id", evt.Payload.ApiId), + slog.String("api_key_name", evt.Payload.KeyName), ) - // Revoke the external API key + payload := evt.Payload + err = c.apiKeyService.RevokeExternalAPIKeyFromEvent( payload.ApiId, payload.KeyName, - c.logger, + evt.UserId, + evt.CorrelationID, + logger, ) - if err != nil { - c.logger.Error("Failed to revoke external API key", - slog.String("api_id", payload.ApiId), - slog.String("key_name", payload.KeyName), - slog.String("correlation_id", keyRevokedEvent.CorrelationID), - slog.Any("error", err), - ) + logger.Error("Failed to revoke external API key", slog.Any("error", err)) return } - c.logger.Info("Successfully processed API key revoked event", - slog.String("api_id", payload.ApiId), - slog.String("key_name", payload.KeyName), - slog.String("correlation_id", keyRevokedEvent.CorrelationID), - ) + logger.Info("Successfully processed API key revoked event") } -// handleAPIKeyUpdatedEvent handles API key updated events from platform-api +// handleAPIKeyUpdatedEvent handles API key updated events from platform-api. func (c *Client) handleAPIKeyUpdatedEvent(event map[string]interface{}) { - c.logger.Info("API Key Updated Event", - slog.Any("payload", event["payload"]), + baseLogger := c.logger + if baseLogger == nil { + baseLogger = slog.Default() + } + baseLogger.Info("API Key Updated Event received", + slog.Any("correlation_id", event["correlationId"]), slog.Any("timestamp", event["timestamp"]), - slog.Any("correlationId", event["correlationId"]), ) - - // Parse the event into structured format eventBytes, err := json.Marshal(event) if err != nil { - c.logger.Error("Failed to marshal event for parsing", + baseLogger.Error("Failed to marshal event for parsing", + slog.Any("correlation_id", event["correlationId"]), slog.Any("error", err), ) return } - var keyUpdatedEvent APIKeyUpdatedEvent - if err := json.Unmarshal(eventBytes, &keyUpdatedEvent); err != nil { - c.logger.Error("Failed to parse API key updated event", + var evt APIKeyUpdatedEvent + if err := json.Unmarshal(eventBytes, &evt); err != nil { + baseLogger.Error("Failed to parse API key updated event", + slog.Any("correlation_id", event["correlationId"]), slog.Any("error", err), ) return } - // Extract event payload - payload := keyUpdatedEvent.Payload + payload := evt.Payload - // Validate required fields + // Defensive nil/empty checks on required fields if payload.ApiId == "" { - c.logger.Error("API ID is empty in API key updated event") + baseLogger.Error("API key updated event missing required api_id", + slog.Any("correlation_id", event["correlationId"]), + ) return } if payload.KeyName == "" { - c.logger.Error("Key name is empty in API key updated event") + baseLogger.Error("API key updated event missing required key_name", + slog.Any("correlation_id", event["correlationId"]), + ) return } if payload.ApiKey == "" { - c.logger.Error("API key is empty in API key updated event") + baseLogger.Error("API key updated event missing required api_key", + slog.Any("correlation_id", event["correlationId"]), + slog.String("api_id", payload.ApiId), + slog.String("key_name", payload.KeyName), + ) + return + } + if payload.DisplayName == "" { + baseLogger.Error("API key updated event missing required display_name", + slog.Any("correlation_id", event["correlationId"]), + slog.String("api_id", payload.ApiId), + slog.String("key_name", payload.KeyName), + ) return } - c.logger.Info("Processing API key update", + logger := baseLogger.With( + slog.String("correlation_id", evt.CorrelationID), + slog.String("user_id", evt.UserId), slog.String("api_id", payload.ApiId), slog.String("key_name", payload.KeyName), - slog.String("correlation_id", keyUpdatedEvent.CorrelationID), ) - // Parse expiration time if provided var expiresAt *time.Time - if payload.ExpiresAt != nil && *payload.ExpiresAt != "" { - parsedTime, err := time.Parse(time.RFC3339, *payload.ExpiresAt) + var duration *int + now := time.Now() + + apiKeyCreationRequest := api.APIKeyCreationRequest{ + ApiKey: &payload.ApiKey, + DisplayName: &payload.DisplayName, + ExternalRefId: &payload.ExternalRefId, + } + if payload.ExpiresAt != nil { + // payload.ExpiresAt is likely a *string (RFC3339). Attempt to parse it to time.Time + parsedExpiresAt, err := time.Parse(time.RFC3339, *payload.ExpiresAt) if err != nil { - c.logger.Warn("Failed to parse expiration time, proceeding without expiry", - slog.String("expires_at", *payload.ExpiresAt), + logger.Error("Invalid expires_at format for API key, expected RFC3339", + slog.Any("expires_at", *payload.ExpiresAt), slog.Any("error", err), ) - } else { - expiresAt = &parsedTime + return } + if parsedExpiresAt.Before(now) { + logger.Error("API key expiration time must be in the future", + slog.String("expires_at", parsedExpiresAt.Format(time.RFC3339)), + slog.String("now", now.Format(time.RFC3339))) + return + } + // If expires_at is explicitly provided, use it + expiresAt = &parsedExpiresAt + apiKeyCreationRequest.ExpiresAt = expiresAt + } else if payload.ExpiresIn != nil { + duration = &payload.ExpiresIn.Duration + timeDuration := time.Duration(*duration) + switch payload.ExpiresIn.Unit { + case string(api.APIKeyCreationRequestExpiresInUnitSeconds): + timeDuration *= time.Second + case string(api.APIKeyCreationRequestExpiresInUnitMinutes): + timeDuration *= time.Minute + case string(api.APIKeyCreationRequestExpiresInUnitHours): + timeDuration *= time.Hour + case string(api.APIKeyCreationRequestExpiresInUnitDays): + timeDuration *= 24 * time.Hour + case string(api.APIKeyCreationRequestExpiresInUnitWeeks): + timeDuration *= 7 * 24 * time.Hour + case string(api.APIKeyCreationRequestExpiresInUnitMonths): + timeDuration *= 30 * 24 * time.Hour // Approximate month as 30 days + default: + logger.Error("Unsupported expiration unit", slog.Any("expires_in.unit", payload.ExpiresIn.Unit)) + return + } + expiry := now.Add(timeDuration) + expiresAt = &expiry + apiKeyCreationRequest.ExpiresAt = expiresAt } - // Update the external API key err = c.apiKeyService.UpdateExternalAPIKeyFromEvent( payload.ApiId, payload.KeyName, - payload.ApiKey, // Plain text API key from platform-api - expiresAt, - c.logger, + &apiKeyCreationRequest, + evt.UserId, + evt.CorrelationID, + logger, ) - if err != nil { - c.logger.Error("Failed to update external API key", - slog.String("api_id", payload.ApiId), - slog.String("key_name", payload.KeyName), - slog.String("correlation_id", keyUpdatedEvent.CorrelationID), - slog.Any("error", err), - ) + logger.Error("Failed to update external API key", slog.Any("error", err)) return } - - c.logger.Info("Successfully processed API key updated event", - slog.String("api_id", payload.ApiId), - slog.String("key_name", payload.KeyName), - slog.String("correlation_id", keyUpdatedEvent.CorrelationID), - ) + logger.Info("Successfully processed API key updated event") } // calculateNextRetryDelay calculates the next retry delay with exponential backoff and jitter diff --git a/gateway/gateway-controller/pkg/controlplane/events.go b/gateway/gateway-controller/pkg/controlplane/events.go index 3d6d4fd23..c2a3c0320 100644 --- a/gateway/gateway-controller/pkg/controlplane/events.go +++ b/gateway/gateway-controller/pkg/controlplane/events.go @@ -43,15 +43,18 @@ type APIDeployedEvent struct { CorrelationID string `json:"correlationId"` } -// APIKeyCreatedEventPayload represents the payload of an API key created event +// APIKeyCreatedEventPayload represents the payload of an API key created event. type APIKeyCreatedEventPayload struct { ApiId string `json:"apiId"` - KeyName string `json:"keyName"` ApiKey string `json:"apiKey"` // Plain text API key (will be hashed by gateway) ExternalRefId *string `json:"externalRefId,omitempty"` Operations string `json:"operations"` ExpiresAt *string `json:"expiresAt,omitempty"` // ISO 8601 format - // TODO: Support expires in field + ExpiresIn *struct { + Duration int `json:"duration,omitempty"` + Unit string `json:"unit,omitempty"` + } `json:"expiresIn,omitempty"` + DisplayName *string `json:"displayName,omitempty"` } // APIKeyCreatedEvent represents the complete API key created event @@ -60,34 +63,43 @@ type APIKeyCreatedEvent struct { Payload APIKeyCreatedEventPayload `json:"payload"` Timestamp string `json:"timestamp"` CorrelationID string `json:"correlationId"` + UserId string `json:"userId"` } -// APIKeyRevokedEventPayload represents the payload of an API key revoked event -type APIKeyRevokedEventPayload struct { - ApiId string `json:"apiId"` - KeyName string `json:"keyName"` +type APIKeyUpdatedEventPayload struct { + ApiId string `json:"apiId"` + KeyName string `json:"keyName"` + ApiKey string `json:"apiKey"` // Plain text API key (will be hashed by gateway) + ExternalRefId string `json:"externalRefId"` + Operations string `json:"operations"` + ExpiresAt *string `json:"expiresAt,omitempty"` // ISO 8601 format + ExpiresIn *struct { + Duration int `json:"duration,omitempty"` + Unit string `json:"unit,omitempty"` + } `json:"expiresIn,omitempty"` + DisplayName string `json:"displayName"` } -// APIKeyRevokedEvent represents the complete API key revoked event -type APIKeyRevokedEvent struct { +// APIKeyUpdatedEvent represents the complete API key updated event +type APIKeyUpdatedEvent struct { Type string `json:"type"` - Payload APIKeyRevokedEventPayload `json:"payload"` + Payload APIKeyUpdatedEventPayload `json:"payload"` Timestamp string `json:"timestamp"` CorrelationID string `json:"correlationId"` + UserId string `json:"userId"` } -// APIKeyUpdatedEventPayload represents the payload of an API key updated event -type APIKeyUpdatedEventPayload struct { - ApiId string `json:"apiId"` - KeyName string `json:"keyName"` - ApiKey string `json:"apiKey"` // Plain text API key (will be hashed by gateway) - ExpiresAt *string `json:"expiresAt,omitempty"` // ISO 8601 format +// APIKeyRevokedEventPayload represents the payload of an API key revoked event +type APIKeyRevokedEventPayload struct { + ApiId string `json:"apiId"` + KeyName string `json:"keyName"` } -// APIKeyUpdatedEvent represents the complete API key updated event -type APIKeyUpdatedEvent struct { +// APIKeyRevokedEvent represents the complete API key revoked event +type APIKeyRevokedEvent struct { Type string `json:"type"` - Payload APIKeyUpdatedEventPayload `json:"payload"` + Payload APIKeyRevokedEventPayload `json:"payload"` Timestamp string `json:"timestamp"` CorrelationID string `json:"correlationId"` + UserId string `json:"userId"` } diff --git a/gateway/gateway-controller/pkg/models/api_key.go b/gateway/gateway-controller/pkg/models/api_key.go index efb2c259a..02ce7d657 100644 --- a/gateway/gateway-controller/pkg/models/api_key.go +++ b/gateway/gateway-controller/pkg/models/api_key.go @@ -34,8 +34,9 @@ const ( // APIKey represents an API key for an API type APIKey struct { ID string `json:"id" db:"id"` - Name string `json:"name" db:"name"` - APIKey string `json:"api_key" db:"api_key"` // Stores hashed API key + Name string `json:"name" db:"name"` // URL-safe identifier (auto-generated, immutable) + DisplayName string `json:"display_name" db:"display_name"` // Human-readable name (user-provided, mutable) + APIKey string `json:"api_key" db:"api_key"` // Stores hashed API key MaskedAPIKey string `json:"masked_api_key" db:"masked_api_key"` // Stores masked API key for display PlainAPIKey string `json:"-" db:"-"` // Temporary field for plain API key (not persisted) APIId string `json:"apiId" db:"apiId"` @@ -51,6 +52,7 @@ type APIKey struct { // Source tracking for external key support Source string `json:"source" db:"source"` // "local" | "external" ExternalRefId *string `json:"external_ref_id" db:"external_ref_id"` // Cloud APIM key ID or other external reference + IndexKey *string `json:"index_key" db:"index_key"` // Pre-computed SHA-256 hash for O(1) lookup (external plain text keys only) } // IsValid checks if the API key is valid (active and not expired) diff --git a/gateway/gateway-controller/pkg/storage/apikey_store.go b/gateway/gateway-controller/pkg/storage/apikey_store.go index b056055ea..4a868657f 100644 --- a/gateway/gateway-controller/pkg/storage/apikey_store.go +++ b/gateway/gateway-controller/pkg/storage/apikey_store.go @@ -32,6 +32,7 @@ type APIKeyStore struct { mu sync.RWMutex apiKeys map[string]*models.APIKey // key: configID:APIKeyName → Value: *APIKey apiKeysByAPI map[string]map[string]*models.APIKey // Key: configID → Value: map[keyID]*APIKey + externalKeyIndex map[string]map[string]*string // Key: configID → Value: map[indexKey]*string resourceVersion int64 logger *slog.Logger } @@ -41,6 +42,7 @@ func NewAPIKeyStore(logger *slog.Logger) *APIKeyStore { return &APIKeyStore{ apiKeys: make(map[string]*models.APIKey), apiKeysByAPI: make(map[string]map[string]*models.APIKey), + externalKeyIndex: make(map[string]map[string]*string), logger: logger, } } @@ -68,9 +70,15 @@ func (s *APIKeyStore) Store(apiKey *models.APIKey) error { // Handle both rotation and generation scenarios for existing key name delete(s.apiKeys, compositeKey) delete(s.apiKeysByAPI[apiKey.APIId], existingKeyID) + if apiKey.Source == "external" { + delete(s.externalKeyIndex[apiKey.APIId], *apiKey.IndexKey) + } // Store the new key (could be same ID with new value, or new ID entirely) s.apiKeys[compositeKey] = apiKey s.apiKeysByAPI[apiKey.APIId][apiKey.ID] = apiKey + if apiKey.Source == "external" { + s.externalKeyIndex[apiKey.APIId][*apiKey.IndexKey] = &apiKey.ID + } } else { // Store the API key s.apiKeys[compositeKey] = apiKey @@ -146,6 +154,7 @@ func (s *APIKeyStore) RemoveByAPI(apiId string) int { delete(s.apiKeys, compositeKey) } delete(s.apiKeysByAPI, apiId) + delete(s.externalKeyIndex, apiId) s.logger.Debug("Removed API keys by API", slog.String("api_id", apiId), @@ -178,8 +187,17 @@ func (s *APIKeyStore) addToAPIMapping(apiKey *models.APIKey) { s.apiKeysByAPI[apiKey.APIId] = make(map[string]*models.APIKey) } + // Initialize the map for this API ID if it doesn't exist + if s.externalKeyIndex[apiKey.APIId] == nil { + s.externalKeyIndex[apiKey.APIId] = make(map[string]*string) + } + // Store by API key ID s.apiKeysByAPI[apiKey.APIId][apiKey.ID] = apiKey + if apiKey.Source == "external" { + externalKeyIndexKey := *apiKey.IndexKey + s.externalKeyIndex[apiKey.APIId][externalKeyIndexKey] = &apiKey.ID + } } // removeFromAPIMapping removes an API key from the API mapping @@ -191,6 +209,14 @@ func (s *APIKeyStore) removeFromAPIMapping(apiKey *models.APIKey) { if len(s.apiKeysByAPI[apiKey.APIId]) == 0 { delete(s.apiKeysByAPI, apiKey.APIId) } + if apiKey.Source == "external" { + externalKeyIndexKey := *apiKey.IndexKey + delete(s.externalKeyIndex[apiKey.APIId], externalKeyIndexKey) + } + // clean up empty maps + if len(s.externalKeyIndex[apiKey.APIId]) == 0 { + delete(s.externalKeyIndex, apiKey.APIId) + } } } diff --git a/gateway/gateway-controller/pkg/storage/gateway-controller-db.sql b/gateway/gateway-controller/pkg/storage/gateway-controller-db.sql index 0cc6ef26d..ac1c9f9f1 100644 --- a/gateway/gateway-controller/pkg/storage/gateway-controller-db.sql +++ b/gateway/gateway-controller/pkg/storage/gateway-controller-db.sql @@ -143,6 +143,12 @@ CREATE TABLE IF NOT EXISTS api_keys ( source TEXT NOT NULL DEFAULT 'local', -- 'local' or 'external' external_ref_id TEXT NULL, -- Cloud APIM key ID or other external reference + -- O(1) lookup optimization for external keys + index_key TEXT NULL, -- Pre-computed SHA-256 hash for fast lookup + + -- Human-readable display name for the API key + display_name TEXT NOT NULL DEFAULT '', + -- Foreign key relationship to deployments FOREIGN KEY (apiId) REFERENCES deployments(id) ON DELETE CASCADE, @@ -158,6 +164,7 @@ CREATE INDEX IF NOT EXISTS idx_api_key_expiry ON api_keys(expires_at); CREATE INDEX IF NOT EXISTS idx_created_by ON api_keys(created_by); CREATE INDEX IF NOT EXISTS idx_api_key_source ON api_keys(source); CREATE INDEX IF NOT EXISTS idx_api_key_external_ref ON api_keys(external_ref_id); +CREATE INDEX IF NOT EXISTS idx_api_key_index_key ON api_keys(index_key); --- Set schema version to 6 +-- Set schema version to 6 (api_keys with external ref, index_key, display_name) PRAGMA user_version = 6; diff --git a/gateway/gateway-controller/pkg/storage/memory.go b/gateway/gateway-controller/pkg/storage/memory.go index bb70c00f5..d4a8bd86f 100644 --- a/gateway/gateway-controller/pkg/storage/memory.go +++ b/gateway/gateway-controller/pkg/storage/memory.go @@ -43,6 +43,8 @@ type ConfigStore struct { // API Keys storage apiKeysByAPI map[string]map[string]*models.APIKey // Key: configID → Value: map[keyID]*APIKey + externalKeyIndex map[string]map[string]*string // Key: configID → Value: map[indexKey]*string + // Labels storage labelsByAPI map[string]map[string]string // Key: API handle (metadata.name) → Value: labels map } @@ -58,6 +60,7 @@ func NewConfigStore() *ConfigStore { templates: make(map[string]*models.StoredLLMProviderTemplate), templateIdByHandle: make(map[string]string), apiKeysByAPI: make(map[string]map[string]*models.APIKey), + externalKeyIndex: make(map[string]map[string]*string), labelsByAPI: make(map[string]map[string]string), } } @@ -525,9 +528,25 @@ func (cs *ConfigStore) StoreAPIKey(apiKey *models.APIKey) error { } if existingKeyID != "" { + // Remove old external index entry using the previous key's IndexKey (avoid leaking stale entries after rotation) + oldEntry := cs.apiKeysByAPI[apiKey.APIId][existingKeyID] + if oldEntry != nil && oldEntry.Source == "external" && oldEntry.IndexKey != nil { + if extIndex, ok := cs.externalKeyIndex[apiKey.APIId]; ok && extIndex != nil { + delete(extIndex, *oldEntry.IndexKey) + } + } // Update the existing entry in apiKeysByAPI delete(cs.apiKeysByAPI[apiKey.APIId], existingKeyID) cs.apiKeysByAPI[apiKey.APIId][apiKey.ID] = apiKey // in API key rotation scenario apiKey.ID = existingKeyID + if apiKey.Source == "external" { + if apiKey.IndexKey == nil { + return fmt.Errorf("external API key must have IndexKey set") + } + if cs.externalKeyIndex[apiKey.APIId] == nil { + cs.externalKeyIndex[apiKey.APIId] = make(map[string]*string) + } + cs.externalKeyIndex[apiKey.APIId][*apiKey.IndexKey] = &apiKey.ID + } } else { // Insert new API key // Check if API key ID already exists @@ -540,8 +559,22 @@ func (cs *ConfigStore) StoreAPIKey(apiKey *models.APIKey) error { cs.apiKeysByAPI[apiKey.APIId] = make(map[string]*models.APIKey) } - // Store API key by API ID and API key ID + // Initialize the map for this API ID if it doesn't exist + if cs.externalKeyIndex[apiKey.APIId] == nil { + cs.externalKeyIndex[apiKey.APIId] = make(map[string]*string) + } + + // Store API key by API ID and API key ID and externalKeyIndex cs.apiKeysByAPI[apiKey.APIId][apiKey.ID] = apiKey + if apiKey.Source == "external" { + if apiKey.IndexKey == nil { + return fmt.Errorf("external API key must have IndexKey set") + } + if cs.externalKeyIndex[apiKey.APIId] == nil { + cs.externalKeyIndex[apiKey.APIId] = make(map[string]*string) + } + cs.externalKeyIndex[apiKey.APIId][*apiKey.IndexKey] = &apiKey.ID + } } return nil @@ -622,18 +655,32 @@ func (cs *ConfigStore) RemoveAPIKeyByID(apiId, id string) error { cs.mu.Lock() defer cs.mu.Unlock() - _, exists := cs.apiKeysByAPI[apiId][id] + apiKeys, exists := cs.apiKeysByAPI[apiId] + if !exists { + return ErrNotFound + } + apiKey, exists := apiKeys[id] if !exists { return ErrNotFound } + // Remove from external key index before removing from apiKeysByAPI (need apiKey while still in map) + if apiKey != nil && apiKey.Source == "external" && apiKey.IndexKey != nil { + if extIndex, ok := cs.externalKeyIndex[apiId]; ok { + delete(extIndex, *apiKey.IndexKey) + } + } + // Remove from apiKeysByAPI map - apiKeys, _ := cs.apiKeysByAPI[apiId] delete(apiKeys, id) + // Clean up empty maps if len(cs.apiKeysByAPI[apiId]) == 0 { delete(cs.apiKeysByAPI, apiId) } + if extIndex, ok := cs.externalKeyIndex[apiId]; ok && len(extIndex) == 0 { + delete(cs.externalKeyIndex, apiId) + } return nil } @@ -650,7 +697,7 @@ func (cs *ConfigStore) RemoveAPIKeysByAPI(apiId string) error { // Remove from API-specific map delete(cs.apiKeysByAPI, apiId) - + delete(cs.externalKeyIndex, apiId) return nil } diff --git a/gateway/gateway-controller/pkg/storage/sqlite.go b/gateway/gateway-controller/pkg/storage/sqlite.go index 957321642..f9ae07151 100644 --- a/gateway/gateway-controller/pkg/storage/sqlite.go +++ b/gateway/gateway-controller/pkg/storage/sqlite.go @@ -84,7 +84,7 @@ func (s *SQLiteStorage) initSchema() error { } if version == 0 { - s.logger.Info("Initializing database schema (version 1)") + s.logger.Info("Initializing database schema (version 6)") s.logger.Debug("Creating schema with SQL", slog.String("schema_sql", schemaSQL)) // Execute schema creation SQL @@ -272,10 +272,31 @@ func (s *SQLiteStorage) initSchema() error { if _, err := s.db.Exec(`CREATE INDEX IF NOT EXISTS idx_api_key_external_ref ON api_keys(external_ref_id);`); err != nil { return fmt.Errorf("failed to create api_keys external_ref_id index: %w", err) } + // Add index_key column for O(1) external API key lookup optimization + err = s.db.QueryRow(`SELECT COUNT(*) FROM pragma_table_info('api_keys') WHERE name = 'index_key'`).Scan(&columnExists) + if err == nil && columnExists == 0 { + if _, err := s.db.Exec(`ALTER TABLE api_keys ADD COLUMN index_key TEXT NULL`); err != nil { + return fmt.Errorf("failed to add index_key column to api_keys: %w", err) + } + } + if _, err := s.db.Exec(`CREATE INDEX IF NOT EXISTS idx_api_key_index_key ON api_keys(index_key);`); err != nil { + return fmt.Errorf("failed to create api_keys index_key index: %w", err) + } + // Add display_name column for human-readable API key names + err = s.db.QueryRow(`SELECT COUNT(*) FROM pragma_table_info('api_keys') WHERE name = 'display_name'`).Scan(&columnExists) + if err == nil && columnExists == 0 { + if _, err := s.db.Exec(`ALTER TABLE api_keys ADD COLUMN display_name TEXT NOT NULL DEFAULT ''`); err != nil { + return fmt.Errorf("failed to add display_name column to api_keys: %w", err) + } + // Backfill existing rows: set display_name = name for existing API keys + if _, err := s.db.Exec(`UPDATE api_keys SET display_name = name WHERE display_name = ''`); err != nil { + s.logger.Warn("Failed to backfill api_keys.display_name", slog.Any("error", err)) + } + } if _, err := s.db.Exec("PRAGMA user_version = 6"); err != nil { return fmt.Errorf("failed to set schema version to 6: %w", err) } - s.logger.Info("Schema migrated to version 6 (external API key support)") + s.logger.Info("Schema migrated to version 6 (api_keys: external ref, index_key, display_name)") version = 6 } @@ -1168,6 +1189,7 @@ func (s *SQLiteStorage) DeleteCertificate(id string) error { // SaveAPIKey persists a new API key to the database or updates existing one // if an API key with the same apiId and name already exists func (s *SQLiteStorage) SaveAPIKey(apiKey *models.APIKey) error { + // Begin transaction to ensure atomicity tx, err := s.db.Begin() if err != nil { @@ -1182,6 +1204,22 @@ func (s *SQLiteStorage) SaveAPIKey(apiKey *models.APIKey) error { } }() + // Before inserting, check for duplicates if this is an external key + if apiKey.Source == "external" && apiKey.IndexKey != nil { + var count int + checkQuery := `SELECT COUNT(*) FROM api_keys + WHERE apiId = ? AND index_key = ? AND source = 'external'` + err := tx.QueryRow(checkQuery, apiKey.APIId, apiKey.IndexKey).Scan(&count) + if err != nil { + tx.Rollback() + return fmt.Errorf("failed to check for duplicate API key: %w", err) + } + if count > 0 { + tx.Rollback() + return fmt.Errorf("%w: API key value already exists for this API", ErrConflict) + } + } + // First, check if an API key with the same apiId and name exists checkQuery := `SELECT id FROM api_keys WHERE apiId = ? AND name = ?` var existingID string @@ -1196,15 +1234,16 @@ func (s *SQLiteStorage) SaveAPIKey(apiKey *models.APIKey) error { // No existing record, insert new API key insertQuery := ` INSERT INTO api_keys ( - id, name, api_key, masked_api_key, apiId, operations, status, + id, name, display_name, api_key, masked_api_key, apiId, operations, status, created_at, created_by, updated_at, expires_at, expires_in_unit, expires_in_duration, - source, external_ref_id - ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) + source, external_ref_id, index_key + ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) ` _, err := tx.Exec(insertQuery, apiKey.ID, apiKey.Name, + apiKey.DisplayName, apiKey.APIKey, apiKey.MaskedAPIKey, apiKey.APIId, @@ -1218,6 +1257,7 @@ func (s *SQLiteStorage) SaveAPIKey(apiKey *models.APIKey) error { apiKey.Duration, apiKey.Source, apiKey.ExternalRefId, + apiKey.IndexKey, ) if err != nil { @@ -1235,44 +1275,13 @@ func (s *SQLiteStorage) SaveAPIKey(apiKey *models.APIKey) error { slog.String("apiId", apiKey.APIId), slog.String("created_by", apiKey.CreatedBy)) } else { - // Existing record found, update it with new API key data - updateQuery := ` - UPDATE api_keys - SET api_key = ?, masked_api_key = ?, operations = ?, status = ?, created_by = ?, updated_at = ?, expires_at = ?, expires_in_unit = ?, expires_in_duration = ?, - source = ?, external_ref_id = ? - WHERE apiId = ? AND name = ? - ` - - _, err := tx.Exec(updateQuery, - apiKey.APIKey, - apiKey.MaskedAPIKey, - apiKey.Operations, - apiKey.Status, - apiKey.CreatedBy, - apiKey.UpdatedAt, - apiKey.ExpiresAt, - apiKey.Unit, - apiKey.Duration, - apiKey.Source, - apiKey.ExternalRefId, - apiKey.APIId, - apiKey.Name, - ) - - if err != nil { - tx.Rollback() - // Check for unique constraint violation on api_key field - if isAPIKeyUniqueConstraintError(err) { - return fmt.Errorf("%w: API key value already exists", ErrConflict) - } - return fmt.Errorf("failed to update API key: %w", err) - } - - s.logger.Info("API key updated successfully", - slog.String("existing_id", existingID), + // Existing record found, return conflict error that API Key name already exists + tx.Rollback() + s.logger.Error("API key name already exists for the API", slog.String("name", apiKey.Name), slog.String("apiId", apiKey.APIId), - slog.String("created_by", apiKey.CreatedBy)) + slog.Any("error", ErrConflict)) + return fmt.Errorf("%w: API key name already exists for the API: %s", ErrConflict, apiKey.Name) } // Commit the transaction @@ -1286,8 +1295,8 @@ func (s *SQLiteStorage) SaveAPIKey(apiKey *models.APIKey) error { // GetAPIKeyByID retrieves an API key by its ID func (s *SQLiteStorage) GetAPIKeyByID(id string) (*models.APIKey, error) { query := ` - SELECT id, name, api_key, masked_api_key, apiId, operations, status, - created_at, created_by, updated_at, expires_at, source, external_ref_id + SELECT id, name, display_name, api_key, masked_api_key, apiId, operations, status, + created_at, created_by, updated_at, expires_at, source, external_ref_id, index_key FROM api_keys WHERE id = ? ` @@ -1295,10 +1304,12 @@ func (s *SQLiteStorage) GetAPIKeyByID(id string) (*models.APIKey, error) { var apiKey models.APIKey var expiresAt sql.NullTime var externalRefId sql.NullString + var indexKey sql.NullString err := s.db.QueryRow(query, id).Scan( &apiKey.ID, &apiKey.Name, + &apiKey.DisplayName, &apiKey.APIKey, &apiKey.MaskedAPIKey, &apiKey.APIId, @@ -1310,6 +1321,7 @@ func (s *SQLiteStorage) GetAPIKeyByID(id string) (*models.APIKey, error) { &expiresAt, &apiKey.Source, &externalRefId, + &indexKey, ) if err != nil { @@ -1326,6 +1338,9 @@ func (s *SQLiteStorage) GetAPIKeyByID(id string) (*models.APIKey, error) { if externalRefId.Valid { apiKey.ExternalRefId = &externalRefId.String } + if indexKey.Valid { + apiKey.IndexKey = &indexKey.String + } return &apiKey, nil } @@ -1333,8 +1348,8 @@ func (s *SQLiteStorage) GetAPIKeyByID(id string) (*models.APIKey, error) { // GetAPIKeyByKey retrieves an API key by its key value func (s *SQLiteStorage) GetAPIKeyByKey(key string) (*models.APIKey, error) { query := ` - SELECT id, name, api_key, masked_api_key, apiId, operations, status, - created_at, created_by, updated_at, expires_at, source, external_ref_id + SELECT id, name, display_name, api_key, masked_api_key, apiId, operations, status, + created_at, created_by, updated_at, expires_at, source, external_ref_id, index_key FROM api_keys WHERE api_key = ? ` @@ -1342,10 +1357,12 @@ func (s *SQLiteStorage) GetAPIKeyByKey(key string) (*models.APIKey, error) { var apiKey models.APIKey var expiresAt sql.NullTime var externalRefId sql.NullString + var indexKey sql.NullString err := s.db.QueryRow(query, key).Scan( &apiKey.ID, &apiKey.Name, + &apiKey.DisplayName, &apiKey.APIKey, &apiKey.MaskedAPIKey, &apiKey.APIId, @@ -1357,6 +1374,7 @@ func (s *SQLiteStorage) GetAPIKeyByKey(key string) (*models.APIKey, error) { &expiresAt, &apiKey.Source, &externalRefId, + &indexKey, ) if err != nil { @@ -1373,6 +1391,9 @@ func (s *SQLiteStorage) GetAPIKeyByKey(key string) (*models.APIKey, error) { if externalRefId.Valid { apiKey.ExternalRefId = &externalRefId.String } + if indexKey.Valid { + apiKey.IndexKey = &indexKey.String + } return &apiKey, nil } @@ -1380,8 +1401,8 @@ func (s *SQLiteStorage) GetAPIKeyByKey(key string) (*models.APIKey, error) { // GetAPIKeysByAPI retrieves all API keys for a specific API func (s *SQLiteStorage) GetAPIKeysByAPI(apiId string) ([]*models.APIKey, error) { query := ` - SELECT id, name, api_key, masked_api_key, apiId, operations, status, - created_at, created_by, updated_at, expires_at, source, external_ref_id + SELECT id, name, display_name, api_key, masked_api_key, apiId, operations, status, + created_at, created_by, updated_at, expires_at, source, external_ref_id, index_key FROM api_keys WHERE apiId = ? ORDER BY created_at DESC @@ -1399,10 +1420,12 @@ func (s *SQLiteStorage) GetAPIKeysByAPI(apiId string) ([]*models.APIKey, error) var apiKey models.APIKey var expiresAt sql.NullTime var externalRefId sql.NullString + var indexKey sql.NullString err := rows.Scan( &apiKey.ID, &apiKey.Name, + &apiKey.DisplayName, &apiKey.APIKey, &apiKey.MaskedAPIKey, &apiKey.APIId, @@ -1414,6 +1437,7 @@ func (s *SQLiteStorage) GetAPIKeysByAPI(apiId string) ([]*models.APIKey, error) &expiresAt, &apiKey.Source, &externalRefId, + &indexKey, ) if err != nil { @@ -1427,6 +1451,9 @@ func (s *SQLiteStorage) GetAPIKeysByAPI(apiId string) ([]*models.APIKey, error) if externalRefId.Valid { apiKey.ExternalRefId = &externalRefId.String } + if indexKey.Valid { + apiKey.IndexKey = &indexKey.String + } apiKeys = append(apiKeys, &apiKey) } @@ -1441,8 +1468,8 @@ func (s *SQLiteStorage) GetAPIKeysByAPI(apiId string) ([]*models.APIKey, error) // GetAPIKeysByAPIAndName retrieves an API key by its apiId and name func (s *SQLiteStorage) GetAPIKeysByAPIAndName(apiId, name string) (*models.APIKey, error) { query := ` - SELECT id, name, api_key, masked_api_key, apiId, operations, status, - created_at, created_by, updated_at, expires_at, source, external_ref_id + SELECT id, name, display_name, api_key, masked_api_key, apiId, operations, status, + created_at, created_by, updated_at, expires_at, source, external_ref_id, index_key FROM api_keys WHERE apiId = ? AND name = ? LIMIT 1 @@ -1451,10 +1478,12 @@ func (s *SQLiteStorage) GetAPIKeysByAPIAndName(apiId, name string) (*models.APIK var apiKey models.APIKey var expiresAt sql.NullTime var externalRefId sql.NullString + var indexKey sql.NullString err := s.db.QueryRow(query, apiId, name).Scan( &apiKey.ID, &apiKey.Name, + &apiKey.DisplayName, &apiKey.APIKey, &apiKey.MaskedAPIKey, &apiKey.APIId, @@ -1466,6 +1495,7 @@ func (s *SQLiteStorage) GetAPIKeysByAPIAndName(apiId, name string) (*models.APIK &expiresAt, &apiKey.Source, &externalRefId, + &indexKey, ) if err != nil { @@ -1482,42 +1512,94 @@ func (s *SQLiteStorage) GetAPIKeysByAPIAndName(apiId, name string) (*models.APIK if externalRefId.Valid { apiKey.ExternalRefId = &externalRefId.String } + if indexKey.Valid { + apiKey.IndexKey = &indexKey.String + } return &apiKey, nil } // UpdateAPIKey updates an existing API key func (s *SQLiteStorage) UpdateAPIKey(apiKey *models.APIKey) error { - query := ` - UPDATE api_keys - SET status = ?, updated_at = ?, expires_at = ? - WHERE api_key = ? - ` - result, err := s.db.Exec(query, + // Begin transaction to ensure atomicity + tx, err := s.db.Begin() + if err != nil { + return fmt.Errorf("failed to begin transaction: %w", err) + } + + // Ensure transaction is properly handled + defer func() { + if p := recover(); p != nil { + tx.Rollback() + panic(p) // Re-throw panic after rollback + } + }() + + if apiKey.Source == "external" && apiKey.IndexKey != nil { + // Check for duplicate API key value within the same API (same value, different name) + duplicateCheckQuery := ` + SELECT id, name FROM api_keys + WHERE apiId = ? AND index_key = ? AND name != ? + LIMIT 1 + ` + var duplicateID, duplicateName string + err := s.db.QueryRow(duplicateCheckQuery, apiKey.APIId, apiKey.IndexKey, apiKey.Name).Scan(&duplicateID, &duplicateName) + if err != nil && !errors.Is(err, sql.ErrNoRows) { + tx.Rollback() + return fmt.Errorf("failed to check for duplicate API key: %w", err) + } + if err == nil { + // Row found: same key value already exists for this API under a different name + tx.Rollback() + return fmt.Errorf("%w: API key value already exists for this API", ErrConflict) + } + } + + updateQuery := ` + UPDATE api_keys + SET api_key = ?, masked_api_key = ?, display_name = ?, operations = ?, status = ?, created_by = ?, updated_at = ?, expires_at = ?, expires_in_unit = ?, expires_in_duration = ?, + source = ?, external_ref_id = ?, index_key = ? + WHERE apiId = ? AND name = ? + ` + + _, err = tx.Exec(updateQuery, + apiKey.APIKey, + apiKey.MaskedAPIKey, + apiKey.DisplayName, + apiKey.Operations, apiKey.Status, + apiKey.CreatedBy, apiKey.UpdatedAt, apiKey.ExpiresAt, - apiKey.APIKey, + apiKey.Unit, + apiKey.Duration, + apiKey.Source, + apiKey.ExternalRefId, + apiKey.IndexKey, + apiKey.APIId, + apiKey.Name, ) if err != nil { + tx.Rollback() + // Check for unique constraint violation on api_key field + if isAPIKeyUniqueConstraintError(err) { + return fmt.Errorf("%w: API key value already exists", ErrConflict) + } return fmt.Errorf("failed to update API key: %w", err) } - rows, err := result.RowsAffected() - if err != nil { - return fmt.Errorf("failed to get rows affected: %w", err) - } + s.logger.Info("API key updated successfully", + slog.String("name", apiKey.Name), + slog.String("apiId", apiKey.APIId), + slog.String("created_by", apiKey.CreatedBy)) - if rows == 0 { - return fmt.Errorf("%w: API key not found", ErrNotFound) + // Commit the transaction + if err := tx.Commit(); err != nil { + return fmt.Errorf("failed to commit transaction: %w", err) } - s.logger.Info("API key updated successfully", - slog.String("id", apiKey.ID), - slog.String("status", string(apiKey.Status))) - return nil } @@ -1703,14 +1785,15 @@ func isCertificateUniqueConstraintError(err error) bool { func isAPIKeyUniqueConstraintError(err error) bool { return err != nil && (err.Error() == "UNIQUE constraint failed: api_keys.api_key" || - err.Error() == "UNIQUE constraint failed: api_keys.id") + err.Error() == "UNIQUE constraint failed: api_keys.id" || + err.Error() == "UNIQUE constraint failed: index 'idx_unique_external_api_key'") } // GetAllAPIKeys retrieves all API keys from the database func (s *SQLiteStorage) GetAllAPIKeys() ([]*models.APIKey, error) { query := ` - SELECT id, name, api_key, masked_api_key, apiId, operations, status, - created_at, created_by, updated_at, expires_at, source, external_ref_id + SELECT id, name, display_name, api_key, masked_api_key, apiId, operations, status, + created_at, created_by, updated_at, expires_at, source, external_ref_id, index_key FROM api_keys WHERE status = 'active' ORDER BY created_at DESC @@ -1728,10 +1811,12 @@ func (s *SQLiteStorage) GetAllAPIKeys() ([]*models.APIKey, error) { var apiKey models.APIKey var expiresAt sql.NullTime var externalRefId sql.NullString + var indexKey sql.NullString err := rows.Scan( &apiKey.ID, &apiKey.Name, + &apiKey.DisplayName, &apiKey.APIKey, &apiKey.MaskedAPIKey, &apiKey.APIId, @@ -1743,6 +1828,7 @@ func (s *SQLiteStorage) GetAllAPIKeys() ([]*models.APIKey, error) { &expiresAt, &apiKey.Source, &externalRefId, + &indexKey, ) if err != nil { @@ -1756,6 +1842,9 @@ func (s *SQLiteStorage) GetAllAPIKeys() ([]*models.APIKey, error) { if externalRefId.Valid { apiKey.ExternalRefId = &externalRefId.String } + if indexKey.Valid { + apiKey.IndexKey = &indexKey.String + } apiKeys = append(apiKeys, &apiKey) } @@ -1795,7 +1884,7 @@ func LoadAPIKeysFromDatabase(storage Storage, configStore *ConfigStore, apiKeySt func (s *SQLiteStorage) CountActiveAPIKeysByUserAndAPI(apiId, userID string) (int, error) { query := ` SELECT COUNT(*) - FROM api_keys + FROM api_keys WHERE apiId = ? AND created_by = ? AND status = ? ` @@ -1807,4 +1896,3 @@ func (s *SQLiteStorage) CountActiveAPIKeysByUserAndAPI(apiId, userID string) (in return count, nil } - diff --git a/gateway/gateway-controller/pkg/utils/api_key.go b/gateway/gateway-controller/pkg/utils/api_key.go index f6ae232da..bca95eac9 100644 --- a/gateway/gateway-controller/pkg/utils/api_key.go +++ b/gateway/gateway-controller/pkg/utils/api_key.go @@ -46,18 +46,18 @@ import ( // APIKeyCreationParams contains parameters for API key creation operations. // Handles both local key generation and external key injection. type APIKeyCreationParams struct { - Handle string // API handle/ID + Handle string // API handle/ID Request api.APIKeyCreationRequest // Request body with API key creation details - User *commonmodels.AuthContext // User who initiated the request - CorrelationID string // Correlation ID for tracking - Logger *slog.Logger // Logger instance + User *commonmodels.AuthContext // User who initiated the request + CorrelationID string // Correlation ID for tracking + Logger *slog.Logger // Logger instance } // APIKeyCreationResult contains the result of API key creation. // Used for both locally generated keys and externally injected keys. type APIKeyCreationResult struct { - Response api.APIKeyGenerationResponse // Response following the generated schema - IsRetry bool // Whether this was a retry due to collision + Response api.APIKeyCreationResponse // Response following the generated schema + IsRetry bool // Whether this was a retry due to collision } // APIKeyRevocationParams contains parameters for API key revocation operations @@ -86,20 +86,25 @@ type APIKeyRegenerationParams struct { // APIKeyRegenerationResult contains the result of API key regeneration type APIKeyRegenerationResult struct { - Response api.APIKeyGenerationResponse // Response following the generated schema - IsRetry bool // Whether this was a retry due to collision + Response api.APIKeyCreationResponse // Response following the generated schema + IsRetry bool // Whether this was a retry due to collision } // APIKeyUpdateParams contains parameters for API key update operations type APIKeyUpdateParams struct { Handle string // API handle/ID APIKeyName string // Name of the API key to update - Request api.APIKeyRegenerationRequest // Request body with update details + Request api.APIKeyCreationRequest // Request body with update details User *commonmodels.AuthContext // User who initiated the request CorrelationID string // Correlation ID for tracking Logger *slog.Logger // Logger instance } +// APIKeyUpdateResult contains the result of API key update +type APIKeyUpdateResult struct { + Response api.APIKeyCreationResponse // Response following the generated schema +} + // ListAPIKeyParams contains parameters for listing API keys type ListAPIKeyParams struct { Handle string // API handle/ID @@ -164,9 +169,18 @@ const ( // Supports both local key generation by generating a new random key and external key injection // (accepts key from external systems like Cloud APIM). func (s *APIKeyService) CreateAPIKey(params APIKeyCreationParams) (*APIKeyCreationResult, error) { - logger := params.Logger + baseLogger := params.Logger + if baseLogger == nil { + baseLogger = slog.Default() + } user := params.User + logger := baseLogger.With( + slog.String("handle", params.Handle), + slog.String("correlation_id", params.CorrelationID), + slog.String("user_id", user.UserID), + ) + // Determine operation type for context-aware messaging isExternalKeyInjection := params.Request.ApiKey != nil && strings.TrimSpace(*params.Request.ApiKey) != "" operationType := "generate" @@ -177,38 +191,34 @@ func (s *APIKeyService) CreateAPIKey(params APIKeyCreationParams) (*APIKeyCreati // Validate that API exists config, err := s.store.GetByHandle(params.Handle) if err != nil { - logger.Warn("API configuration not found for API Key generation", - slog.String("handle", params.Handle), - slog.String("operation", operationType), - slog.String("correlation_id", params.CorrelationID)) + logger.Error("API configuration not found for API Key generation", + slog.String("operation", operationType+"_key"), + slog.Any("error", err)) return nil, fmt.Errorf("API configuration handle '%s' not found", params.Handle) } // Check API key limit enforcement if err := s.enforceAPIKeyLimit(config.ID, user.UserID, logger); err != nil { logger.Warn("API key generation limit exceeded", - slog.String("user_id", user.UserID), slog.String("api_id", config.ID), - slog.String("handle", params.Handle), - slog.String("operation", operationType + "_key"), - slog.Any("error", err), - slog.String("correlation_id", params.CorrelationID)) + slog.String("operation", operationType+"_key"), + slog.Any("error", err)) return nil, err } + result := &APIKeyCreationResult{ + IsRetry: false, + } + // Create the API key from request (generate new or register external) + // For local keys, retry once if duplicate is detected during generation apiKey, err := s.createAPIKeyFromRequest(params.Handle, ¶ms.Request, user.UserID, config) if err != nil { - logger.Error(fmt.Sprintf("Failed to %s API key", operationType), + logger.Error("Failed to generate API key", slog.Any("error", err), slog.String("handle", params.Handle), - slog.String("operation", operationType + "_key"), slog.String("correlation_id", params.CorrelationID)) - return nil, fmt.Errorf("failed to %s API key: %w", operationType, err) - } - - result := &APIKeyCreationResult{ - IsRetry: false, + return nil, fmt.Errorf("failed to generate API key: %w", err) } // Save API key to database (only if persistent mode) @@ -219,42 +229,36 @@ func (s *APIKeyService) CreateAPIKey(params APIKeyCreationParams) (*APIKeyCreati if isExternalKeyInjection { // For external keys, collision means the key already exists logger.Error("External API key already exists in the system", - slog.String("handle", params.Handle), - slog.String("operation", operationType + "_key"), - slog.String("correlation_id", params.CorrelationID)) + slog.String("operation", operationType+"_key")) return nil, fmt.Errorf("%w: provided API key already exists", storage.ErrConflict) } // For local keys, retry with a new generated key logger.Warn("API key collision detected, generating new key", - slog.String("handle", params.Handle), - slog.String("operation", operationType + "_key"), - slog.String("correlation_id", params.CorrelationID)) + slog.String("operation", operationType+"_key")) // Generate a new key apiKey, err = s.createAPIKeyFromRequest(params.Handle, ¶ms.Request, user.UserID, config) if err != nil { logger.Error("Failed to generate API key after collision", - slog.Any("error", err), - slog.String("correlation_id", params.CorrelationID)) + slog.String("operation", operationType+"_key"), + slog.Any("error", err)) return nil, fmt.Errorf("failed to generate API key after collision: %w", err) } // Try saving again if err := s.db.SaveAPIKey(apiKey); err != nil { logger.Error("Failed to save API key after retry", - slog.Any("error", err), - slog.String("correlation_id", params.CorrelationID)) + slog.String("operation", operationType+"_key"), + slog.Any("error", err)) return nil, fmt.Errorf("failed to save API key after retry: %w", err) } result.IsRetry = true } else { logger.Error("Failed to save API key to database", - slog.Any("error", err), - slog.String("handle", params.Handle), - slog.String("operation", operationType + "_key"), - slog.String("correlation_id", params.CorrelationID)) + slog.String("operation", operationType+"_key"), + slog.Any("error", err)) return nil, fmt.Errorf("failed to save API key to database: %w", err) } } @@ -267,9 +271,7 @@ func (s *APIKeyService) CreateAPIKey(params APIKeyCreationParams) (*APIKeyCreati if err := s.store.StoreAPIKey(apiKey); err != nil { logger.Error("Failed to store API key in ConfigStore", slog.Any("error", err), - slog.String("handle", params.Handle), - slog.String("operation", operationType + "_key"), - slog.String("correlation_id", params.CorrelationID)) + slog.String("operation", operationType+"_key")) // Rollback database save to maintain consistency if s.db != nil { @@ -285,9 +287,7 @@ func (s *APIKeyService) CreateAPIKey(params APIKeyCreationParams) (*APIKeyCreati apiConfig, err := config.Configuration.Spec.AsAPIConfigData() if err != nil { logger.Error("Failed to parse API configuration data", - slog.Any("error", err), - slog.String("handle", params.Handle), - slog.String("correlation_id", params.CorrelationID)) + slog.Any("error", err)) return nil, fmt.Errorf("failed to parse API configuration data: %w", err) } @@ -295,21 +295,17 @@ func (s *APIKeyService) CreateAPIKey(params APIKeyCreationParams) (*APIKeyCreati apiName := apiConfig.DisplayName apiVersion := apiConfig.Version logger.Info("Storing API key in policy engine", - slog.String("handle", params.Handle), slog.String("name", apiKey.Name), slog.String("api_name", apiName), slog.String("api_version", apiVersion), - slog.String("operation", operationType + "_key"), - slog.String("user", user.UserID), - slog.String("correlation_id", params.CorrelationID)) + slog.String("operation", operationType+"_key")) // Send the API key to the policy engine via xDS if s.xdsManager != nil { if err := s.xdsManager.StoreAPIKey(apiId, apiName, apiVersion, apiKey, params.CorrelationID); err != nil { logger.Error("Failed to send API key to policy engine", - slog.Any("error", err), - slog.String("operation", operationType + "_key"), - slog.String("correlation_id", params.CorrelationID)) + slog.String("operation", operationType+"_key"), + slog.Any("error", err)) return nil, fmt.Errorf("failed to send API key to policy engine: %w", err) } } @@ -318,21 +314,28 @@ func (s *APIKeyService) CreateAPIKey(params APIKeyCreationParams) (*APIKeyCreati result.Response = s.buildAPIKeyResponse(apiKey, params.Handle, plainAPIKey, isExternalKeyInjection) logger.Info("API key successfully created", - slog.String("handle", params.Handle), slog.String("name", apiKey.Name), - slog.String("operation", operationType + "_key"), - slog.String("user", user.UserID), - slog.Bool("is_retry", result.IsRetry), - slog.String("correlation_id", params.CorrelationID)) + slog.String("operation", operationType+"_key"), + slog.Bool("is_retry", result.IsRetry)) return result, nil } // RevokeAPIKey handles the API key revocation process +// TODO: checks if the index created in policy engine is removed func (s *APIKeyService) RevokeAPIKey(params APIKeyRevocationParams) (*APIKeyRevocationResult, error) { - logger := params.Logger + baseLogger := params.Logger + if baseLogger == nil { + baseLogger = slog.Default() + } user := params.User apiKeyName := params.APIKeyName + + logger := baseLogger.With( + slog.String("correlation_id", params.CorrelationID), + slog.String("handle", params.Handle), + slog.String("user_id", user.UserID), + ) result := &APIKeyRevocationResult{ Response: api.APIKeyRevocationResponse{ Status: "success", @@ -344,8 +347,7 @@ func (s *APIKeyService) RevokeAPIKey(params APIKeyRevocationParams) (*APIKeyRevo config, err := s.store.GetByHandle(params.Handle) if err != nil { logger.Warn("API configuration not found for API key revocation", - slog.String("handle", params.Handle), - slog.String("correlation_id", params.CorrelationID)) + slog.Any("error", err)) return nil, fmt.Errorf("API configuration handle '%s' not found", params.Handle) } @@ -359,9 +361,7 @@ func (s *APIKeyService) RevokeAPIKey(params APIKeyRevocationParams) (*APIKeyRevo existingAPIKey, err = s.db.GetAPIKeysByAPIAndName(config.ID, apiKeyName) if err != nil { logger.Debug("Failed to get API keys for revocation", - slog.Any("error", err), - slog.String("handle", params.Handle), - slog.String("correlation_id", params.CorrelationID)) + slog.Any("error", err)) // Continue with revocation for security reasons (don't leak info) } } @@ -370,9 +370,7 @@ func (s *APIKeyService) RevokeAPIKey(params APIKeyRevocationParams) (*APIKeyRevo // If API key not found, log and continue for security reasons if existingAPIKey == nil { logger.Debug("API key not found for revocation", - slog.String("handle", params.Handle), - slog.String("api_key_name", apiKeyName), - slog.String("correlation_id", params.CorrelationID)) + slog.String("api_key_name", apiKeyName)) } apiKey = existingAPIKey @@ -391,18 +389,14 @@ func (s *APIKeyService) RevokeAPIKey(params APIKeyRevocationParams) (*APIKeyRevo err := s.canRevokeAPIKey(user, apiKey, logger) if err != nil { logger.Debug("User not authorized to revoke API key", - slog.String("handle", params.Handle), slog.String("creator", apiKey.CreatedBy), - slog.String("requesting_user", user.UserID), - slog.String("correlation_id", params.CorrelationID)) + slog.String("requesting_user", user.UserID)) return nil, fmt.Errorf("API key revocation failed for API: '%s'", params.Handle) } // Check if the API key is already revoked if apiKey.Status == models.APIKeyStatusRevoked { - logger.Debug("API key is already revoked", - slog.String("handle", params.Handle), - slog.String("correlation_id", params.CorrelationID)) + logger.Debug("API key is already revoked") return result, nil } @@ -415,9 +409,7 @@ func (s *APIKeyService) RevokeAPIKey(params APIKeyRevocationParams) (*APIKeyRevo if s.db != nil { if err := s.db.UpdateAPIKey(apiKey); err != nil { logger.Error("Failed to update API key status in database", - slog.Any("error", err), - slog.String("handle", params.Handle), - slog.String("correlation_id", params.CorrelationID)) + slog.Any("error", err)) return nil, fmt.Errorf("failed to revoke API key: %w", err) } } @@ -425,17 +417,14 @@ func (s *APIKeyService) RevokeAPIKey(params APIKeyRevocationParams) (*APIKeyRevo // Remove the API key from memory store by name (since we have the matched key) if err := s.store.RemoveAPIKeyByID(config.ID, apiKey.ID); err != nil { logger.Error("Failed to remove API key from memory store", - slog.Any("error", err), - slog.String("handle", params.Handle), - slog.String("correlation_id", params.CorrelationID)) + slog.Any("error", err)) // Try to rollback database update if memory removal fails if s.db != nil { apiKey.Status = models.APIKeyStatusActive // Rollback status if rollbackErr := s.db.UpdateAPIKey(apiKey); rollbackErr != nil { logger.Error("Failed to rollback API key status in database", - slog.Any("error", rollbackErr), - slog.String("correlation_id", params.CorrelationID)) + slog.Any("error", rollbackErr)) } } return nil, fmt.Errorf("failed to revoke API key: %w", err) @@ -447,9 +436,7 @@ func (s *APIKeyService) RevokeAPIKey(params APIKeyRevocationParams) (*APIKeyRevo if s.db != nil && matchedKey != nil { if err := s.db.RemoveAPIKeyAPIAndName(config.ID, matchedKey.Name); err != nil { logger.Warn("Failed to remove API key from database, but revocation was successful", - slog.Any("error", err), - slog.String("handle", params.Handle), - slog.String("correlation_id", params.CorrelationID)) + slog.Any("error", err)) // Don't return error - revocation was already successful // The key is marked as revoked in DB and removed from memory } @@ -459,9 +446,7 @@ func (s *APIKeyService) RevokeAPIKey(params APIKeyRevocationParams) (*APIKeyRevo apiConfig, err := config.Configuration.Spec.AsAPIConfigData() if err != nil { logger.Error("Failed to parse API configuration data", - slog.Any("error", err), - slog.String("handle", params.Handle), - slog.String("correlation_id", params.CorrelationID)) + slog.Any("error", err)) return nil, fmt.Errorf("failed to revoke API key: %w", err) } @@ -469,168 +454,156 @@ func (s *APIKeyService) RevokeAPIKey(params APIKeyRevocationParams) (*APIKeyRevo apiName := apiConfig.DisplayName apiVersion := apiConfig.Version logger.Info("Removing API key from policy engine", - slog.String("handle", params.Handle), slog.String("api key", apiKeyName), slog.String("api_name", apiName), - slog.String("api_version", apiVersion), - slog.String("user", user.UserID), - slog.String("correlation_id", params.CorrelationID)) + slog.String("api_version", apiVersion)) // Send the plain API key revocation to the policy engine via xDS // The policy engine will find and revoke the matching hashed key if s.xdsManager != nil { if err := s.xdsManager.RevokeAPIKey(apiId, apiName, apiVersion, apiKeyName, params.CorrelationID); err != nil { logger.Error("Failed to remove API key from policy engine", - slog.Any("error", err), - slog.String("correlation_id", params.CorrelationID)) + slog.Any("error", err)) return nil, fmt.Errorf("failed to revoke API key: %w", err) } } logger.Info("API key revoked successfully", - slog.String("handle", params.Handle), - slog.String("api key", apiKeyName), - slog.String("user", user.UserID), - slog.String("correlation_id", params.CorrelationID)) + slog.String("api key", apiKeyName)) return result, nil } // UpdateAPIKey updates an existing API key with a specific provided value -func (s *APIKeyService) UpdateAPIKey(params APIKeyUpdateParams) (*APIKeyRegenerationResult, error) { - logger := params.Logger - if logger == nil { - logger = slog.Default() +func (s *APIKeyService) UpdateAPIKey(params APIKeyUpdateParams) (*APIKeyUpdateResult, error) { + baseLogger := params.Logger + if baseLogger == nil { + baseLogger = slog.Default() } - user := params.User - logger.Info("Starting API key update", + // Create logger with pre-attached correlation ID and common fields + logger := baseLogger.With( + slog.String("correlation_id", params.CorrelationID), slog.String("handle", params.Handle), slog.String("api_key_name", params.APIKeyName), - slog.String("user", user.UserID), - slog.String("correlation_id", params.CorrelationID)) + ) + + user := params.User + + logger.Info("Starting API key update", + slog.String("user", user.UserID)) // Get the API configuration config, err := s.store.GetByHandle(params.Handle) if err != nil { - logger.Warn("API configuration not found for API Key update", - slog.String("handle", params.Handle), - slog.String("correlation_id", params.CorrelationID)) + logger.Warn("API configuration not found for API key update") return nil, fmt.Errorf("API configuration handle '%s' not found", params.Handle) } // Get the existing API key by name existingKey, err := s.store.GetAPIKeyByName(config.ID, params.APIKeyName) if err != nil { - logger.Warn("API key not found for update", - slog.String("handle", params.Handle), - slog.String("api_key_name", params.APIKeyName), - slog.String("correlation_id", params.CorrelationID)) + logger.Warn("API key not found for update") return nil, fmt.Errorf("API key '%s' not found for API '%s'", params.APIKeyName, params.Handle) } + // Check authorization - only creator can update their own key (unless admin) err = s.canRegenerateAPIKey(user, existingKey, logger) if err != nil { - logger.Warn("User attempting to update API key is not the creator", - slog.String("handle", params.Handle), - slog.String("api_key_name", params.APIKeyName), + logger.Warn("User not authorized to update API key", slog.String("creator", existingKey.CreatedBy), - slog.String("requesting_user", user.UserID), - slog.String("correlation_id", params.CorrelationID)) - return nil, fmt.Errorf("API key regeneration failed for API: '%s'", params.Handle) + slog.String("requesting_user", user.UserID)) + return nil, fmt.Errorf("not authorized to update API key '%s'", params.APIKeyName) } - // Update API key using the extracted helper method - updatedKey, err := s.updateAPIKey(existingKey, params.Request, user.UserID, logger) + updatedKey, err := s.updateAPIKeyFromRequest(existingKey, params.Request, user.UserID, logger) if err != nil { - logger.Error("Failed to update API key", - slog.Any("error", err), - slog.String("handle", params.Handle), - slog.String("correlation_id", params.CorrelationID)) - return nil, fmt.Errorf("failed to update API key: %w", err) + logger.Error("Failed to update API key from request", + slog.Any("error", err)) + return nil, fmt.Errorf("failed to update API key from request: %w", err) } + // Clear plaintext secret before persisting or storing + updatedKey.PlainAPIKey = "" - result := &APIKeyRegenerationResult{ - IsRetry: false, - } - - // Save updated API key to database (only if persistent mode) + // Save to database (if persistent mode) if s.db != nil { - if err := s.db.SaveAPIKey(updatedKey); err != nil { - if errors.Is(err, storage.ErrConflict) { - logger.Error("API key already exists in the system", - slog.Any("error", err), - slog.String("correlation_id", params.CorrelationID)) - return nil, fmt.Errorf("API key already exists in the system: %w", err) - } - - logger.Error("Failed to save updated API key to database", - slog.Any("error", err), - slog.String("handle", params.Handle), - slog.String("correlation_id", params.CorrelationID)) - return nil, fmt.Errorf("failed to save updated API key to database: %w", err) + if err := s.db.UpdateAPIKey(updatedKey); err != nil { + logger.Error("Failed to update API key in database", + slog.Any("error", err)) + return nil, fmt.Errorf("failed to update API key in database: %w", err) } } - plainAPIKey := updatedKey.PlainAPIKey // Store plain API key for response - updatedKey.PlainAPIKey = "" // Clear plain API key from the struct for security - - // Store the generated API key in the ConfigStore + // Update in ConfigStore if err := s.store.StoreAPIKey(updatedKey); err != nil { - logger.Error("Failed to store the updated API key in ConfigStore", - slog.Any("error", err), - slog.String("handle", params.Handle), - slog.String("correlation_id", params.CorrelationID)) + logger.Error("Failed to update API key in ConfigStore", + slog.Any("error", err)) - // Rollback database save to maintain consistency + // Rollback database update if we have a persistent DB if s.db != nil { - if delErr := s.db.RemoveAPIKeyAPIAndName(updatedKey.APIId, updatedKey.Name); delErr != nil { - logger.Error("Failed to rollback API key from database", - slog.Any("error", delErr), - slog.String("correlation_id", params.CorrelationID)) + if rollbackErr := s.db.UpdateAPIKey(existingKey); rollbackErr != nil { + logger.Error("Failed to rollback API key in database after ConfigStore failure", + slog.Any("error", rollbackErr), + slog.Any("original_error", err)) + } else { + logger.Info("Successfully rolled back API key in database after ConfigStore failure") } } - return nil, fmt.Errorf("failed to store updated API key in ConfigStore: %w", err) + + return nil, fmt.Errorf("failed to update API key in ConfigStore: %w", err) } apiConfig, err := config.Configuration.Spec.AsAPIConfigData() if err != nil { logger.Error("Failed to parse API configuration data", - slog.Any("error", err), - slog.String("handle", params.Handle), - slog.String("correlation_id", params.CorrelationID)) + slog.Any("error", err)) return nil, fmt.Errorf("failed to parse API configuration data: %w", err) } apiId := config.ID apiName := apiConfig.DisplayName apiVersion := apiConfig.Version - logger.Info("Storing API key in policy engine", - slog.String("handle", params.Handle), - slog.String("name", updatedKey.Name), + logger.Info("Updating API key in policy engine", slog.String("api_name", apiName), slog.String("api_version", apiVersion), - slog.String("user", user.UserID), - slog.String("correlation_id", params.CorrelationID)) + slog.String("user", user.UserID)) - // Update xDS snapshot if needed + // Update xDS snapshot to propagate to policy engine if s.xdsManager != nil { if err := s.xdsManager.StoreAPIKey(apiId, apiName, apiVersion, updatedKey, params.CorrelationID); err != nil { logger.Error("Failed to send updated API key to policy engine", - slog.Any("error", err), - slog.String("correlation_id", params.CorrelationID)) + slog.Any("error", err)) return nil, fmt.Errorf("failed to send updated API key to policy engine: %w", err) } } - // Build and return the response - result.Response = s.buildAPIKeyResponse(updatedKey, params.Handle, plainAPIKey, false) + // Build response + // If API key was updated, use the new masked value; otherwise use existing + responseMessage := "API key updated successfully" + var responseAPIKey *string + responseAPIKey = &updatedKey.MaskedAPIKey + + result := &APIKeyUpdateResult{ + Response: api.APIKeyCreationResponse{ + Status: "success", + Message: responseMessage, + ApiKey: &api.APIKey{ + Name: updatedKey.Name, + DisplayName: &updatedKey.DisplayName, + ApiKey: responseAPIKey, + ApiId: params.Handle, + Operations: updatedKey.Operations, + Status: api.APIKeyStatus(updatedKey.Status), + CreatedAt: updatedKey.CreatedAt, + CreatedBy: updatedKey.CreatedBy, + ExpiresAt: updatedKey.ExpiresAt, + Source: api.APIKeySource(updatedKey.Source), + }, + }, + } logger.Info("API key update completed successfully", - slog.String("handle", params.Handle), - slog.String("api_key_name", params.APIKeyName), - slog.String("new_key_id", updatedKey.ID), - slog.String("correlation_id", params.CorrelationID)) + slog.String("key_id", updatedKey.ID)) return result, nil } @@ -679,23 +652,43 @@ func (s *APIKeyService) RegenerateAPIKey(params APIKeyRegenerationParams) (*APIK return nil, fmt.Errorf("API key regeneration failed for API: '%s'", params.Handle) } + result := &APIKeyRegenerationResult{ + IsRetry: false, + } + // Regenerate API key using the extracted helper method + // Retry once if duplicate is detected during generation regeneratedKey, err := s.regenerateAPIKey(existingKey, params.Request, user.UserID, logger) if err != nil { - logger.Error("Failed to regenerate API key", - slog.Any("error", err), - slog.String("handle", params.Handle), - slog.String("correlation_id", params.CorrelationID)) - return nil, fmt.Errorf("failed to regenerate API key: %w", err) - } + // Check if this is a duplicate key error + if strings.Contains(err.Error(), "API key value already exists") { + // For local key regeneration, retry with a new generated key + logger.Warn("API key collision detected during regeneration, retrying", + slog.String("handle", params.Handle), + slog.String("correlation_id", params.CorrelationID)) - result := &APIKeyRegenerationResult{ - IsRetry: false, + regeneratedKey, err = s.regenerateAPIKey(existingKey, params.Request, user.UserID, logger) + if err != nil { + logger.Error("Failed to regenerate API key after retry", + slog.Any("error", err), + slog.String("handle", params.Handle), + slog.String("correlation_id", params.CorrelationID)) + return nil, fmt.Errorf("failed to regenerate API key after retry: %w", err) + } + result.IsRetry = true + } else { + // Other error, return immediately + logger.Error("Failed to regenerate API key", + slog.Any("error", err), + slog.String("handle", params.Handle), + slog.String("correlation_id", params.CorrelationID)) + return nil, fmt.Errorf("failed to regenerate API key: %w", err) + } } // Save regenerated API key to database (only if persistent mode) if s.db != nil { - if err := s.db.SaveAPIKey(regeneratedKey); err != nil { + if err := s.db.UpdateAPIKey(regeneratedKey); err != nil { if errors.Is(err, storage.ErrConflict) { // Handle collision by retrying once with a new key logger.Warn("API key collision detected during regeneration, retrying", @@ -712,7 +705,7 @@ func (s *APIKeyService) RegenerateAPIKey(params APIKeyRegenerationParams) (*APIK } // Try saving again - if err := s.db.SaveAPIKey(regeneratedKey); err != nil { + if err := s.db.UpdateAPIKey(regeneratedKey); err != nil { logger.Error("Failed to save regenerated API key after retry", slog.Any("error", err), slog.String("correlation_id", params.CorrelationID)) @@ -862,15 +855,17 @@ func (s *APIKeyService) ListAPIKeys(params ListAPIKeyParams) (*ListAPIKeyResult, for _, key := range activeUserAPIKeys { // Return masked API key for display purposes responseAPIKey := api.APIKey{ - Name: key.Name, - ApiKey: &key.MaskedAPIKey, // Return masked API key for security - ApiId: params.Handle, // Use handle instead of internal API ID - Operations: key.Operations, - Status: api.APIKeyStatus(key.Status), - CreatedAt: key.CreatedAt, - CreatedBy: key.CreatedBy, - ExpiresAt: key.ExpiresAt, - Source: api.APIKeySource(key.Source), + Name: key.Name, + DisplayName: &key.DisplayName, + ApiKey: &key.MaskedAPIKey, // Return masked API key for security + ApiId: params.Handle, // Use handle instead of internal API ID + Operations: key.Operations, + Status: api.APIKeyStatus(key.Status), + CreatedAt: key.CreatedAt, + CreatedBy: key.CreatedBy, + ExpiresAt: key.ExpiresAt, + Source: api.APIKeySource(key.Source), + ExternalRefId: key.ExternalRefId, } responseAPIKeys = append(responseAPIKeys, responseAPIKey) } @@ -917,16 +912,9 @@ func (s *APIKeyService) createAPIKeyFromRequest(handle string, request *api.APIK if request.ApiKey != nil { // External key injection: use provided key AS-IS providedKey := strings.TrimSpace(*request.ApiKey) - - if providedKey == "" { - return nil, fmt.Errorf("provided API key is empty") - } - - // Basic validation: ensure reasonable length (not too short) - if len(providedKey) < 16 { - return nil, fmt.Errorf("provided API key is too short (minimum 16 characters required)") + if err := ValidateAPIKeyValue(providedKey); err != nil { + return nil, err } - // Use the key as-is - we don't dictate format for external keys plainAPIKeyValue = providedKey source = "external" @@ -952,10 +940,28 @@ func (s *APIKeyService) createAPIKeyFromRequest(handle string, request *api.APIK // Generate masked API key for display purposes maskedAPIKeyValue := s.MaskAPIKey(plainAPIKeyValue) - // Set name - use provided name or generate a default one - name := fmt.Sprintf("%s-key-%s", handle, id[:8]) // Default name - if request.Name != nil && strings.TrimSpace(*request.Name) != "" { - name = strings.TrimSpace(*request.Name) + // Handle displayName - optional during creation + var displayName string + if request.DisplayName != nil && strings.TrimSpace(*request.DisplayName) != "" { + // User provided a display name + displayName = strings.TrimSpace(*request.DisplayName) + + // Validate user-provided displayName + if err := ValidateDisplayName(displayName); err != nil { + return nil, fmt.Errorf("invalid display name: %w", err) + } + } else { + // Auto-generate display name: use handle + short ID portion + // Example: "weather-api-jh~cPInv" + displayName = fmt.Sprintf("%s-key-%s", handle, id[:8]) + } + + // Generate unique URL-safe name from displayName with collision handling + // name is immutable after creation and used in path parameters + // Use config.ID (API internal ID) not handle so uniqueness is checked per API + name, err := s.generateUniqueAPIKeyName(config.ID, displayName, 5) + if err != nil { + return nil, fmt.Errorf("failed to generate unique API key name: %w", err) } // Process operations @@ -1005,9 +1011,19 @@ func (s *APIKeyService) createAPIKeyFromRequest(handle string, request *api.APIK expiresAt.Format(time.RFC3339), now.Format(time.RFC3339)) } + var indexKey *string + if source == "external" { + computedIndexKey := computeExternalKeyIndexKey(plainAPIKeyValue) + if computedIndexKey == "" { + return nil, fmt.Errorf("failed to compute index key") + } + indexKey = &computedIndexKey + } + apiKey := &models.APIKey{ ID: id, Name: name, + DisplayName: displayName, APIKey: hashedAPIKeyValue, // Store hashed key in database and policy engine MaskedAPIKey: maskedAPIKeyValue, // Store masked key for display APIId: config.ID, @@ -1019,7 +1035,8 @@ func (s *APIKeyService) createAPIKeyFromRequest(handle string, request *api.APIK ExpiresAt: expiresAt, Unit: unit, Duration: duration, - Source: source, // "local" or "external" + Source: source, // "local" or "external" + IndexKey: indexKey, } // Set external reference fields if provided @@ -1065,9 +1082,9 @@ func (s *APIKeyService) generateOperationsString(operations []api.Operation) str } // buildAPIKeyResponse builds the response following the generated schema -func (s *APIKeyService) buildAPIKeyResponse(key *models.APIKey, handle string, plainAPIKey string, isExternalKeyInjection bool) api.APIKeyGenerationResponse { +func (s *APIKeyService) buildAPIKeyResponse(key *models.APIKey, handle string, plainAPIKey string, isExternalKeyInjection bool) api.APIKeyCreationResponse { if key == nil { - return api.APIKeyGenerationResponse{ + return api.APIKeyCreationResponse{ Status: "error", Message: "API key is nil", } @@ -1105,34 +1122,61 @@ func (s *APIKeyService) buildAPIKeyResponse(key *models.APIKey, handle string, p responseAPIKey = nil } - return api.APIKeyGenerationResponse{ + return api.APIKeyCreationResponse{ Status: "success", Message: message, RemainingApiKeyQuota: remainingQuota, ApiKey: &api.APIKey{ - Name: key.Name, - ApiKey: responseAPIKey, // Return plain key only for locally generated keys - ApiId: handle, - Operations: key.Operations, - Status: api.APIKeyStatus(key.Status), - CreatedAt: key.CreatedAt, - CreatedBy: key.CreatedBy, - ExpiresAt: key.ExpiresAt, - Source: api.APIKeySource(key.Source), + Name: key.Name, + DisplayName: &key.DisplayName, + ApiKey: responseAPIKey, // Return plain key only for locally generated keys + ApiId: handle, + Operations: key.Operations, + Status: api.APIKeyStatus(key.Status), + CreatedAt: key.CreatedAt, + CreatedBy: key.CreatedBy, + ExpiresAt: key.ExpiresAt, + Source: api.APIKeySource(key.Source), }, } } -// UpdateAPIKey updates an existing API key with a specific provided value -func (s *APIKeyService) updateAPIKey(existingKey *models.APIKey, request api.APIKeyRegenerationRequest, +// updateAPIKeyFromRequest updates an existing API key with a specific provided value +// Only mutable fields (displayName, api_key value, expiration) can be updated +// Immutable fields (name, source, createdAt, createdBy) are preserved from existing key +func (s *APIKeyService) updateAPIKeyFromRequest(existingKey *models.APIKey, request api.APIKeyCreationRequest, user string, logger *slog.Logger) (*models.APIKey, error) { - // Generate new API key value + + // Validate required field: api_key value + if request.ApiKey == nil || strings.TrimSpace(*request.ApiKey) == "" { + return nil, fmt.Errorf("api_key is required for update") + } + plainAPIKeyValue := strings.TrimSpace(*request.ApiKey) + if err := ValidateAPIKeyValue(plainAPIKeyValue); err != nil { + return nil, fmt.Errorf("invalid API key value: %w", err) + } + + // Handle displayName - optional during update + // If not provided or empty, keep the existing displayName + var displayName string + if request.DisplayName != nil && strings.TrimSpace(*request.DisplayName) != "" { + displayName = strings.TrimSpace(*request.DisplayName) + + // Validate user-provided displayName + if err := ValidateDisplayName(displayName); err != nil { + return nil, fmt.Errorf("invalid display name: %w", err) + } + } else { + return nil, fmt.Errorf("display name is required for update") + } + + operations := "[\"*\"]" // Default to all operations // Hash the new API key for storage hashedAPIKeyValue, err := s.hashAPIKey(plainAPIKeyValue) if err != nil { - return nil, fmt.Errorf("failed to hash regenerated API key: %w", err) + return nil, fmt.Errorf("failed to hash API key: %w", err) } // Generate masked API key for display purposes @@ -1146,6 +1190,10 @@ func (s *APIKeyService) updateAPIKey(existingKey *models.APIKey, request api.API var duration *int if request.ExpiresAt != nil { + if request.ExpiresAt.Before(now) { + return nil, fmt.Errorf("API key expiration time must be in the future, got: %s (current time: %s)", + request.ExpiresAt.Format(time.RFC3339), now.Format(time.RFC3339)) + } // If expires_at is explicitly provided, use it expiresAt = request.ExpiresAt logger.Info("Using provided expires_at for update", slog.Time("expires_at", *expiresAt)) @@ -1157,17 +1205,17 @@ func (s *APIKeyService) updateAPIKey(existingKey *models.APIKey, request api.API timeDuration := time.Duration(request.ExpiresIn.Duration) switch request.ExpiresIn.Unit { - case api.APIKeyRegenerationRequestExpiresInUnitSeconds: + case api.APIKeyCreationRequestExpiresInUnitSeconds: timeDuration *= time.Second - case api.APIKeyRegenerationRequestExpiresInUnitMinutes: + case api.APIKeyCreationRequestExpiresInUnitMinutes: timeDuration *= time.Minute - case api.APIKeyRegenerationRequestExpiresInUnitHours: + case api.APIKeyCreationRequestExpiresInUnitHours: timeDuration *= time.Hour - case api.APIKeyRegenerationRequestExpiresInUnitDays: + case api.APIKeyCreationRequestExpiresInUnitDays: timeDuration *= 24 * time.Hour - case api.APIKeyRegenerationRequestExpiresInUnitWeeks: + case api.APIKeyCreationRequestExpiresInUnitWeeks: timeDuration *= 7 * 24 * time.Hour - case api.APIKeyRegenerationRequestExpiresInUnitMonths: + case api.APIKeyCreationRequestExpiresInUnitMonths: timeDuration *= 30 * 24 * time.Hour default: return nil, fmt.Errorf("unsupported expiration unit: %s", request.ExpiresIn.Unit) @@ -1178,45 +1226,10 @@ func (s *APIKeyService) updateAPIKey(existingKey *models.APIKey, request api.API slog.String("unit", unitStr), slog.Int("duration", *duration), slog.Time("calculated_expires_at", *expiresAt)) - } else { - // No expiration provided in request, use existing key's logic - if existingKey.Unit != nil && existingKey.Duration != nil { - // Existing key has duration/unit, apply same duration from now - unit = existingKey.Unit - duration = existingKey.Duration - - timeDuration := time.Duration(*existingKey.Duration) - switch *existingKey.Unit { - case string(api.APIKeyRegenerationRequestExpiresInUnitSeconds): - timeDuration *= time.Second - case string(api.APIKeyRegenerationRequestExpiresInUnitMinutes): - timeDuration *= time.Minute - case string(api.APIKeyRegenerationRequestExpiresInUnitHours): - timeDuration *= time.Hour - case string(api.APIKeyRegenerationRequestExpiresInUnitDays): - timeDuration *= 24 * time.Hour - case string(api.APIKeyRegenerationRequestExpiresInUnitWeeks): - timeDuration *= 7 * 24 * time.Hour - case string(api.APIKeyRegenerationRequestExpiresInUnitMonths): - timeDuration *= 30 * 24 * time.Hour - default: - return nil, fmt.Errorf("unsupported existing expiration unit: %s", *existingKey.Unit) - } - expiry := now.Add(timeDuration) - expiresAt = &expiry - logger.Info("Using existing key's duration settings for update", - slog.String("unit", *unit), - slog.Int("duration", *duration), - slog.Time("calculated_expires_at", *expiresAt)) - } else if existingKey.ExpiresAt != nil { - // Existing key has absolute expiry, use same expiry - expiresAt = existingKey.ExpiresAt - logger.Info("Using existing key's expires_at for update", slog.Time("expires_at", *expiresAt)) - } else { - // Existing key has no expiry, new key also has no expiry - expiresAt = nil - logger.Info("No expiry set for updated key (matching existing key)") - } + } else if request.ExpiresAt == nil && request.ExpiresIn == nil { + // Existing key has no expiry, new key also has no expiry + expiresAt = nil + logger.Info("No expiry set for updated key (matching existing key)") } // Validate that expiresAt is in the future (if set) @@ -1225,14 +1238,24 @@ func (s *APIKeyService) updateAPIKey(existingKey *models.APIKey, request api.API expiresAt.Format(time.RFC3339), now.Format(time.RFC3339)) } + var indexKey *string + if existingKey.Source == "external" { + computedIndexKey := computeExternalKeyIndexKey(plainAPIKeyValue) + if computedIndexKey == "" { + return nil, fmt.Errorf("failed to compute index key") + } + indexKey = &computedIndexKey + } + // Create the regenerated API key updatedKey := &models.APIKey{ ID: existingKey.ID, Name: existingKey.Name, + DisplayName: displayName, APIKey: hashedAPIKeyValue, // Store hashed key MaskedAPIKey: maskedAPIKeyValue, // Store masked key for display APIId: existingKey.APIId, - Operations: existingKey.Operations, + Operations: operations, Status: models.APIKeyStatusActive, CreatedAt: existingKey.CreatedAt, CreatedBy: existingKey.CreatedBy, @@ -1240,7 +1263,8 @@ func (s *APIKeyService) updateAPIKey(existingKey *models.APIKey, request api.API ExpiresAt: expiresAt, Unit: unit, Duration: duration, - Source: existingKey.Source, // Preserve source from original key + Source: existingKey.Source, // Preserve source from original key. + IndexKey: indexKey, } // Temporarily store the plain key for response generation @@ -1275,6 +1299,10 @@ func (s *APIKeyService) regenerateAPIKey(existingKey *models.APIKey, request api var duration *int if request.ExpiresAt != nil { + if request.ExpiresAt.Before(now) { + return nil, fmt.Errorf("API key expiration time must be in the future, got: %s (current time: %s)", + request.ExpiresAt.Format(time.RFC3339), now.Format(time.RFC3339)) + } // If expires_at is explicitly provided, use it expiresAt = request.ExpiresAt logger.Info("Using provided expires_at for regeneration", slog.Time("expires_at", *expiresAt)) @@ -1369,7 +1397,7 @@ func (s *APIKeyService) regenerateAPIKey(existingKey *models.APIKey, request api ExpiresAt: expiresAt, Unit: unit, Duration: duration, - Source: existingKey.Source, // Preserve source from original key + Source: existingKey.Source, // Preserve source from original key } // Temporarily store the plain key for response generation @@ -1390,7 +1418,6 @@ func (s *APIKeyService) canRevokeAPIKey(user *commonmodels.AuthContext, apiKey * } logger.Debug("Checking API key revocation authorization", - slog.String("user_id", user.UserID), slog.Any("roles", user.Roles), slog.String("api_key_name", apiKey.Name), slog.String("api_key_creator", apiKey.CreatedBy)) @@ -1398,7 +1425,6 @@ func (s *APIKeyService) canRevokeAPIKey(user *commonmodels.AuthContext, apiKey * // Admin role can revoke any API key if s.isAdmin(user) { logger.Debug("User has admin role, authorized to revoke any API key", - slog.String("user_id", user.UserID), slog.String("api_key_name", apiKey.Name)) return nil } @@ -1406,14 +1432,12 @@ func (s *APIKeyService) canRevokeAPIKey(user *commonmodels.AuthContext, apiKey * // Non-admin users can only revoke keys they created if apiKey.CreatedBy != user.UserID { logger.Warn("User cannot revoke API key - not the creator and not admin", - slog.String("user_id", user.UserID), slog.String("api_key_name", apiKey.Name), slog.String("api_key_creator", apiKey.CreatedBy)) return fmt.Errorf("API key revocation not authorized for user") } logger.Debug("User authorized to revoke API key as creator", - slog.String("user_id", user.UserID), slog.String("api_key_name", apiKey.Name)) return nil @@ -1825,6 +1849,97 @@ func (s *APIKeyService) getCurrentAPIKeyCount(apiId, userID string) (int, error) return 0, fmt.Errorf("failed to get current API key count") } +// generateShortSuffix generates a short 4-character URL-safe suffix +// Uses 3 random bytes encoded as base64url, similar to patterns used in the repository +// Returns a string like "efhh" or "xrhy" +func (s *APIKeyService) generateShortSuffix() (string, error) { + // Generate 3 random bytes for a 4-character suffix + randomBytes := make([]byte, 3) + if _, err := rand.Read(randomBytes); err != nil { + return "", fmt.Errorf("failed to generate random bytes for suffix: %w", err) + } + + // Encode as base64url without padding (3 bytes = 4 chars) + suffix := base64.RawURLEncoding.EncodeToString(randomBytes) + + // Replace any non-alphanumeric characters to ensure only lowercase letters and numbers + // Convert to lowercase and replace special chars with random letters + suffix = strings.ToLower(suffix) + suffix = strings.Map(func(r rune) rune { + if (r >= 'a' && r <= 'z') || (r >= '0' && r <= '9') { + return r + } + // Replace special chars with a random lowercase letter + return 'a' + rune(randomBytes[0]%26) + }, suffix) + + return suffix, nil +} + +// generateUniqueAPIKeyName generates a unique name from displayName, handling collisions +// If a name collision occurs, appends a short suffix (e.g., "-efhh", "-xrhy") +// Retries up to maxRetries times to find a unique name +func (s *APIKeyService) generateUniqueAPIKeyName(apiId, displayName string, maxRetries int) (string, error) { + // Generate base name from display name + baseName, err := GenerateAPIKeyName(displayName) + if err != nil { + return "", fmt.Errorf("failed to generate base name: %w", err) + } + + // Try base name first + exists, err := s.checkAPIKeyNameExists(apiId, baseName) + if err != nil { + return "", fmt.Errorf("failed to check name existence: %w", err) + } + if !exists { + return baseName, nil + } + + // Name collision detected, try with suffixes + for i := 0; i < maxRetries; i++ { + suffix, err := s.generateShortSuffix() + if err != nil { + return "", err + } + + uniqueName := baseName + "-" + suffix + + // Enforce max length (name field is typically 63 chars max) + if len(uniqueName) > apiKeyNameMaxLength { + // Truncate base name to make room for suffix + truncatedBase := baseName[:apiKeyNameMaxLength-len(suffix)-1] + uniqueName = truncatedBase + "-" + suffix + } + + exists, err := s.checkAPIKeyNameExists(apiId, uniqueName) + if err != nil { + return "", fmt.Errorf("failed to check name existence: %w", err) + } + if !exists { + return uniqueName, nil + } + } + + return "", fmt.Errorf("failed to generate unique name after %d attempts", maxRetries) +} + +// checkAPIKeyNameExists checks if an API key name already exists for the given API +func (s *APIKeyService) checkAPIKeyNameExists(apiId, name string) (bool, error) { + if s.db != nil { + if apiKey, _ := s.db.GetAPIKeysByAPIAndName(apiId, name); apiKey != nil { + return true, nil + } + } + + // Fallback to memory store (for in-memory mode) + if s.store != nil { + if apiKey, err := s.store.GetAPIKeyByName(apiId, name); err == nil && apiKey != nil { + return true, nil + } + } + return false, nil +} + // generateShortUniqueID generates a 22-character URL-safe unique identifier // Uses 16 random bytes (128 bits) encoded as base64url without padding // Results in exactly 22 characters that are URL-safe and highly unique @@ -1853,308 +1968,62 @@ func (s *APIKeyService) generateShortUniqueID() (string, error) { // This is used when platform-api broadcasts an apikey.created event. // The plain API key is hashed before storage. func (s *APIKeyService) CreateExternalAPIKeyFromEvent( - apiId string, - keyName string, - plainAPIKey string, - externalRefId *string, - operations string, - expiresAt *time.Time, + handle string, + user string, + request *api.APIKeyCreationRequest, + correlationID string, logger *slog.Logger, -) error { +) (*APIKeyCreationResult, error) { logger.Info("Creating external API key from event", - slog.String("api_id", apiId), - slog.String("key_name", keyName), - slog.Bool("has_expiry", expiresAt != nil), + slog.String("api_id", handle), + slog.Bool("has_expiry", request.ExpiresAt != nil), ) - // Validate inputs - if apiId == "" { - return fmt.Errorf("API ID cannot be empty") - } - if keyName == "" { - return fmt.Errorf("key name cannot be empty") - } - if plainAPIKey == "" { - return fmt.Errorf("API key cannot be empty") - } - - // Validate API key length - if len(plainAPIKey) < 16 { - return fmt.Errorf("API key is too short (minimum 16 characters required)") - } - - // Check if API exists - config, err := s.store.Get(apiId) - if err != nil { - return fmt.Errorf("API not found: %s", apiId) - } - - // Check if an API key with this name already exists (db or in-memory store) - var existingKeys []*models.APIKey - if s.db != nil { - existingKeys, err = s.db.GetAPIKeysByAPI(apiId) - if err != nil && !errors.Is(err, storage.ErrNotFound) { - return fmt.Errorf("failed to check existing keys: %w", err) - } - } else { - existingKeys, err = s.store.GetAPIKeysByAPI(apiId) - if err != nil && !errors.Is(err, storage.ErrNotFound) { - return fmt.Errorf("failed to check existing keys: %w", err) - } - } - - for _, key := range existingKeys { - if key.Name == keyName { - // Key with same name exists - update it instead of creating new one - logger.Info("API key with same name already exists, updating it", - slog.String("api_id", apiId), - slog.String("key_name", keyName), - slog.String("existing_id", key.ID), - ) - - // Hash the new API key - hashedAPIKey, err := s.hashAPIKey(plainAPIKey) - if err != nil { - return fmt.Errorf("failed to hash API key: %w", err) - } - - // Update existing key - key.APIKey = hashedAPIKey - key.MaskedAPIKey = s.MaskAPIKey(plainAPIKey) - key.Operations = operations - key.Status = models.APIKeyStatusActive - key.UpdatedAt = time.Now() - key.ExpiresAt = expiresAt - key.Source = "external" - // Only update ExternalRefId when a new value is provided; avoid clearing an existing reference on nil - if externalRefId != nil { - key.ExternalRefId = externalRefId - } - - if s.db != nil { - if err := s.db.UpdateAPIKey(key); err != nil { - return fmt.Errorf("failed to update API key: %w", err) - } - } - - // Upsert into in-memory ConfigStore - if err := s.store.StoreAPIKey(key); err != nil { - return fmt.Errorf("failed to update API key in config store: %w", err) - } - - // Trigger xDS snapshot update via xdsManager (log only, do not fail) - if s.xdsManager != nil { - if err := s.xdsManager.StoreAPIKey(apiId, config.GetDisplayName(), config.GetVersion(), key, "external-update"); err != nil { - logger.Error("Failed to update xDS snapshot after API key update", - slog.String("api_id", apiId), - slog.Any("error", err), - ) - } - } - - logger.Info("Successfully updated external API key", - slog.String("api_id", apiId), - slog.String("key_name", keyName), - slog.String("key_id", key.ID), - ) - - return nil - } - } - - // Enforce API key quota/limit for this API before creating a NEW key. - // External events don't carry an end-user identity, so we attribute quota checks to the - // external creator ("platform-api") to ensure we never exceed the configured maximum. - if err := s.enforceAPIKeyLimit(apiId, "platform-api", logger); err != nil { - logger.Warn("API key creation limit exceeded for external event", - slog.String("api_id", apiId), - slog.String("key_name", keyName), - slog.String("created_by", "platform-api"), - slog.Any("error", err), - ) - return err - } - - // Generate unique ID for the key - id, err := s.generateShortUniqueID() - if err != nil { - return fmt.Errorf("failed to generate unique ID: %w", err) + params := APIKeyCreationParams{ + Handle: handle, + Request: *request, + User: &commonmodels.AuthContext{ + UserID: user, + }, + Logger: logger, + CorrelationID: correlationID, } - // Hash the API key - hashedAPIKey, err := s.hashAPIKey(plainAPIKey) + result, err := s.CreateAPIKey(params) if err != nil { - return fmt.Errorf("failed to hash API key: %w", err) - } - - // Generate masked API key for display - maskedAPIKey := s.MaskAPIKey(plainAPIKey) - - // Create API key model - now := time.Now() - apiKey := &models.APIKey{ - ID: id, - Name: keyName, - APIKey: hashedAPIKey, - MaskedAPIKey: maskedAPIKey, - APIId: apiId, - Operations: operations, - Status: models.APIKeyStatusActive, - CreatedAt: now, - CreatedBy: "platform-api", // External keys are created by platform-api - UpdatedAt: now, - ExpiresAt: expiresAt, - Source: "external", - ExternalRefId: externalRefId, - } - - // Store in database (optional) - if s.db != nil { - if err := s.db.SaveAPIKey(apiKey); err != nil { - if errors.Is(err, storage.ErrConflict) { - return fmt.Errorf("API key with name '%s' already exists", keyName) - } - return fmt.Errorf("failed to store API key: %w", err) - } - } - - // Upsert into in-memory ConfigStore - if err := s.store.StoreAPIKey(apiKey); err != nil { - return fmt.Errorf("failed to store API key in config store: %w", err) - } - - // Trigger xDS snapshot update to propagate to policy engine via xdsManager (log only, do not fail) - if s.xdsManager != nil { - if err := s.xdsManager.StoreAPIKey(apiId, config.GetDisplayName(), config.GetVersion(), apiKey, "external-create"); err != nil { - logger.Error("Failed to update xDS snapshot after API key creation", - slog.String("api_id", apiId), - slog.Any("error", err), - ) - // Don't return error - key is already stored in DB/store - // Policy engine will get it on next full sync - } + logger.Error("Failed to create external API key", slog.Any("error", err)) + return nil, err } - logger.Info("Successfully created external API key", - slog.String("api_id", apiId), - slog.String("key_name", keyName), - slog.String("key_id", id), - slog.String("source", "external"), - ) - - return nil + return result, nil } // RevokeExternalAPIKeyFromEvent revokes an API key from an external event (websocket). // This is used when platform-api broadcasts an apikey.revoked event. func (s *APIKeyService) RevokeExternalAPIKeyFromEvent( - apiId string, + handle string, keyName string, + user string, + correlationID string, logger *slog.Logger, ) error { - logger.Info("Revoking external API key from event", - slog.String("api_id", apiId), - slog.String("key_name", keyName), - ) - - // Validate inputs - if apiId == "" { - return fmt.Errorf("API ID cannot be empty") - } - if keyName == "" { - return fmt.Errorf("key name cannot be empty") + apiKeyRevocationParams := APIKeyRevocationParams{ + Handle: handle, + APIKeyName: keyName, + User: &commonmodels.AuthContext{ + UserID: user, + }, + Logger: logger, + CorrelationID: correlationID, } - // Check if API exists - config, err := s.store.Get(apiId) + _, err := s.RevokeAPIKey(apiKeyRevocationParams) if err != nil { - logger.Warn("API not found, skipping revocation", - slog.String("api_id", apiId), - ) - return nil // Idempotent - already gone - } - - // Get API keys for this API - var apiKeys []*models.APIKey - if s.db != nil { - apiKeys, err = s.db.GetAPIKeysByAPI(apiId) - if err != nil { - if errors.Is(err, storage.ErrNotFound) { - logger.Warn("No API keys found for API, skipping revocation", - slog.String("api_id", apiId), - ) - return nil // Idempotent - no keys to revoke - } - return fmt.Errorf("failed to get API keys: %w", err) - } - } else { - // DB is optional; fall back to in-memory store for idempotent revocation handling - apiKeys, err = s.store.GetAPIKeysByAPI(apiId) - if err != nil { - // Treat errors as non-fatal; absence of keys is idempotent - logger.Warn("Failed to get API keys from in-memory store, skipping revocation", - slog.String("api_id", apiId), - slog.Any("error", err), - ) - return nil - } - if len(apiKeys) == 0 { - logger.Warn("No API keys found for API (in-memory), skipping revocation", - slog.String("api_id", apiId), - ) - return nil // Idempotent - no keys to revoke - } - } - - // Find the key by name - var targetKey *models.APIKey - for _, key := range apiKeys { - if key.Name == keyName { - targetKey = key - break - } - } - - if targetKey == nil { - logger.Warn("API key not found, skipping revocation (idempotent)", - slog.String("api_id", apiId), - slog.String("key_name", keyName), - ) - return nil // Idempotent - key doesn't exist - } - - // Mark as revoked - targetKey.Status = models.APIKeyStatusRevoked - targetKey.UpdatedAt = time.Now() - - // Update in database - if s.db != nil { - if err := s.db.UpdateAPIKey(targetKey); err != nil { - return fmt.Errorf("failed to update API key status: %w", err) - } - } - - // Remove from in-memory ConfigStore so cache isn't stale - if err := s.store.RemoveAPIKeyByID(apiId, targetKey.ID); err != nil && !errors.Is(err, storage.ErrNotFound) { - return fmt.Errorf("failed to remove API key from config store: %w", err) + logger.Error("Failed to revoke external API key", slog.Any("error", err)) + return err } - // Trigger xDS snapshot update to propagate to policy engine via xdsManager (optional; log only) - if s.xdsManager != nil { - if err := s.xdsManager.RevokeAPIKey(apiId, config.GetDisplayName(), config.GetVersion(), keyName, "external-revoke"); err != nil { - logger.Error("Failed to update xDS snapshot after API key revocation", - slog.String("api_id", apiId), - slog.Any("error", err), - ) - // Don't return error - key is already revoked in DB/store - } - } - - logger.Info("Successfully revoked external API key", - slog.String("api_id", apiId), - slog.String("key_name", keyName), - slog.String("key_id", targetKey.ID), - ) + logger.Info("Successfully revoked external API key") return nil } @@ -2162,115 +2031,48 @@ func (s *APIKeyService) RevokeExternalAPIKeyFromEvent( // UpdateExternalAPIKeyFromEvent updates an API key from an external event (websocket). // This is used when platform-api broadcasts an apikey.updated event. func (s *APIKeyService) UpdateExternalAPIKeyFromEvent( - apiId string, - keyName string, - plainAPIKey string, - expiresAt *time.Time, + handle string, + apiKeyName string, + request *api.APIKeyCreationRequest, + user string, + correlationID string, logger *slog.Logger, ) error { - logger.Info("Updating external API key from event", - slog.String("api_id", apiId), - slog.String("key_name", keyName), - slog.Bool("has_expiry", expiresAt != nil), - ) - - // Validate inputs - if apiId == "" { - return fmt.Errorf("API ID cannot be empty") - } - if keyName == "" { - return fmt.Errorf("key name cannot be empty") - } - if plainAPIKey == "" { - return fmt.Errorf("API key cannot be empty") - } - - // Validate API key length - if len(plainAPIKey) < 16 { - return fmt.Errorf("API key is too short (minimum 16 characters required)") - } - - // Check if API exists - config, err := s.store.Get(apiId) - if err != nil { - return fmt.Errorf("API not found: %s", apiId) - } - - // Get API keys for this API - var apiKeys []*models.APIKey - if s.db != nil { - apiKeys, err = s.db.GetAPIKeysByAPI(apiId) - if err != nil { - if errors.Is(err, storage.ErrNotFound) { - return fmt.Errorf("no API keys found for API: %s", apiId) - } - return fmt.Errorf("failed to get API keys: %w", err) - } - } else { - apiKeys, err = s.store.GetAPIKeysByAPI(apiId) - if err != nil { - return fmt.Errorf("failed to get API keys from store: %w", err) - } - } - - // Find the key by name - var targetKey *models.APIKey - for _, key := range apiKeys { - if key.Name == keyName { - targetKey = key - break - } - } - if targetKey == nil { - return fmt.Errorf("API key not found: %s", keyName) + apiKeyUpdateParams := APIKeyUpdateParams{ + Handle: handle, + APIKeyName: apiKeyName, + Request: *request, + User: &commonmodels.AuthContext{ + UserID: user, + }, + Logger: logger, + CorrelationID: correlationID, } - - // Hash the new API key - hashedAPIKey, err := s.hashAPIKey(plainAPIKey) + _, err := s.UpdateAPIKey(apiKeyUpdateParams) if err != nil { - return fmt.Errorf("failed to hash API key: %w", err) - } - - // Update the key with new values - targetKey.APIKey = hashedAPIKey - targetKey.MaskedAPIKey = s.MaskAPIKey(plainAPIKey) - targetKey.Status = models.APIKeyStatusActive - targetKey.UpdatedAt = time.Now() - targetKey.ExpiresAt = expiresAt - // Preserve source and other metadata - if targetKey.Source == "" { - targetKey.Source = "external" + logger.Error("Failed to update external API key", slog.Any("error", err), + slog.String("correlation_id", correlationID), + slog.String("user_id", user), + slog.String("api_id", handle), + ) + return err } - // Update in database - if s.db != nil { - if err := s.db.UpdateAPIKey(targetKey); err != nil { - return fmt.Errorf("failed to update API key: %w", err) - } - } + logger.Info("Successfully updated external API key") - // Update in-memory ConfigStore - if err := s.store.StoreAPIKey(targetKey); err != nil { - return fmt.Errorf("failed to update API key in config store: %w", err) - } + return nil +} - // Trigger xDS snapshot update via xdsManager (log only, do not fail) - if s.xdsManager != nil { - if err := s.xdsManager.StoreAPIKey(apiId, config.GetDisplayName(), config.GetVersion(), targetKey, "external-update"); err != nil { - logger.Error("Failed to update xDS snapshot after API key update", - slog.String("api_id", apiId), - slog.Any("error", err), - ) - // Don't return error - key is already updated in DB/store - } +// computeIndexKey computes a SHA-256 hash-based index key for fast lookup +// Returns the index key as "hash_hex" (SHA-256 of the plain key) +func computeExternalKeyIndexKey(plainAPIKey string) string { + if plainAPIKey == "" { + return "" } - logger.Info("Successfully updated external API key", - slog.String("api_id", apiId), - slog.String("key_name", keyName), - slog.String("key_id", targetKey.ID), - ) - - return nil + hasher := sha256.New() + hasher.Write([]byte(plainAPIKey)) + hash := hasher.Sum(nil) + return hex.EncodeToString(hash) } diff --git a/gateway/gateway-controller/pkg/utils/api_key_validation.go b/gateway/gateway-controller/pkg/utils/api_key_validation.go new file mode 100644 index 000000000..5d68e871d --- /dev/null +++ b/gateway/gateway-controller/pkg/utils/api_key_validation.go @@ -0,0 +1,118 @@ +/* + * Copyright (c) 2025, WSO2 LLC. (https://www.wso2.com). + * + * WSO2 LLC. licenses this file to you under the Apache License, + * Version 2.0 (the "License"); you may not use this file except + * in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +package utils + +import ( + "fmt" + "regexp" + "strings" + "unicode/utf8" + + "github.com/wso2/api-platform/gateway/gateway-controller/pkg/constants" +) + +const ( + apiKeyNameMinLength = 3 + apiKeyNameMaxLength = 63 + displayNameMaxLength = 100 +) + +var ( + // validAPIKeyNameRegex matches lowercase alphanumeric with hyphens (not at start/end, no consecutive) + validAPIKeyNameRegex = regexp.MustCompile(`^[a-z0-9]+(-[a-z0-9]+)*$`) + // invalidCharsRegex matches any character that is not alphanumeric, hyphen, underscore, or space + invalidCharsRegex = regexp.MustCompile(`[^a-z0-9\-_ ]`) + // multipleHyphensRegex matches consecutive hyphens + multipleHyphensRegex = regexp.MustCompile(`-+`) +) + +// ValidateAPIKeyValue validates a plain API key value for creation or update. +// Use this for both REST create/update and external events (apikey.created, apikey.updated). +// Returns a descriptive error if the key is empty, too short, or too long. +// Note: Expects the caller to trim whitespace before validation. +func ValidateAPIKeyValue(plainKey string) error { + if plainKey == "" { + return fmt.Errorf("API key cannot be empty") + } + if len(plainKey) < constants.MIN_API_KEY_LENGTH { + return fmt.Errorf("API key is too short (minimum %d characters required)", constants.MIN_API_KEY_LENGTH) + } + if len(plainKey) > constants.MAX_API_KEY_LENGTH { + return fmt.Errorf("API key is too long (maximum %d characters allowed)", constants.MAX_API_KEY_LENGTH) + } + return nil +} + +// ValidateDisplayName validates the user-provided display name for an API key. +// Display name must be 1-100 UTF-8 characters (counted by runes, not bytes). +// Trims whitespace before validation. +func ValidateDisplayName(displayName string) error { + trimmed := strings.TrimSpace(displayName) + if trimmed == "" { + return fmt.Errorf("display name cannot be empty") + } + + runeCount := utf8.RuneCountInString(trimmed) + if runeCount > displayNameMaxLength { + return fmt.Errorf("display name is too long (%d characters, maximum %d allowed)", runeCount, displayNameMaxLength) + } + return nil +} + +// GenerateAPIKeyName generates a URL-safe name from a display name. +// Transforms the displayName by: +// - Trimming whitespace +// - Converting to lowercase +// - Replacing spaces and underscores with hyphens +// - Removing invalid characters +// - Collapsing consecutive hyphens +// - Trimming leading/trailing hyphens +// - Enforcing length constraints (3-63 chars) +func GenerateAPIKeyName(displayName string) (string, error) { + trimmed := strings.TrimSpace(displayName) + // Convert to lowercase + name := strings.ToLower(trimmed) + + // Replace spaces and underscores with hyphens + name = strings.ReplaceAll(name, " ", "-") + name = strings.ReplaceAll(name, "_", "-") + + // Remove invalid characters + name = invalidCharsRegex.ReplaceAllString(name, "") + + // Collapse multiple hyphens into single hyphen + name = multipleHyphensRegex.ReplaceAllString(name, "-") + + // Trim leading and trailing hyphens + name = strings.Trim(name, "-") + + // Enforce max length + if len(name) > apiKeyNameMaxLength { + name = name[:apiKeyNameMaxLength] + // Trim trailing hyphen if truncation created one + name = strings.TrimRight(name, "-") + } + + // If name is too short after sanitization, return error + if len(name) < apiKeyNameMinLength { + return "", fmt.Errorf("generated name '%s' is too short (minimum %d characters required after sanitization)", name, apiKeyNameMinLength) + } + + return name, nil +} diff --git a/gateway/gateway-controller/tests/integration/schema_test.go b/gateway/gateway-controller/tests/integration/schema_test.go index 8d7573b98..c0f6de856 100644 --- a/gateway/gateway-controller/tests/integration/schema_test.go +++ b/gateway/gateway-controller/tests/integration/schema_test.go @@ -103,7 +103,7 @@ func TestSchemaInitialization(t *testing.T) { var version int err := rawDB.QueryRow("PRAGMA user_version").Scan(&version) assert.NoError(t, err) - assert.Equal(t, 5, version, "Schema version should be 5") + assert.Equal(t, 6, version, "Schema version should be 6 (api_keys with external ref, index_key, display_name)") }) // Verify deployments table exists diff --git a/gateway/policies/policy-manifest.yaml b/gateway/policies/policy-manifest.yaml index 504924559..de5984a30 100644 --- a/gateway/policies/policy-manifest.yaml +++ b/gateway/policies/policy-manifest.yaml @@ -23,7 +23,7 @@ policies: - name: json-to-xml gomodule: github.com/wso2/gateway-controllers/policies/json-to-xml@v0 - name: jwt-auth - gomodule: github.com/wso2/gateway-controllers/policies/jwt-auth@v0 + gomodule: github.com/wso2/gateway-controllers/policies/jwt-auth@v0.1.1 - name: log-message gomodule: github.com/wso2/gateway-controllers/policies/log-message@v0 - name: mcp-auth diff --git a/gateway/policy-engine/Dockerfile b/gateway/policy-engine/Dockerfile index 8ffcb01ea..12626b54e 100644 --- a/gateway/policy-engine/Dockerfile +++ b/gateway/policy-engine/Dockerfile @@ -36,6 +36,11 @@ RUN apk add --no-cache \ # Set working directory WORKDIR /workspace +# Copy Common mod files first to cache dependencies +COPY --from=common go.mod go.sum /api-platform/common/ +WORKDIR /api-platform/common +RUN go mod download + # Copy SDK mod files first to cache dependencies COPY --from=sdk go.mod go.sum /api-platform/sdk/ WORKDIR /api-platform/sdk @@ -46,15 +51,10 @@ COPY --from=gateway-builder go.mod go.sum /api-platform/gateway/gateway-builder/ WORKDIR /api-platform/gateway/gateway-builder RUN go mod download -# Copy Common mod files first to cache dependencies -COPY --from=common go.mod go.sum /api-platform/common/ -WORKDIR /api-platform/common -RUN go mod download - # Copy full SDK and Gateway Builder and Common source code +COPY --from=common . /api-platform/common COPY --from=sdk . /api-platform/sdk COPY --from=gateway-builder . /api-platform/gateway/gateway-builder -COPY --from=common . /api-platform/common # Build the Gateway Builder binary (CGO disabled for static binary) WORKDIR /api-platform/gateway/gateway-builder @@ -77,19 +77,17 @@ ENV VERSION=${VERSION} ENV GIT_COMMIT=${GIT_COMMIT} ENV BUILD_DATE=${BUILD_DATE} -# Copy Common mod files first to cache dependencies +# Copy Policy Engine mod files and SDK and Common mod files for dependency resolution COPY --from=common go.mod go.sum /api-platform/common/ -WORKDIR /api-platform/common -RUN go mod download - -# Copy Policy Engine mod files and SDK mod files for dependency resolution COPY --from=sdk go.mod go.sum /api-platform/sdk/ COPY go.mod go.sum /api-platform/gateway/policy-engine/ WORKDIR /api-platform/gateway/policy-engine RUN go mod download -# Copy full Policy Engine and SDK source code + +# Copy full Policy Engine and SDK and Common source code COPY . /api-platform/gateway/policy-engine +COPY --from=common . /api-platform/common COPY --from=sdk . /api-platform/sdk # Copy policy implementations @@ -98,9 +96,6 @@ COPY --from=policies . /workspace/policies/ # Copy system policy implementations COPY --from=system-policies . /workspace/policies/ -# Copy Common mod files first to cache dependencies -COPY --from=common . /api-platform/common - # Create output directory RUN mkdir -p /workspace/output diff --git a/gateway/policy-engine/internal/xdsclient/api_key_handler.go b/gateway/policy-engine/internal/xdsclient/api_key_handler.go index 0b95d4a11..deea9c2c8 100644 --- a/gateway/policy-engine/internal/xdsclient/api_key_handler.go +++ b/gateway/policy-engine/internal/xdsclient/api_key_handler.go @@ -25,7 +25,9 @@ import ( "log/slog" "time" - "github.com/wso2/api-platform/common/apikey" + // TODO: Migrate to common/apikey.APIkeyStore for better architecture + // Currently using policy/v1alpha store to ensure validation and xDS use the same instance + policyv1alpha "github.com/wso2/api-platform/sdk/gateway/policy/v1alpha" policyenginev1 "github.com/wso2/api-platform/sdk/gateway/policyengine/v1" "google.golang.org/protobuf/encoding/protojson" "google.golang.org/protobuf/proto" @@ -35,12 +37,14 @@ import ( // APIKeyOperationHandler handles API key operations received via xDS type APIKeyOperationHandler struct { - apiKeyStore *apikey.APIkeyStore + // TODO: Migrate to common/apikey.APIkeyStore for better architecture + // Currently using policy/v1alpha store to ensure validation and xDS use the same instance + apiKeyStore *policyv1alpha.APIkeyStore logger *slog.Logger } // NewAPIKeyOperationHandler creates a new API key operation handler -func NewAPIKeyOperationHandler(apiKeyStore *apikey.APIkeyStore, logger *slog.Logger) *APIKeyOperationHandler { +func NewAPIKeyOperationHandler(apiKeyStore *policyv1alpha.APIkeyStore, logger *slog.Logger) *APIKeyOperationHandler { return &APIKeyOperationHandler{ apiKeyStore: apiKeyStore, logger: logger, @@ -128,22 +132,23 @@ func (h *APIKeyOperationHandler) handleStoreOperation(operation policyenginev1.A "api_key_name", operation.APIKey.Name, "correlation_id", operation.CorrelationID) - // Convert APIKeyData to apikey.APIKey - apiKey := &apikey.APIKey{ + // Convert APIKeyData to policyv1alpha.APIKey + apiKey := &policyv1alpha.APIKey{ ID: operation.APIKey.ID, Name: operation.APIKey.Name, APIKey: operation.APIKey.APIKey, APIId: operation.APIKey.APIId, Operations: operation.APIKey.Operations, - Status: apikey.APIKeyStatus(operation.APIKey.Status), + Status: policyv1alpha.APIKeyStatus(operation.APIKey.Status), CreatedAt: operation.APIKey.CreatedAt, CreatedBy: operation.APIKey.CreatedBy, UpdatedAt: operation.APIKey.UpdatedAt, ExpiresAt: operation.APIKey.ExpiresAt, Source: operation.APIKey.Source, + IndexKey: operation.APIKey.IndexKey, } - // Store the API key + // Store the API key in the policy validation store if err := h.apiKeyStore.StoreAPIKey(operation.APIId, apiKey); err != nil { return fmt.Errorf("failed to store API key in store: %w", err) } @@ -207,22 +212,23 @@ func (h *APIKeyOperationHandler) replaceAllAPIKeys(apiKeyDataList []APIKeyData) // Then, add all API keys from the new state for i, apiKeyData := range apiKeyDataList { - // Convert APIKeyData to apikey.APIKey - apiKey := &apikey.APIKey{ + // Convert APIKeyData to policyv1alpha.APIKey + apiKey := &policyv1alpha.APIKey{ ID: apiKeyData.ID, Name: apiKeyData.Name, APIKey: apiKeyData.APIKey, APIId: apiKeyData.APIId, Operations: apiKeyData.Operations, - Status: apikey.APIKeyStatus(apiKeyData.Status), + Status: policyv1alpha.APIKeyStatus(apiKeyData.Status), CreatedAt: apiKeyData.CreatedAt, CreatedBy: apiKeyData.CreatedBy, UpdatedAt: apiKeyData.UpdatedAt, ExpiresAt: apiKeyData.ExpiresAt, Source: apiKeyData.Source, + IndexKey: apiKeyData.IndexKey, } - // Store the API key + // Store the API key in the policy validation store if err := h.apiKeyStore.StoreAPIKey(apiKeyData.APIId, apiKey); err != nil { h.logger.Error("Failed to store API key during state replacement", "error", err, @@ -258,5 +264,6 @@ type APIKeyData struct { CreatedBy string `json:"createdBy"` UpdatedAt time.Time `json:"updatedAt"` ExpiresAt *time.Time `json:"expiresAt"` - Source string `json:"source"` // "local" | "external" + Source string `json:"source"` // "local" | "external" + IndexKey string `json:"indexKey"` // Pre-computed SHA-256 hash for O(1) lookup (external plain text keys only) } diff --git a/gateway/policy-engine/internal/xdsclient/handler.go b/gateway/policy-engine/internal/xdsclient/handler.go index b671153bb..75ab089f7 100644 --- a/gateway/policy-engine/internal/xdsclient/handler.go +++ b/gateway/policy-engine/internal/xdsclient/handler.go @@ -30,10 +30,11 @@ import ( "google.golang.org/protobuf/types/known/anypb" "google.golang.org/protobuf/types/known/structpb" - "github.com/wso2/api-platform/common/apikey" "github.com/wso2/api-platform/gateway/policy-engine/internal/kernel" "github.com/wso2/api-platform/gateway/policy-engine/internal/metrics" "github.com/wso2/api-platform/gateway/policy-engine/internal/registry" + // TODO: Migrate to common/apikey for better architecture + // Currently using policy/v1alpha store to ensure validation and xDS use the same instance policy "github.com/wso2/api-platform/sdk/gateway/policy/v1alpha" policyenginev1 "github.com/wso2/api-platform/sdk/gateway/policyengine/v1" ) @@ -57,7 +58,9 @@ type ResourceHandler struct { // NewResourceHandler creates a new ResourceHandler func NewResourceHandler(k *kernel.Kernel, reg *registry.PolicyRegistry) *ResourceHandler { - apiKeyStore := apikey.GetAPIkeyStoreInstance() + // TODO: Migrate to common/apikey.GetAPIkeyStoreInstance() for better architecture + // Currently using policy/v1alpha store to ensure validation and xDS use the same singleton instance + apiKeyStore := policy.GetAPIkeyStoreInstance() lazyResourceStore := policy.GetLazyResourceStoreInstance() return &ResourceHandler{ kernel: k, diff --git a/platform-api/src/internal/handler/api_key.go b/platform-api/src/internal/handler/api_key.go index 2ae335d6c..ef34f47e8 100644 --- a/platform-api/src/internal/handler/api_key.go +++ b/platform-api/src/internal/handler/api_key.go @@ -44,7 +44,7 @@ func NewAPIKeyHandler(apiKeyService *service.APIKeyService) *APIKeyHandler { } // CreateAPIKey handles POST /api/v1/apis/{apiId}/api-keys -// This endpoint allows Cloud APIM to inject external API keys to hybrid gateways +// This endpoint allows users to inject external API keys to all the gateways where the API is deployed func (h *APIKeyHandler) CreateAPIKey(c *gin.Context) { // Extract organization from JWT token orgId, exists := middleware.GetOrganizationFromContext(c) @@ -65,23 +65,21 @@ func (h *APIKeyHandler) CreateAPIKey(c *gin.Context) { // Parse and validate request body var req dto.CreateAPIKeyRequest if err := c.ShouldBindJSON(&req); err != nil { - log.Printf("[WARN] Invalid API key creation request: orgId=%s apiId=%s error=%v", - orgId, apiID, err) + utils.LogError("Invalid API key creation request", err) c.JSON(http.StatusBadRequest, utils.NewErrorResponse(400, "Bad Request", - "Invalid request body: "+err.Error())) + "Invalid request body")) return } - // Validate request fields - if req.Name == "" { + if req.ApiKey == "" { c.JSON(http.StatusBadRequest, utils.NewErrorResponse(400, "Bad Request", - "API key name is required")) + "API key value is required")) return } - if req.ApiKey == "" { + if req.Name == "" { c.JSON(http.StatusBadRequest, utils.NewErrorResponse(400, "Bad Request", - "API key value is required")) + "API key name is required")) return } @@ -237,7 +235,7 @@ func (h *APIKeyHandler) RevokeAPIKey(c *gin.Context) { log.Printf("[ERROR] Failed to revoke API key: apiId=%s orgId=%s keyName=%s error=%v", apiID, orgId, keyName, err) c.JSON(http.StatusInternalServerError, utils.NewErrorResponse(500, "Internal Server Error", - "Failed to revoke API key")) + "Failed to revoke API key in one or more gateways")) return } diff --git a/platform-api/src/internal/service/apikey.go b/platform-api/src/internal/service/apikey.go index d57d41e6c..958bfa5de 100644 --- a/platform-api/src/internal/service/apikey.go +++ b/platform-api/src/internal/service/apikey.go @@ -52,19 +52,16 @@ func (s *APIKeyService) CreateAPIKey(ctx context.Context, apiId, orgId string, r return fmt.Errorf("failed to get API: %w", err) } if api == nil { - log.Printf("[WARN] API not found for API key creation: apiId=%s", apiId) return constants.ErrAPINotFound } // Get all deployments for this API to find target gateways deployments, err := s.apiRepo.GetDeploymentsByAPIUUID(apiId, orgId, nil, nil) if err != nil { - log.Printf("[ERROR] Failed to get deployments for API key creation: apiId=%s error=%v", apiId, err) - return fmt.Errorf("failed to get API deployments: %w", err) + return fmt.Errorf("failed to get API deployments for API ID: %s: %w", apiId, err) } if len(deployments) == 0 { - log.Printf("[WARN] No gateway deployments found for API: apiId=%s", apiId) return constants.ErrGatewayUnavailable } @@ -201,23 +198,19 @@ func (s *APIKeyService) RevokeAPIKey(ctx context.Context, apiId, orgId, keyName // Validate API exists and get its deployments api, err := s.apiRepo.GetAPIByUUID(apiId, orgId) if err != nil { - log.Printf("[ERROR] Failed to get API for API key revocation: apiId=%s error=%v", apiId, err) return fmt.Errorf("failed to get API: %w", err) } if api == nil { - log.Printf("[WARN] API not found for API key revocation: apiId=%s", apiId) return constants.ErrAPINotFound } // Get all deployments for this API to find target gateways deployments, err := s.apiRepo.GetDeploymentsByAPIUUID(apiId, orgId, nil, nil) if err != nil { - log.Printf("[ERROR] Failed to get deployments for API key revocation: apiId=%s error=%v", apiId, err) return fmt.Errorf("failed to get API deployments: %w", err) } if len(deployments) == 0 { - log.Printf("[WARN] No gateway deployments found for API: apiId=%s", apiId) return constants.ErrGatewayUnavailable } @@ -257,12 +250,12 @@ func (s *APIKeyService) RevokeAPIKey(ctx context.Context, apiId, orgId, keyName log.Printf("[INFO] API key revocation broadcast summary: apiId=%s keyName=%s total=%d success=%d failed=%d", apiId, keyName, len(deployments), successCount, failureCount) - // Return error if all deliveries failed - if successCount == 0 { - log.Printf("[ERROR] Failed to deliver API key revocation to any gateway: apiId=%s keyName=%s", apiId, keyName) - return fmt.Errorf("failed to deliver API key revocation event to any gateway: %w", lastError) + if failureCount > 0 { + log.Printf("[ERROR] Failed to deliver API key revocation to all gateways: apiId=%s keyName=%s failed=%d total=%d", + apiId, keyName, failureCount, len(deployments)) + return fmt.Errorf("failed to deliver API key revocation to %d of %d gateways: %w", + failureCount, len(deployments), lastError) } - // Partial success is still considered success return nil } diff --git a/platform-api/src/internal/service/gateway_events.go b/platform-api/src/internal/service/gateway_events.go index 5afca7b8e..be76a294f 100644 --- a/platform-api/src/internal/service/gateway_events.go +++ b/platform-api/src/internal/service/gateway_events.go @@ -208,86 +208,63 @@ func (s *GatewayEventsService) BroadcastUndeploymentEvent(gatewayID string, unde return nil } -// BroadcastAPIKeyCreatedEvent sends an API key created event to target gateway with retries. +// BroadcastAPIKeyCreatedEvent sends an API key created event to target gateway. // This method handles: // - Looking up gateway connections by gateway ID // - Serializing event to JSON // - Broadcasting to all connections for the gateway (clustering support) -// - Current API key events are not retried +// - Up to 2 attempts per call (no backoff; caller should handle broader retry logic if needed) // - Payload size validation // - Delivery statistics tracking func (s *GatewayEventsService) BroadcastAPIKeyCreatedEvent(gatewayID string, event *model.APIKeyCreatedEvent) error { - const maxRetries = 1 - const retryDelay = 1 * time.Second + const maxAttempts = 2 var lastError error - // Retry loop for critical API key events - for attempt := 0; attempt < maxRetries; attempt++ { - if attempt > 0 { - log.Printf("[INFO] Retrying API key created event broadcast: gatewayID=%s attempt=%d/%d", - gatewayID, attempt+1, maxRetries) - time.Sleep(retryDelay * time.Duration(attempt)) // Linear backoff - } - + for attempt := 0; attempt < maxAttempts; attempt++ { err := s.broadcastAPIKeyCreated(gatewayID, event) if err == nil { - if attempt > 0 { - log.Printf("[INFO] API key created event delivered after retry: gatewayID=%s attempts=%d", - gatewayID, attempt+1) - } return nil } lastError = err - log.Printf("[WARN] API key created event delivery failed: gatewayID=%s attempt=%d/%d error=%v", - gatewayID, attempt+1, maxRetries, err) + log.Printf("[WARN] API key created event delivery failed: gatewayID=%s error=%v", + gatewayID, err) } - log.Printf("[ERROR] API key created event delivery failed after all retries: gatewayID=%s retries=%d error=%v", - gatewayID, maxRetries, lastError) - return fmt.Errorf("failed to deliver API key created event after %d retries: %w", maxRetries, lastError) + log.Printf("[ERROR] API key created event delivery failed: gatewayID=%s error=%v", + gatewayID, lastError) + return fmt.Errorf("failed to deliver API key created event: %w", lastError) } -// BroadcastAPIKeyRevokedEvent sends an API key revoked event to target gateway with retries. +// BroadcastAPIKeyRevokedEvent sends an API key revoked event to target gateway. // This method handles: // - Looking up gateway connections by gateway ID // - Serializing event to JSON // - Broadcasting to all connections for the gateway (clustering support) -// - Current API key events are not retried +// - Up to 2 attempts per call (no backoff; caller should handle broader retry logic if needed) // - Payload size validation // - Delivery statistics tracking func (s *GatewayEventsService) BroadcastAPIKeyRevokedEvent(gatewayID string, event *model.APIKeyRevokedEvent) error { - const maxRetries = 1 - const retryDelay = 1 * time.Second + const maxAttempts = 2 var lastError error - // Retry loop for critical API key events - for attempt := 0; attempt < maxRetries; attempt++ { - if attempt > 0 { - log.Printf("[INFO] Retrying API key revoked event broadcast: gatewayID=%s attempt=%d/%d", - gatewayID, attempt+1, maxRetries) - time.Sleep(retryDelay * time.Duration(attempt)) // Linear backoff - } - + // Single attempt delivery for API key events + for attempt := 0; attempt < maxAttempts; attempt++ { err := s.broadcastAPIKeyRevoked(gatewayID, event) if err == nil { - if attempt > 0 { - log.Printf("[INFO] API key revoked event delivered after retry: gatewayID=%s attempts=%d", - gatewayID, attempt+1) - } return nil } lastError = err - log.Printf("[WARN] API key revoked event delivery failed: gatewayID=%s attempt=%d/%d error=%v", - gatewayID, attempt+1, maxRetries, err) + log.Printf("[WARN] API key revoked event delivery failed: gatewayID=%s error=%v", + gatewayID, err) } - log.Printf("[ERROR] API key revoked event delivery failed after all retries: gatewayID=%s retries=%d error=%v", - gatewayID, maxRetries, lastError) - return fmt.Errorf("failed to deliver API key revoked event after %d retries: %w", maxRetries, lastError) + log.Printf("[ERROR] API key revoked event delivery failed: gatewayID=%s error=%v", + gatewayID, lastError) + return fmt.Errorf("failed to deliver API key revoked event: %w", lastError) } // broadcastAPIKeyCreated is the internal implementation for broadcasting API key created events @@ -298,14 +275,12 @@ func (s *GatewayEventsService) broadcastAPIKeyCreated(gatewayID string, event *m // Serialize payload payloadJSON, err := json.Marshal(event) if err != nil { - log.Printf("[ERROR] Failed to serialize API key created event: gatewayID=%s error=%v", gatewayID, err) return fmt.Errorf("failed to serialize API key created event: %w", err) } // Validate payload size if len(payloadJSON) > MaxEventPayloadSize { err := fmt.Errorf("event payload exceeds maximum size: %d bytes (limit: %d bytes)", len(payloadJSON), MaxEventPayloadSize) - log.Printf("[ERROR] Payload size validation failed: gatewayID=%s size=%d error=%v", gatewayID, len(payloadJSON), err) return err } @@ -320,15 +295,12 @@ func (s *GatewayEventsService) broadcastAPIKeyCreated(gatewayID string, event *m // Serialize complete event eventJSON, err := json.Marshal(eventDTO) if err != nil { - log.Printf("[ERROR] Failed to marshal API key created event DTO: gatewayID=%s correlationId=%s error=%v", - gatewayID, correlationID, err) return fmt.Errorf("failed to marshal event: %w", err) } // Get all connections for this gateway connections := s.manager.GetConnections(gatewayID) if len(connections) == 0 { - log.Printf("[WARN] No active connections for gateway: gatewayID=%s correlationId=%s", gatewayID, correlationID) return fmt.Errorf("no active connections for gateway: %s", gatewayID) } @@ -373,14 +345,12 @@ func (s *GatewayEventsService) broadcastAPIKeyRevoked(gatewayID string, event *m // Serialize payload payloadJSON, err := json.Marshal(event) if err != nil { - log.Printf("[ERROR] Failed to serialize API key revoked event: gatewayID=%s error=%v", gatewayID, err) return fmt.Errorf("failed to serialize API key revoked event: %w", err) } // Validate payload size if len(payloadJSON) > MaxEventPayloadSize { err := fmt.Errorf("event payload exceeds maximum size: %d bytes (limit: %d bytes)", len(payloadJSON), MaxEventPayloadSize) - log.Printf("[ERROR] Payload size validation failed: gatewayID=%s size=%d error=%v", gatewayID, len(payloadJSON), err) return err } @@ -395,15 +365,12 @@ func (s *GatewayEventsService) broadcastAPIKeyRevoked(gatewayID string, event *m // Serialize complete event eventJSON, err := json.Marshal(eventDTO) if err != nil { - log.Printf("[ERROR] Failed to marshal API key revoked event DTO: gatewayID=%s correlationId=%s error=%v", - gatewayID, correlationID, err) return fmt.Errorf("failed to marshal event: %w", err) } // Get all connections for this gateway connections := s.manager.GetConnections(gatewayID) if len(connections) == 0 { - log.Printf("[WARN] No active connections for gateway: gatewayID=%s correlationId=%s", gatewayID, correlationID) return fmt.Errorf("no active connections for gateway: %s", gatewayID) } @@ -440,45 +407,34 @@ func (s *GatewayEventsService) broadcastAPIKeyRevoked(gatewayID string, event *m return nil } -// BroadcastAPIKeyUpdatedEvent sends an API key updated event to target gateway with retries. +// BroadcastAPIKeyUpdatedEvent sends an API key updated event to target gateway. // This method handles: // - Looking up gateway connections by gateway ID // - Serializing event to JSON // - Broadcasting to all connections for the gateway (clustering support) -// - Current API key events are not retried +// - Up to 2 attempts per call (no backoff; caller should handle broader retry logic if needed) // - Payload size validation // - Delivery statistics tracking func (s *GatewayEventsService) BroadcastAPIKeyUpdatedEvent(gatewayID string, event *model.APIKeyUpdatedEvent) error { - const maxRetries = 1 - const retryDelay = 1 * time.Second + const maxAttempts = 1 var lastError error - // Retry loop for critical API key events - for attempt := 0; attempt < maxRetries; attempt++ { - if attempt > 0 { - log.Printf("[INFO] Retrying API key updated event broadcast: gatewayID=%s attempt=%d/%d", - gatewayID, attempt+1, maxRetries) - time.Sleep(retryDelay * time.Duration(attempt)) // Linear backoff - } - + // Single attempt delivery for API key events + for attempt := 0; attempt < maxAttempts; attempt++ { err := s.broadcastAPIKeyUpdated(gatewayID, event) if err == nil { - if attempt > 0 { - log.Printf("[INFO] API key updated event delivered after retry: gatewayID=%s attempts=%d", - gatewayID, attempt+1) - } return nil } lastError = err - log.Printf("[WARN] API key updated event delivery failed: gatewayID=%s attempt=%d/%d error=%v", - gatewayID, attempt+1, maxRetries, err) + log.Printf("[WARN] API key updated event delivery failed: gatewayID=%s error=%v", + gatewayID, err) } - log.Printf("[ERROR] API key updated event delivery failed after all retries: gatewayID=%s retries=%d error=%v", - gatewayID, maxRetries, lastError) - return fmt.Errorf("failed to deliver API key updated event after %d retries: %w", maxRetries, lastError) + log.Printf("[ERROR] API key updated event delivery failed: gatewayID=%s error=%v", + gatewayID, lastError) + return fmt.Errorf("failed to deliver API key update event: %w", lastError) } // broadcastAPIKeyUpdated is the internal implementation for broadcasting API key updated events @@ -489,14 +445,12 @@ func (s *GatewayEventsService) broadcastAPIKeyUpdated(gatewayID string, event *m // Serialize payload payloadJSON, err := json.Marshal(event) if err != nil { - log.Printf("[ERROR] Failed to serialize API key updated event: gatewayID=%s error=%v", gatewayID, err) return fmt.Errorf("failed to serialize API key updated event: %w", err) } // Validate payload size if len(payloadJSON) > MaxEventPayloadSize { err := fmt.Errorf("event payload exceeds maximum size: %d bytes (limit: %d bytes)", len(payloadJSON), MaxEventPayloadSize) - log.Printf("[ERROR] Payload size validation failed: gatewayID=%s size=%d error=%v", gatewayID, len(payloadJSON), err) return err } @@ -511,15 +465,12 @@ func (s *GatewayEventsService) broadcastAPIKeyUpdated(gatewayID string, event *m // Serialize complete event eventJSON, err := json.Marshal(eventDTO) if err != nil { - log.Printf("[ERROR] Failed to marshal API key updated event DTO: gatewayID=%s correlationId=%s error=%v", - gatewayID, correlationID, err) return fmt.Errorf("failed to marshal event: %w", err) } // Get all connections for this gateway connections := s.manager.GetConnections(gatewayID) if len(connections) == 0 { - log.Printf("[WARN] No active connections for gateway: gatewayID=%s correlationId=%s", gatewayID, correlationID) return fmt.Errorf("no active connections for gateway: %s", gatewayID) } diff --git a/sdk/gateway/policy/v1alpha/api_key.go b/sdk/gateway/policy/v1alpha/api_key.go new file mode 100644 index 000000000..05faf8b8e --- /dev/null +++ b/sdk/gateway/policy/v1alpha/api_key.go @@ -0,0 +1,564 @@ +/* + * Copyright (c) 2026, WSO2 LLC. (https://www.wso2.com). + * + * WSO2 LLC. licenses this file to you under the Apache License, + * Version 2.0 (the "License"); you may not use this file except + * in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +package policyv1alpha + +import ( + "crypto/sha256" + "crypto/subtle" + "encoding/base64" + "encoding/hex" + "encoding/json" + "errors" + "fmt" + "strings" + "sync" + "time" + + "golang.org/x/crypto/argon2" + "golang.org/x/crypto/bcrypt" +) + +type APIKey struct { + // ID of the API Key + ID string `json:"id" yaml:"id"` + // Name of the API key (URL-safe identifier, auto-generated, immutable) + Name string `json:"name" yaml:"name"` + // DisplayName is the human-readable name (user-provided, mutable) + DisplayName string `json:"display_name" yaml:"display_name"` + // ApiKey API key with apip_ prefix + APIKey string `json:"api_key" yaml:"api_key"` + // APIId Unique identifier of the API that the key is associated with + APIId string `json:"apiId" yaml:"apiId"` + // Operations List of API operations the key will have access to + Operations string `json:"operations" yaml:"operations"` + // Status of the API key + Status APIKeyStatus `json:"status" yaml:"status"` + // CreatedAt Timestamp when the API key was generated + CreatedAt time.Time `json:"created_at" yaml:"created_at"` + // CreatedBy User who created the API key + CreatedBy string `json:"created_by" yaml:"created_by"` + // UpdatedAt Timestamp when the API key was last updated + UpdatedAt time.Time `json:"updated_at" yaml:"updated_at"` + // ExpiresAt Expiration timestamp (null if no expiration) + ExpiresAt *time.Time `json:"expires_at" yaml:"expires_at"` + // Source tracking for external key support ("local" | "external") + Source string `json:"source" yaml:"source"` + // IndexKey Pre-computed hash for O(1) lookup (external plain text keys only) + IndexKey string `json:"index_key" yaml:"index_key"` +} + +// APIKeyStatus Status of the API key +type APIKeyStatus string + +// ParsedAPIKey represents a parsed API key with its components +type ParsedAPIKey struct { + APIKey string + ID string +} + +// Defines values for APIKeyStatus. +const ( + Active APIKeyStatus = "active" + Expired APIKeyStatus = "expired" + Revoked APIKeyStatus = "revoked" +) + +const APIKeySeparator = "_" + +// Common storage errors - implementation agnostic +var ( + // ErrNotFound is returned when an API key is not found + ErrNotFound = errors.New("API key not found") + + // ErrConflict is returned when an API Key with the same name/version or key value already exists + ErrConflict = errors.New("API key already exists") +) + +// Singleton instance +var ( + instance *APIkeyStore + once sync.Once +) + +// APIkeyStore holds all API keys in memory for fast access +type APIkeyStore struct { + mu sync.RWMutex // Protects concurrent access + // API Keys storage + apiKeysByAPI map[string]map[string]*APIKey // Key: "API ID" → Value: map[API key ID]*APIKey + // Fast lookup index for external keys: Key: "API ID:SHA256(plain key)" → Value: API key ID + // This avoids O(n) iteration through all keys for external key validation + externalKeyIndex map[string]map[string]*string +} + +// NewAPIkeyStore creates a new in-memory API key store +func NewAPIkeyStore() *APIkeyStore { + return &APIkeyStore{ + apiKeysByAPI: make(map[string]map[string]*APIKey), + externalKeyIndex: make(map[string]map[string]*string), + } +} + +// GetAPIkeyStoreInstance provides a shared instance of APIkeyStore +func GetAPIkeyStoreInstance() *APIkeyStore { + once.Do(func() { + instance = NewAPIkeyStore() + }) + return instance +} + +// StoreAPIKey stores an API key in the in-memory cache +func (aks *APIkeyStore) StoreAPIKey(apiId string, apiKey *APIKey) error { + if apiKey == nil { + return fmt.Errorf("API key cannot be nil") + } + + aks.mu.Lock() + defer aks.mu.Unlock() + + // Check if an API key with the same apiId and name already exists + existingKeys, apiIdExists := aks.apiKeysByAPI[apiId] + var existingKeyID = "" + + if apiIdExists { + for id, existingKey := range existingKeys { + if existingKey.Name == apiKey.Name { + existingKeyID = id + break + } + } + } + + if existingKeyID != "" { + // Remove old external key index entry if it exists + oldKey := aks.apiKeysByAPI[apiId][existingKeyID] + if oldKey != nil && oldKey.Source == "external" { + var oldIndexKey string + if oldKey.IndexKey != "" { + oldIndexKey = oldKey.IndexKey + } else { + oldIndexKey = computeExternalKeyIndexKey(oldKey.APIKey) + if oldIndexKey == "" { + return fmt.Errorf("failed to compute index key") + } + } + delete(aks.externalKeyIndex[apiId], oldIndexKey) + } + + // Update the existing entry in apiKeysByAPI + aks.apiKeysByAPI[apiId][existingKeyID] = apiKey + } else { + // Insert new API key + // Check if API key ID already exists + if _, exists := aks.apiKeysByAPI[apiId][apiKey.ID]; exists { + return ErrConflict + } + + // Initialize the map for this API ID if it doesn't exist + if aks.apiKeysByAPI[apiId] == nil { + aks.apiKeysByAPI[apiId] = make(map[string]*APIKey) + } + + // Store by API key value + aks.apiKeysByAPI[apiId][apiKey.ID] = apiKey + } + + // For external keys with plain text (not hashed), add to fast lookup index + // This enables O(1) lookup during validation instead of O(n) iteration + if apiKey.Source == "external" { + if existingKeyID != "" { + if aks.externalKeyIndex[apiId] == nil { + aks.externalKeyIndex[apiId] = make(map[string]*string) + } + aks.externalKeyIndex[apiId][apiKey.IndexKey] = &apiKey.ID + } else { + if aks.externalKeyIndex[apiId] == nil { + aks.externalKeyIndex[apiId] = make(map[string]*string) + } + aks.externalKeyIndex[apiId][apiKey.IndexKey] = &apiKey.ID + } + } + + return nil +} + +// ValidateAPIKey validates the provided API key against the internal APIkey store +// Supports both local keys (with format: key_id) and external keys (any format) +func (aks *APIkeyStore) ValidateAPIKey(apiId, apiOperation, operationMethod, providedAPIKey string) (bool, error) { + aks.mu.Lock() + defer aks.mu.Unlock() + + var targetAPIKey *APIKey + + // Try to parse as local key (format: key_id) + parsedAPIkey, ok := parseAPIKey(providedAPIKey) + if ok { + // Optimized O(1) lookup for local keys using ID + apiKey, exists := aks.apiKeysByAPI[apiId][parsedAPIkey.ID] + if exists && apiKey.Source == "local" && compareAPIKeys(parsedAPIkey.APIKey, apiKey.APIKey) { + targetAPIKey = apiKey + } + } + + // If not found via local key lookup, try external key index for O(1) lookup + if targetAPIKey == nil { + // Compute the index key for external key lookup + indexKey := computeExternalKeyIndexKey(providedAPIKey) + if indexKey == "" { + return false, fmt.Errorf("API key is empty") + } + trimmedAPIKey := strings.TrimSpace(providedAPIKey) + keyID, exists := aks.externalKeyIndex[apiId][indexKey] + if exists { + // Found in index, retrieve the key + if apiKey, ok := aks.apiKeysByAPI[apiId][*keyID]; ok { + if apiKey.Source == "external" && compareAPIKeys(trimmedAPIKey, apiKey.APIKey) { + targetAPIKey = apiKey + } + } + } + } + + if targetAPIKey == nil { + return false, ErrNotFound + } + + // Check if the API key belongs to the specified API + if targetAPIKey.APIId != apiId { + return false, nil + } + + // Check if the API key is active + if targetAPIKey.Status != Active { + return false, 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 + } + + // 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) + } + + // Check if wildcard is present + for _, op := range operations { + if strings.TrimSpace(op) == "*" { + return true, 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 + } + } + + // Operation not found in allowed list + return false, nil +} + +// RevokeAPIKey revokes a specific API key by plain text API key value +// Supports both local keys (with format: key_id) and external keys (any format) +func (aks *APIkeyStore) RevokeAPIKey(apiId, providedAPIKey string) error { + aks.mu.Lock() + defer aks.mu.Unlock() + + var matchedKey *APIKey + + // Try to parse as local key (format: key_id); empty Source treated as "local" + parsedAPIkey, ok := parseAPIKey(providedAPIKey) + if ok { + apiKey, exists := aks.apiKeysByAPI[apiId][parsedAPIkey.ID] + if exists && apiKey.Source == "local" && compareAPIKeys(parsedAPIkey.APIKey, apiKey.APIKey) { + matchedKey = apiKey + } + } + + // If not found via local key lookup, try external key index for O(1) lookup + if matchedKey == nil { + indexKey := computeExternalKeyIndexKey(providedAPIKey) + if keyID, exists := aks.externalKeyIndex[apiId][indexKey]; exists { + if apiKey, ok := aks.apiKeysByAPI[apiId][*keyID]; ok { + if apiKey.Source == "external" && compareAPIKeys(providedAPIKey, apiKey.APIKey) { + matchedKey = apiKey + } + } + } + } + + // If the API key doesn't exist, treat revocation as successful (idempotent operation) + if matchedKey == nil { + return nil + } + + // Set status to revoked + matchedKey.Status = Revoked + + aks.removeFromAPIMapping(matchedKey) + + return nil +} + +// RemoveAPIKeysByAPI removes all API keys for a specific API +func (aks *APIkeyStore) RemoveAPIKeysByAPI(apiId string) error { + aks.mu.Lock() + defer aks.mu.Unlock() + + apiKeys, exists := aks.apiKeysByAPI[apiId] + if !exists { + return nil // No keys to remove + } + + // Remove from external key index + for _, apiKey := range apiKeys { + if apiKey.Source == "external" { + var indexKey string + if apiKey.IndexKey != "" { + indexKey = apiKey.IndexKey + } else { + indexKey = computeExternalKeyIndexKey(apiKey.APIKey) + if indexKey == "" { + return fmt.Errorf("failed to compute index key") + } + } + delete(aks.externalKeyIndex[apiKey.APIId], indexKey) + } + } + + // Remove from API-specific map + delete(aks.apiKeysByAPI, apiId) + + return nil +} + +// ClearAll removes all API keys from the store +func (aks *APIkeyStore) ClearAll() error { + aks.mu.Lock() + defer aks.mu.Unlock() + + // Clear the API-specific keys map + aks.apiKeysByAPI = make(map[string]map[string]*APIKey) + // Clear the external key index + aks.externalKeyIndex = make(map[string]map[string]*string) + + return nil +} + +// compareAPIKeys compares API keys for external use +// Returns true if the plain API key matches the hash, false otherwise +// If hashing is disabled, performs plain text comparison +func compareAPIKeys(providedAPIKey, storedAPIKey string) bool { + if providedAPIKey == "" || storedAPIKey == "" { + return false + } + + // Check if it's an SHA-256 hash (format: $sha256$$) + if strings.HasPrefix(storedAPIKey, "$sha256$") { + return compareSHA256Hash(providedAPIKey, storedAPIKey) + } + + // Check if it's a bcrypt hash (starts with $2a$, $2b$, or $2y$) + if strings.HasPrefix(storedAPIKey, "$2a$") || + strings.HasPrefix(storedAPIKey, "$2b$") || + strings.HasPrefix(storedAPIKey, "$2y$") { + return compareBcryptHash(providedAPIKey, storedAPIKey) + } + + // Check if it's an Argon2id hash + if strings.HasPrefix(storedAPIKey, "$argon2id$") { + err := compareArgon2id(providedAPIKey, storedAPIKey) + return err == nil + } + + // If no hash format is detected and hashing is enabled, try plain text comparison as fallback + // This handles migration scenarios where some keys might still be stored as plain text + return subtle.ConstantTimeCompare([]byte(providedAPIKey), []byte(storedAPIKey)) == 1 +} + +// compareSHA256Hash validates an encoded SHA-256 hash and compares it to the provided password. +// Expected format: $sha256$$ +// Returns true if the plain API key matches the hash, false otherwise +func compareSHA256Hash(apiKey, encoded string) bool { + if apiKey == "" || encoded == "" { + return false + } + + // Parse the hash format: $sha256$$ + parts := strings.Split(encoded, "$") + if len(parts) != 4 || parts[1] != "sha256" { + return false + } + + // Decode salt and hash from hex + salt, err := hex.DecodeString(parts[2]) + if err != nil { + return false + } + + storedHash, err := hex.DecodeString(parts[3]) + if err != nil { + return false + } + + // Compute hash of the provided key with the stored salt + hasher := sha256.New() + hasher.Write([]byte(apiKey)) + hasher.Write(salt) + computedHash := hasher.Sum(nil) + + // Constant-time comparison + return subtle.ConstantTimeCompare(computedHash, storedHash) == 1 +} + +// compareBcryptHash validates an encoded bcrypt hash and compares it to the provided password. +// Returns true if the plain API key matches the hash, false otherwise +func compareBcryptHash(apiKey, encoded string) bool { + if apiKey == "" || encoded == "" { + return false + } + + // Compare the provided key with the stored bcrypt hash + err := bcrypt.CompareHashAndPassword([]byte(encoded), []byte(apiKey)) + return err == nil +} + +// compareArgon2id parses an encoded Argon2id hash and compares it to the provided password. +// Expected format: $argon2id$v=19$m=,t=,p=

$$ +func compareArgon2id(apiKey, encoded string) error { + parts := strings.Split(encoded, "$") + if len(parts) != 6 || parts[1] != "argon2id" { + return fmt.Errorf("invalid argon2id hash format") + } + + // parts[2] -> v=19 + var version int + if _, err := fmt.Sscanf(parts[2], "v=%d", &version); err != nil { + return err + } + if version != argon2.Version { + return fmt.Errorf("unsupported argon2 version: %d", version) + } + + // parts[3] -> m=,t=,p=

+ var mem uint32 + var iters uint32 + var threads uint8 + var t, m, p uint32 + if _, err := fmt.Sscanf(parts[3], "m=%d,t=%d,p=%d", &m, &t, &p); err != nil { + return err + } + mem = m + iters = t + threads = uint8(p) + + // decode salt and hash (try RawStd then Std) + salt, err := decodeBase64(parts[4]) + if err != nil { + return err + } + hash, err := decodeBase64(parts[5]) + if err != nil { + return err + } + + derived := argon2.IDKey([]byte(apiKey), salt, iters, mem, threads, uint32(len(hash))) + if subtle.ConstantTimeCompare(derived, hash) == 1 { + return nil + } + return errors.New("API key mismatch") +} + +// decodeBase64 decodes a base64 string, trying RawStdEncoding first, then StdEncoding +func decodeBase64(s string) ([]byte, error) { + b, err := base64.RawStdEncoding.DecodeString(s) + if err == nil { + return b, nil + } + // try StdEncoding as a fallback + return base64.StdEncoding.DecodeString(s) +} + +// parseAPIKey splits an API key value into its key and ID components +func parseAPIKey(value string) (ParsedAPIKey, bool) { + idx := strings.LastIndex(value, APIKeySeparator) + if idx <= 0 || idx == len(value)-1 { + return ParsedAPIKey{}, false + } + + apiKey := value[:idx] + encodedID := value[idx+1:] + + // The ID is already base64url encoded (22 chars) + // with underscores replaced by tildes (~) + return ParsedAPIKey{ + APIKey: apiKey, + ID: encodedID, // Use the encoded ID directly (contains ~ instead of _) + }, true +} + +// computeExternalKeyIndexKey computes a SHA-256 hash of the plain-text API key for fast lookup +// Returns the index key as "hash_hex" (SHA-256 of the plain key) +func computeExternalKeyIndexKey(plainAPIKey string) string { + trimmedAPIKey := strings.TrimSpace(plainAPIKey) + if trimmedAPIKey == "" { + return "" + } + + hasher := sha256.New() + hasher.Write([]byte(trimmedAPIKey)) + hash := hasher.Sum(nil) + return hex.EncodeToString(hash) +} + +// removeFromAPIMapping removes an API key from the API mapping +func (aks *APIkeyStore) removeFromAPIMapping(apiKey *APIKey) { + apiKeys, apiIdExists := aks.apiKeysByAPI[apiKey.APIId] + if apiIdExists { + delete(apiKeys, apiKey.ID) + // clean up empty maps + if len(aks.apiKeysByAPI[apiKey.APIId]) == 0 { + delete(aks.apiKeysByAPI, apiKey.APIId) + } + } + + // Remove from external key index if it's an external key + if apiKey.Source == "external" { + if aks.externalKeyIndex[apiKey.APIId] == nil { + return + } + var indexKey string + if apiKey.IndexKey != "" { + indexKey = apiKey.IndexKey + } else { + indexKey = computeExternalKeyIndexKey(apiKey.APIKey) + if indexKey == "" { + return + } + } + delete(aks.externalKeyIndex[apiKey.APIId], indexKey) + } +} diff --git a/sdk/gateway/policy/v1alpha/api_key_hash_test.go b/sdk/gateway/policy/v1alpha/api_key_hash_test.go new file mode 100644 index 000000000..86f202a5e --- /dev/null +++ b/sdk/gateway/policy/v1alpha/api_key_hash_test.go @@ -0,0 +1,197 @@ +/* + * Copyright (c) 2025, WSO2 LLC. (https://www.wso2.com). + * + * WSO2 LLC. licenses this file to you under the Apache License, + * Version 2.0 (the "License"); you may not use this file except + * in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +package policyv1alpha + +import ( + "crypto/rand" + "encoding/base64" + "fmt" + "testing" + "time" + + "golang.org/x/crypto/argon2" +) + +func TestAPIKeyHashedValidation(t *testing.T) { + store := NewAPIkeyStore() + + // Create a plain text API key (69 bytes like real generated keys) + plainAPIKey := "apip_88f8399ef29761f92f4f6d2dbd6dcd78399b3bcb8c53417cb23726e5780ac215" + + // Hash the API key using Argon2id (simulating what the gateway controller does) + salt := make([]byte, 16) + _, err := rand.Read(salt) + if err != nil { + t.Fatalf("Failed to generate salt: %v", err) + } + + hash := argon2.IDKey([]byte(plainAPIKey), salt, 1, 64*1024, 4, 32) + saltEncoded := base64.RawStdEncoding.EncodeToString(salt) + hashEncoded := base64.RawStdEncoding.EncodeToString(hash) + hashedAPIKey := fmt.Sprintf("$argon2id$v=19$m=%d,t=%d,p=%d$%s$%s", + 64*1024, 1, 4, saltEncoded, hashEncoded) + + // Create API key with hashed value + apiKey := &APIKey{ + ID: "test-id-1", + Name: "test-key", + APIKey: hashedAPIKey, // Store hashed key + APIId: "api-123", + Operations: "[\"*\"]", + Status: Active, + CreatedAt: time.Now(), + CreatedBy: "test-user", + UpdatedAt: time.Now(), + ExpiresAt: nil, + } + + // Store the API key + err = store.StoreAPIKey("api-123", apiKey) + if err != nil { + t.Fatalf("Failed to store API key: %v", err) + } + + // Test validation with correct plain text key + valid, 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") + } +} + +func TestAPIKeyHashedValidationFailures(t *testing.T) { + store := NewAPIkeyStore() + + plainAPIKey := "apip_88f8399ef29761f92f4f6d2dbd6dcd78399b3bcb8c53417cb23726e5780ac215" + + // Hash the API key using Argon2id + salt := make([]byte, 16) + _, err := rand.Read(salt) + if err != nil { + t.Fatalf("Failed to generate salt: %v", err) + } + + hash := argon2.IDKey([]byte(plainAPIKey), salt, 1, 64*1024, 4, 32) + saltEncoded := base64.RawStdEncoding.EncodeToString(salt) + hashEncoded := base64.RawStdEncoding.EncodeToString(hash) + hashedAPIKey := fmt.Sprintf("$argon2id$v=19$m=%d,t=%d,p=%d$%s$%s", + 64*1024, 1, 4, saltEncoded, hashEncoded) + + apiKey := &APIKey{ + ID: "test-id-2", + Name: "test-key-2", + APIKey: hashedAPIKey, + APIId: "api-456", + Operations: "[\"*\"]", + Status: Active, + CreatedAt: time.Now(), + CreatedBy: "test-user", + UpdatedAt: time.Now(), + ExpiresAt: nil, + } + + err = store.StoreAPIKey("api-456", apiKey) + if err != nil { + t.Fatalf("Failed to store API key: %v", err) + } + + // Test validation with wrong plain text key + wrongKey := "apip_wrong399ef29761f92f4f6d2dbd6dcd78399b3bcb8c53417cb23726e5780ac999" + valid, err := store.ValidateAPIKey("api-456", "/test", "GET", wrongKey) + if err != nil { + if err != ErrNotFound { + t.Fatalf("Expected ErrNotFound, got: %v", err) + } + } + if valid { + t.Error("Validation should fail with incorrect plain text API key") + } + + // Test validation with non-existent API + valid, err = store.ValidateAPIKey("non-existent-api", "/test", "GET", plainAPIKey) + if err == nil { + t.Error("Expected error for non-existent API") + } + if valid { + t.Error("Validation should fail for non-existent API") + } +} + +func TestAPIKeyHashedRevocation(t *testing.T) { + store := NewAPIkeyStore() + + plainAPIKey := "apip_revoke399ef29761f92f4f6d2dbd6dcd78399b3bcb8c53417cb23726e5780ac215" + + // Hash the API key using Argon2id + salt := make([]byte, 16) + _, err := rand.Read(salt) + if err != nil { + t.Fatalf("Failed to generate salt: %v", err) + } + + hash := argon2.IDKey([]byte(plainAPIKey), salt, 1, 64*1024, 4, 32) + saltEncoded := base64.RawStdEncoding.EncodeToString(salt) + hashEncoded := base64.RawStdEncoding.EncodeToString(hash) + hashedAPIKey := fmt.Sprintf("$argon2id$v=19$m=%d,t=%d,p=%d$%s$%s", + 64*1024, 1, 4, saltEncoded, hashEncoded) + + apiKey := &APIKey{ + ID: "test-id-3", + Name: "revoke-test-key", + APIKey: hashedAPIKey, + APIId: "api-789", + Operations: "[\"*\"]", + Status: Active, + CreatedAt: time.Now(), + CreatedBy: "test-user", + UpdatedAt: time.Now(), + ExpiresAt: nil, + } + + err = store.StoreAPIKey("api-789", apiKey) + if err != nil { + t.Fatalf("Failed to store API key: %v", err) + } + + // Verify key works before revocation + valid, err := store.ValidateAPIKey("api-789", "/test", "GET", plainAPIKey) + if err != nil { + t.Fatalf("Validation failed before revocation: %v", err) + } + if !valid { + t.Error("API key should be valid before revocation") + } + + // Revoke the API key using plain text key + err = store.RevokeAPIKey("api-789", plainAPIKey) + if err != nil { + t.Fatalf("Failed to revoke API key: %v", err) + } + + // Verify key no longer works after revocation + valid, err = store.ValidateAPIKey("api-789", "/test", "GET", plainAPIKey) + if err != nil && err != ErrNotFound { + t.Fatalf("Unexpected error during validation after revocation: %v", err) + } + if valid { + t.Error("API key should be invalid after revocation") + } +} diff --git a/sdk/gateway/policyengine/v1/api_key_xds.go b/sdk/gateway/policyengine/v1/api_key_xds.go index a2fb09a83..e68a3dd49 100644 --- a/sdk/gateway/policyengine/v1/api_key_xds.go +++ b/sdk/gateway/policyengine/v1/api_key_xds.go @@ -89,6 +89,9 @@ type APIKeyData struct { // Source tracking for external key support ("local" | "external") Source string `json:"source" yaml:"source"` + + // IndexKey Pre-computed hash for O(1) lookup (external plain text keys only) + IndexKey string `json:"index_key" yaml:"index_key"` } // APIKeyOperationBatch represents a batch of API key operations diff --git a/sdk/go.mod b/sdk/go.mod index 3557fedf7..fddbb8852 100644 --- a/sdk/go.mod +++ b/sdk/go.mod @@ -8,7 +8,6 @@ require ( github.com/milvus-io/milvus/client/v2 v2.6.1 github.com/milvus-io/milvus/pkg/v2 v2.6.7 github.com/redis/go-redis/v9 v9.8.0 - github.com/wso2/api-platform/common v0.0.0 golang.org/x/crypto v0.46.0 ) @@ -126,5 +125,3 @@ require ( k8s.io/apimachinery v0.32.3 // indirect sigs.k8s.io/yaml v1.4.0 // indirect ) - -replace github.com/wso2/api-platform/common => ../common From 96ec5ce9a9cc23c20fd0d07bbe99ef154887f241 Mon Sep 17 00:00:00 2001 From: Thushani Jayasekera Date: Mon, 2 Feb 2026 20:04:33 +0530 Subject: [PATCH 08/14] Add support to accept valid apikey name as identifier and fix build issues --- .../gateway/policies/apikey-authentication.md | 1 + gateway/gateway-controller/api/openapi.yaml | 7 + .../pkg/api/generated/generated.go | 305 +++++++++--------- .../pkg/controlplane/client.go | 36 +++ .../pkg/controlplane/events.go | 1 + .../pkg/storage/gateway-controller-db.sql | 2 +- .../gateway-controller/pkg/utils/api_key.go | 24 +- .../pkg/utils/api_key_validation.go | 24 ++ platform-api/src/internal/dto/apikey.go | 20 +- platform-api/src/internal/handler/api_key.go | 26 +- .../src/internal/model/apikey_event.go | 7 +- platform-api/src/internal/service/apikey.go | 46 +-- .../src/internal/service/gateway_events.go | 8 +- sdk/gateway/policy/v1alpha/api_key.go | 4 + 14 files changed, 310 insertions(+), 201 deletions(-) diff --git a/docs/gateway/policies/apikey-authentication.md b/docs/gateway/policies/apikey-authentication.md index 2c98c15c2..1d53ca85e 100644 --- a/docs/gateway/policies/apikey-authentication.md +++ b/docs/gateway/policies/apikey-authentication.md @@ -269,6 +269,7 @@ Generate a new API key for a specific API. | Field | Type | Required | Description | |----------------------|------|----------|-------------| | `displayName` | string | No | Custom name for the API key. If not provided, a default name will be generated | +| `name` | string | No | Identifier of the API key. If not provided, a default identifier will be generated | | `expires_at` | string (ISO 8601) | No | Specific expiration timestamp for the API key. If both `expires_in` and `expires_at` are provided, `expires_at` takes precedence | | `expires_in` | object | No | Relative expiration time from creation | | `expires_in.duration` | integer | Yes (if expiresIn used) | Duration value | diff --git a/gateway/gateway-controller/api/openapi.yaml b/gateway/gateway-controller/api/openapi.yaml index dd49dbe7f..0374d4c61 100644 --- a/gateway/gateway-controller/api/openapi.yaml +++ b/gateway/gateway-controller/api/openapi.yaml @@ -2300,6 +2300,13 @@ components: minLength: 1 maxLength: 100 example: My Production Key + name: + type: string + description: Identifier of the API key. If not provided, a default identifier will be generated + pattern: "^[a-z0-9]+(-[a-z0-9]+)*$" + minLength: 3 + maxLength: 63 + example: my-production-key api_key: type: string minLength: 20 diff --git a/gateway/gateway-controller/pkg/api/generated/generated.go b/gateway/gateway-controller/pkg/api/generated/generated.go index d98fe5e5d..cdca7d8c2 100644 --- a/gateway/gateway-controller/pkg/api/generated/generated.go +++ b/gateway/gateway-controller/pkg/api/generated/generated.go @@ -449,6 +449,9 @@ type APIKeyCreationRequest struct { // This field is optional and used for tracing purposes only. // The gateway generates its own internal ID for tracking. ExternalRefId *string `json:"external_ref_id,omitempty" yaml:"external_ref_id,omitempty"` + + // Name Identifier of the API key. If not provided, a default identifier will be generated + Name *string `json:"name,omitempty" yaml:"name,omitempty"` } // APIKeyCreationRequestExpiresInUnit Time unit for expiration @@ -3039,157 +3042,157 @@ var swaggerSpec = []string{ "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", - "IwFZXzV3Kdx+vv3vAAAA//8YqttxMjkBAA==", + "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", } // GetSwagger returns the content of the embedded swagger specification file diff --git a/gateway/gateway-controller/pkg/controlplane/client.go b/gateway/gateway-controller/pkg/controlplane/client.go index 61c1301e0..ab1ec8bd0 100644 --- a/gateway/gateway-controller/pkg/controlplane/client.go +++ b/gateway/gateway-controller/pkg/controlplane/client.go @@ -25,6 +25,7 @@ import ( "fmt" "log/slog" "net/http" + "strings" "sync" "sync/atomic" "time" @@ -668,6 +669,30 @@ func (c *Client) handleAPIKeyCreatedEvent(event map[string]interface{}) { ) return } + // Validate Name - required field for external API key events + // Since no response is sent back through WebSocket, the caller must know the identifier + if keyCreatedEvent.Payload.Name != "" { + // Validate the name format + if err := utils.ValidateAPIKeyName(keyCreatedEvent.Payload.Name); err != nil { + baseLogger.Error("API key created event has invalid name", + slog.Any("correlation_id", event["correlationId"]), + slog.Any("error", err), + ) + return + } + } + + // Validate DisplayName - required field + if strings.TrimSpace(*keyCreatedEvent.Payload.DisplayName) != "" { + // Validate the display name format + if err := utils.ValidateDisplayName(*keyCreatedEvent.Payload.DisplayName); err != nil { + baseLogger.Error("API key created event has invalid display_name", + slog.Any("correlation_id", event["correlationId"]), + slog.Any("error", err), + ) + return + } + } logger := baseLogger.With( slog.String("correlation_id", keyCreatedEvent.CorrelationID), @@ -875,6 +900,17 @@ func (c *Client) handleAPIKeyUpdatedEvent(event map[string]interface{}) { return } + // Validate the display name format + if err := utils.ValidateDisplayName(payload.DisplayName); err != nil { + baseLogger.Error("API key updated event has invalid display_name", + slog.Any("correlation_id", event["correlationId"]), + slog.String("api_id", payload.ApiId), + slog.String("key_name", payload.KeyName), + slog.Any("error", err), + ) + return + } + logger := baseLogger.With( slog.String("correlation_id", evt.CorrelationID), slog.String("user_id", evt.UserId), diff --git a/gateway/gateway-controller/pkg/controlplane/events.go b/gateway/gateway-controller/pkg/controlplane/events.go index c2a3c0320..ae07e4b68 100644 --- a/gateway/gateway-controller/pkg/controlplane/events.go +++ b/gateway/gateway-controller/pkg/controlplane/events.go @@ -47,6 +47,7 @@ type APIDeployedEvent struct { type APIKeyCreatedEventPayload struct { ApiId string `json:"apiId"` ApiKey string `json:"apiKey"` // Plain text API key (will be hashed by gateway) + Name string `json:"name,omitempty"` // URL-safe identifier (3-63 chars, lowercase alphanumeric with hyphens) ExternalRefId *string `json:"externalRefId,omitempty"` Operations string `json:"operations"` ExpiresAt *string `json:"expiresAt,omitempty"` // ISO 8601 format diff --git a/gateway/gateway-controller/pkg/storage/gateway-controller-db.sql b/gateway/gateway-controller/pkg/storage/gateway-controller-db.sql index ac1c9f9f1..d24bc8652 100644 --- a/gateway/gateway-controller/pkg/storage/gateway-controller-db.sql +++ b/gateway/gateway-controller/pkg/storage/gateway-controller-db.sql @@ -141,7 +141,7 @@ CREATE TABLE IF NOT EXISTS api_keys ( -- External API key support (added in schema version 6) source TEXT NOT NULL DEFAULT 'local', -- 'local' or 'external' - external_ref_id TEXT NULL, -- Cloud APIM key ID or other external reference + external_ref_id TEXT NULL, -- external reference -- O(1) lookup optimization for external keys index_key TEXT NULL, -- Pre-computed SHA-256 hash for fast lookup diff --git a/gateway/gateway-controller/pkg/utils/api_key.go b/gateway/gateway-controller/pkg/utils/api_key.go index bca95eac9..3a37f5477 100644 --- a/gateway/gateway-controller/pkg/utils/api_key.go +++ b/gateway/gateway-controller/pkg/utils/api_key.go @@ -167,7 +167,7 @@ const ( // CreateAPIKey handles the complete API key creation process. // Supports both local key generation by generating a new random key and external key injection -// (accepts key from external systems like Cloud APIM). +// (accepts key from external platforms). func (s *APIKeyService) CreateAPIKey(params APIKeyCreationParams) (*APIKeyCreationResult, error) { baseLogger := params.Logger if baseLogger == nil { @@ -956,12 +956,22 @@ func (s *APIKeyService) createAPIKeyFromRequest(handle string, request *api.APIK displayName = fmt.Sprintf("%s-key-%s", handle, id[:8]) } - // Generate unique URL-safe name from displayName with collision handling - // name is immutable after creation and used in path parameters - // Use config.ID (API internal ID) not handle so uniqueness is checked per API - name, err := s.generateUniqueAPIKeyName(config.ID, displayName, 5) - if err != nil { - return nil, fmt.Errorf("failed to generate unique API key name: %w", err) + // Handle name - optional during creation + var name string + if request.Name != nil && strings.TrimSpace(*request.Name) != "" { + // User provided a name + name = strings.TrimSpace(*request.Name) + if err := ValidateAPIKeyName(name); err != nil { + return nil, fmt.Errorf("invalid name: %w", err) + } + } else { + // Generate unique URL-safe name from displayName with collision handling + // name is immutable after creation and used in path parameters + // Use config.ID (API internal ID) not handle so uniqueness is checked per API + name, err = s.generateUniqueAPIKeyName(config.ID, displayName, 5) + if err != nil { + return nil, fmt.Errorf("failed to generate unique API key name: %w", err) + } } // Process operations diff --git a/gateway/gateway-controller/pkg/utils/api_key_validation.go b/gateway/gateway-controller/pkg/utils/api_key_validation.go index 5d68e871d..e90bba9d6 100644 --- a/gateway/gateway-controller/pkg/utils/api_key_validation.go +++ b/gateway/gateway-controller/pkg/utils/api_key_validation.go @@ -75,6 +75,30 @@ func ValidateDisplayName(displayName string) error { return nil } +// ValidateAPIKeyName validates a user-provided API key name. +// Name must be: +// - Lowercase only +// - Alphanumeric with hyphens allowed +// - No special characters +// - No consecutive hyphens +// - Cannot start or end with hyphen +// - Length between 3 and 63 characters +func ValidateAPIKeyName(name string) error { + if name == "" { + return fmt.Errorf("API key name cannot be empty") + } + if len(name) < apiKeyNameMinLength { + return fmt.Errorf("API key name is too short (minimum %d characters required)", apiKeyNameMinLength) + } + if len(name) > apiKeyNameMaxLength { + return fmt.Errorf("API key name is too long (maximum %d characters allowed)", apiKeyNameMaxLength) + } + if !validAPIKeyNameRegex.MatchString(name) { + return fmt.Errorf("API key name must be lowercase alphanumeric with hyphens (no consecutive hyphens, cannot start/end with hyphen)") + } + return nil +} + // GenerateAPIKeyName generates a URL-safe name from a display name. // Transforms the displayName by: // - Trimming whitespace diff --git a/platform-api/src/internal/dto/apikey.go b/platform-api/src/internal/dto/apikey.go index 52732a662..1982dfa46 100644 --- a/platform-api/src/internal/dto/apikey.go +++ b/platform-api/src/internal/dto/apikey.go @@ -18,15 +18,18 @@ package dto // CreateAPIKeyRequest represents the request to register an external API key. -// This is used when Cloud APIM injects API keys to hybrid gateways. +// This is used when external platforms inject API keys to hybrid gateways. type CreateAPIKeyRequest struct { - // Name is the unique identifier for this API key within the API - Name string `json:"name" binding:"required"` + // Name is the unique identifier for this API key within the API (optional; if omitted, generated from displayName) + Name string `json:"name,omitempty"` + + // DisplayName is the display name of the API key + DisplayName string `json:"displayName,omitempty"` // ApiKey is the plain text API key value that will be hashed before storage ApiKey string `json:"api_key" binding:"required"` - // ExternalRefId is an optional reference ID for tracing purposes (from Cloud APIM) + // ExternalRefId is an optional reference ID for tracing purposes (from external platforms) ExternalRefId *string `json:"external_ref_id,omitempty"` // Operations specifies which API operations this key can access (default: "*" for all) @@ -49,7 +52,7 @@ type CreateAPIKeyResponse struct { } // UpdateAPIKeyRequest represents the request to update/regenerate an API key. -// This is used when Cloud APIM rotates API keys on hybrid gateways. +// This is used when external platforms rotate API keys on hybrid gateways. type UpdateAPIKeyRequest struct { // ApiKey is the new plain text API key value that will be hashed before storage ApiKey string `json:"api_key" binding:"required"` @@ -85,8 +88,11 @@ type APIKeyCreatedEventDTO struct { // ApiId identifies the API this key belongs to ApiId string `json:"apiId"` - // KeyName is the unique name of the API key - KeyName string `json:"keyName"` + // Name is the unique name of the API key + Name string `json:"name"` + + // DisplayName is the display name of the API key + DisplayName string `json:"displayName"` // HashedApiKey is the SHA256 hashed API key for secure storage HashedApiKey string `json:"hashedApiKey"` diff --git a/platform-api/src/internal/handler/api_key.go b/platform-api/src/internal/handler/api_key.go index ef34f47e8..f57fdce1f 100644 --- a/platform-api/src/internal/handler/api_key.go +++ b/platform-api/src/internal/handler/api_key.go @@ -77,10 +77,22 @@ func (h *APIKeyHandler) CreateAPIKey(c *gin.Context) { return } - if req.Name == "" { - c.JSON(http.StatusBadRequest, utils.NewErrorResponse(400, "Bad Request", - "API key name is required")) - return + // If user has provided a name, use it. Otherwise, generate a name from the display name. + var name string + if (req.Name != "") { + name = req.Name + } else { + name, err := utils.GenerateHandle(req.DisplayName, nil) + if err != nil { + c.JSON(http.StatusBadRequest, utils.NewErrorResponse(400, "Bad Request", + "Failed to generate API key name")) + return + } + req.Name = name + } + + if req.DisplayName == "" { + req.DisplayName = name } // Create the API key and broadcast to gateways @@ -111,12 +123,12 @@ func (h *APIKeyHandler) CreateAPIKey(c *gin.Context) { // Return success response c.JSON(http.StatusCreated, dto.CreateAPIKeyResponse{ Status: "success", - Message: "API key registered and broadcasted to gateways successfully", + KeyId: req.Name, + Message: "API key created and broadcasted to gateways successfully", }) } - // UpdateAPIKey handles PUT /api/v1/apis/{apiId}/api-keys/{keyName} -// This endpoint allows Cloud APIM to update/regenerate external API keys on hybrid gateways +// This endpoint allows external platforms to update/regenerate external API keys on hybrid gateways func (h *APIKeyHandler) UpdateAPIKey(c *gin.Context) { // Extract organization from JWT token orgId, exists := middleware.GetOrganizationFromContext(c) diff --git a/platform-api/src/internal/model/apikey_event.go b/platform-api/src/internal/model/apikey_event.go index 3529458db..fb54a1814 100644 --- a/platform-api/src/internal/model/apikey_event.go +++ b/platform-api/src/internal/model/apikey_event.go @@ -23,8 +23,11 @@ type APIKeyCreatedEvent struct { // ApiId identifies the API this key belongs to ApiId string `json:"apiId"` - // KeyName is the unique name of the API key - KeyName string `json:"keyName"` + // Name is the unique name of the API key + Name string `json:"name,omitempty"` + + // DisplayName is the display name of the API key + DisplayName string `json:"displayName,omitempty"` // ApiKey is the plain API key value (hashing happens in the gateway) ApiKey string `json:"apiKey"` diff --git a/platform-api/src/internal/service/apikey.go b/platform-api/src/internal/service/apikey.go index 958bfa5de..1cf1b7fc4 100644 --- a/platform-api/src/internal/service/apikey.go +++ b/platform-api/src/internal/service/apikey.go @@ -43,7 +43,7 @@ func NewAPIKeyService(apiRepo repository.APIRepository, gatewayEventsService *Ga } // CreateAPIKey hashes an external API key and broadcasts it to gateways where the API is deployed. -// This method is used when Cloud APIM injects API keys to hybrid gateways. +// This method is used when external platforms inject API keys to hybrid gateways. func (s *APIKeyService) CreateAPIKey(ctx context.Context, apiId, orgId string, req *dto.CreateAPIKeyRequest) error { // Validate API exists and get its deployments api, err := s.apiRepo.GetAPIByUUID(apiId, orgId) @@ -56,12 +56,12 @@ func (s *APIKeyService) CreateAPIKey(ctx context.Context, apiId, orgId string, r } // Get all deployments for this API to find target gateways - deployments, err := s.apiRepo.GetDeploymentsByAPIUUID(apiId, orgId, nil, nil) + gateways, err := s.apiRepo.GetAPIGatewaysWithDetails(apiId, orgId) if err != nil { return fmt.Errorf("failed to get API deployments for API ID: %s: %w", apiId, err) } - if len(deployments) == 0 { + if len(gateways) == 0 { return constants.ErrGatewayUnavailable } @@ -71,7 +71,8 @@ func (s *APIKeyService) CreateAPIKey(ctx context.Context, apiId, orgId string, r // Note: API key is sent as plain text - hashing happens in the gateway/policy-engine event := &model.APIKeyCreatedEvent{ ApiId: apiId, - KeyName: req.Name, + Name: req.Name, + DisplayName: req.DisplayName, ApiKey: req.ApiKey, // Send plain API key (no hashing in platform-api) ExternalRefId: req.ExternalRefId, Operations: operations, @@ -84,8 +85,8 @@ func (s *APIKeyService) CreateAPIKey(ctx context.Context, apiId, orgId string, r var lastError error // Broadcast event to all gateways where API is deployed - for _, deployment := range deployments { - gatewayID := deployment.GatewayID + for _, gateway := range gateways { + gatewayID := gateway.ID log.Printf("[INFO] Broadcasting API key created event: apiId=%s gatewayId=%s keyName=%s", apiId, gatewayID, req.Name) @@ -106,7 +107,7 @@ func (s *APIKeyService) CreateAPIKey(ctx context.Context, apiId, orgId string, r // Log summary log.Printf("[INFO] API key creation broadcast summary: apiId=%s keyName=%s total=%d success=%d failed=%d", - apiId, req.Name, len(deployments), successCount, failureCount) + apiId, req.Name, len(gateways), successCount, failureCount) // Return error if all deliveries failed if successCount == 0 { @@ -119,7 +120,7 @@ func (s *APIKeyService) CreateAPIKey(ctx context.Context, apiId, orgId string, r } // UpdateAPIKey updates/regenerates an API key and broadcasts it to all gateways where the API is deployed. -// This method is used when Cloud APIM rotates/regenerates API keys on hybrid gateways. +// This method is used when external platforms rotates/regenerates API keys on hybrid gateways. func (s *APIKeyService) UpdateAPIKey(ctx context.Context, apiId, orgId, keyName string, req *dto.UpdateAPIKeyRequest) error { // Validate API exists and get its deployments api, err := s.apiRepo.GetAPIByUUID(apiId, orgId) @@ -133,13 +134,13 @@ func (s *APIKeyService) UpdateAPIKey(ctx context.Context, apiId, orgId, keyName } // Get all deployments for this API to find target gateways - deployments, err := s.apiRepo.GetDeploymentsByAPIUUID(apiId, orgId, nil, nil) + gateways, err := s.apiRepo.GetAPIGatewaysWithDetails(apiId, orgId) if err != nil { log.Printf("[ERROR] Failed to get deployments for API key update: apiId=%s error=%v", apiId, err) return fmt.Errorf("failed to get API deployments: %w", err) } - if len(deployments) == 0 { + if len(gateways) == 0 { log.Printf("[WARN] No gateway deployments found for API: apiId=%s", apiId) return constants.ErrGatewayUnavailable } @@ -159,8 +160,8 @@ func (s *APIKeyService) UpdateAPIKey(ctx context.Context, apiId, orgId, keyName var lastError error // Broadcast event to all gateways where API is deployed - for _, deployment := range deployments { - gatewayID := deployment.GatewayID + for _, gateway := range gateways { + gatewayID := gateway.ID log.Printf("[INFO] Broadcasting API key updated event: apiId=%s gatewayId=%s keyName=%s", apiId, gatewayID, keyName) @@ -181,7 +182,7 @@ func (s *APIKeyService) UpdateAPIKey(ctx context.Context, apiId, orgId, keyName // Log summary log.Printf("[INFO] API key update broadcast summary: apiId=%s keyName=%s total=%d success=%d failed=%d", - apiId, keyName, len(deployments), successCount, failureCount) + apiId, keyName, len(gateways), successCount, failureCount) // Return error if all deliveries failed if successCount == 0 { @@ -205,12 +206,12 @@ func (s *APIKeyService) RevokeAPIKey(ctx context.Context, apiId, orgId, keyName } // Get all deployments for this API to find target gateways - deployments, err := s.apiRepo.GetDeploymentsByAPIUUID(apiId, orgId, nil, nil) + gateways, err := s.apiRepo.GetAPIGatewaysWithDetails(apiId, orgId) if err != nil { return fmt.Errorf("failed to get API deployments: %w", err) } - if len(deployments) == 0 { + if len(gateways) == 0 { return constants.ErrGatewayUnavailable } @@ -226,8 +227,8 @@ func (s *APIKeyService) RevokeAPIKey(ctx context.Context, apiId, orgId, keyName var lastError error // Broadcast event to all gateways where API is deployed - for _, deployment := range deployments { - gatewayID := deployment.GatewayID + for _, gateway := range gateways { + gatewayID := gateway.ID log.Printf("[INFO] Broadcasting API key revoked event: apiId=%s gatewayId=%s keyName=%s", apiId, gatewayID, keyName) @@ -248,13 +249,14 @@ func (s *APIKeyService) RevokeAPIKey(ctx context.Context, apiId, orgId, keyName // Log summary log.Printf("[INFO] API key revocation broadcast summary: apiId=%s keyName=%s total=%d success=%d failed=%d", - apiId, keyName, len(deployments), successCount, failureCount) + apiId, keyName, len(gateways), successCount, failureCount) + if failureCount == len(gateways) { + return fmt.Errorf("failed to deliver API key revocation to all gateways: %w", lastError) + } if failureCount > 0 { - log.Printf("[ERROR] Failed to deliver API key revocation to all gateways: apiId=%s keyName=%s failed=%d total=%d", - apiId, keyName, failureCount, len(deployments)) - return fmt.Errorf("failed to deliver API key revocation to %d of %d gateways: %w", - failureCount, len(deployments), lastError) + log.Printf("[WARN] Partial delivery of API key revocation: apiId=%s keyName=%s failureCount=%d total=%d", + apiId, keyName, failureCount, len(gateways)) } return nil diff --git a/platform-api/src/internal/service/gateway_events.go b/platform-api/src/internal/service/gateway_events.go index be76a294f..86fecdc2d 100644 --- a/platform-api/src/internal/service/gateway_events.go +++ b/platform-api/src/internal/service/gateway_events.go @@ -250,7 +250,7 @@ func (s *GatewayEventsService) BroadcastAPIKeyRevokedEvent(gatewayID string, eve var lastError error - // Single attempt delivery for API key events + // Up to 2 attempts (no backoff) for attempt := 0; attempt < maxAttempts; attempt++ { err := s.broadcastAPIKeyRevoked(gatewayID, event) if err == nil { @@ -320,7 +320,7 @@ func (s *GatewayEventsService) broadcastAPIKeyCreated(gatewayID string, event *m } else { successCount++ log.Printf("[INFO] API key created event sent: gatewayID=%s connectionID=%s correlationId=%s keyName=%s", - gatewayID, conn.ConnectionID, correlationID, event.KeyName) + gatewayID, conn.ConnectionID, correlationID, event.Name) conn.DeliveryStats.IncrementTotalSent() } } @@ -416,11 +416,11 @@ func (s *GatewayEventsService) broadcastAPIKeyRevoked(gatewayID string, event *m // - Payload size validation // - Delivery statistics tracking func (s *GatewayEventsService) BroadcastAPIKeyUpdatedEvent(gatewayID string, event *model.APIKeyUpdatedEvent) error { - const maxAttempts = 1 + const maxAttempts = 2 var lastError error - // Single attempt delivery for API key events + // Up to 2 attempts (no backoff) for attempt := 0; attempt < maxAttempts; attempt++ { err := s.broadcastAPIKeyUpdated(gatewayID, event) if err == nil { diff --git a/sdk/gateway/policy/v1alpha/api_key.go b/sdk/gateway/policy/v1alpha/api_key.go index 05faf8b8e..2d9f097e6 100644 --- a/sdk/gateway/policy/v1alpha/api_key.go +++ b/sdk/gateway/policy/v1alpha/api_key.go @@ -127,6 +127,10 @@ func (aks *APIkeyStore) StoreAPIKey(apiId string, apiKey *APIKey) error { if apiKey == nil { return fmt.Errorf("API key cannot be nil") } + // External keys require non-empty IndexKey for fast lookup; fail fast before any writes + if apiKey.Source == "external" && strings.TrimSpace(apiKey.IndexKey) == "" { + return fmt.Errorf("external API key requires non-empty IndexKey for fast lookup") + } aks.mu.Lock() defer aks.mu.Unlock() From 4b1920f5efc18e26f8eac71a0f2d2afe34a16b21 Mon Sep 17 00:00:00 2001 From: Thushani Jayasekera Date: Mon, 2 Feb 2026 23:44:55 +0530 Subject: [PATCH 09/14] Fix test files --- .../gateway/policies/apikey-authentication.md | 2 +- gateway/gateway-controller/go.mod | 2 ++ gateway/gateway-controller/go.sum | 2 ++ .../pkg/api/generated/generated_test.go | 24 ++++++++++++------- .../pkg/api/handlers/handlers_test.go | 14 +++++------ .../controlplane/client_integration_test.go | 4 ++-- .../pkg/controlplane/controlplane_test.go | 6 ++--- .../pkg/storage/sqlite_test.go | 4 ++-- .../pkg/utils/api_key_test.go | 10 ++++---- gateway/policy-engine/Makefile | 1 + gateway/policy-engine/go.mod | 4 +++- sdk/gateway/policy/v1alpha/api_key.go | 16 ++++++------- 12 files changed, 52 insertions(+), 37 deletions(-) diff --git a/docs/gateway/policies/apikey-authentication.md b/docs/gateway/policies/apikey-authentication.md index 1d53ca85e..0d9e1f445 100644 --- a/docs/gateway/policies/apikey-authentication.md +++ b/docs/gateway/policies/apikey-authentication.md @@ -445,7 +445,7 @@ curl -X POST "http://localhost:9090/apis/weather-api-v1.0/api-keys" \ | `api_key.name` | string | Identifier of the generated API key | | `api_key.displayName` | string | Display name of the generated API key | | `api_key.apiId` | string | API identifier | -| `api_key.api_key` | string | The actual API key value (starts with `apip_`) | +| `api_key.api_key` | string | The actual API key value (format may vary) | | `api_key.status` | string | Key status (`active`) | | `api_key.created_at` | string | ISO 8601 timestamp of creation | | `api_key.created_by` | string | User who created the key | diff --git a/gateway/gateway-controller/go.mod b/gateway/gateway-controller/go.mod index ae7ac8e91..11643ffd8 100644 --- a/gateway/gateway-controller/go.mod +++ b/gateway/gateway-controller/go.mod @@ -25,6 +25,7 @@ require ( google.golang.org/grpc v1.78.0 google.golang.org/protobuf v1.36.11 gopkg.in/yaml.v3 v3.0.1 + gotest.tools/v3 v3.5.2 ) require ( @@ -53,6 +54,7 @@ require ( github.com/goccy/go-json v0.10.5 // indirect github.com/goccy/go-yaml v1.19.1 // indirect github.com/golang-jwt/jwt/v5 v5.3.0 // indirect + github.com/google/go-cmp v0.7.0 // indirect github.com/josharian/intern v1.0.0 // indirect github.com/json-iterator/go v1.1.13-0.20220915233716-71ac16282d12 // indirect github.com/klauspost/cpuid/v2 v2.3.0 // indirect diff --git a/gateway/gateway-controller/go.sum b/gateway/gateway-controller/go.sum index 1b4cdf7fa..1bad9a65f 100644 --- a/gateway/gateway-controller/go.sum +++ b/gateway/gateway-controller/go.sum @@ -225,3 +225,5 @@ gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EV gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= +gotest.tools/v3 v3.5.2 h1:7koQfIKdy+I8UTetycgUqXWSDwpgv193Ka+qRsmBY8Q= +gotest.tools/v3 v3.5.2/go.mod h1:LtdLGcnqToBH83WByAAi/wiwSFCArdFIUV/xxN4pcjA= diff --git a/gateway/gateway-controller/pkg/api/generated/generated_test.go b/gateway/gateway-controller/pkg/api/generated/generated_test.go index 599c9f76d..e2aa8ce79 100644 --- a/gateway/gateway-controller/pkg/api/generated/generated_test.go +++ b/gateway/gateway-controller/pkg/api/generated/generated_test.go @@ -209,7 +209,8 @@ type MockServerInterface struct { GetAPIByIdCalled bool UpdateAPICalled bool ListAPIKeysCalled bool - GenerateAPIKeyCalled bool + CreateAPIKeyCalled bool + UpdateAPIKeyCalled bool RevokeAPIKeyCalled bool RegenerateAPIKeyCalled bool ListCertificatesCalled bool @@ -241,6 +242,18 @@ type MockServerInterface struct { ListPoliciesCalled bool } +// CreateAPIKey implements [ServerInterface]. +func (m *MockServerInterface) CreateAPIKey(c *gin.Context, id string) { + m.CreateAPIKeyCalled = true + c.JSON(http.StatusCreated, gin.H{"status": "created"}) +} + +// UpdateAPIKey implements [ServerInterface]. +func (m *MockServerInterface) UpdateAPIKey(c *gin.Context, id string, apiKeyName string) { + m.UpdateAPIKeyCalled = true + c.JSON(http.StatusOK, gin.H{"status": "updated"}) +} + func (m *MockServerInterface) ListAPIs(c *gin.Context, params ListAPIsParams) { m.ListAPIsCalled = true c.JSON(http.StatusOK, gin.H{"status": "ok"}) @@ -271,11 +284,6 @@ func (m *MockServerInterface) ListAPIKeys(c *gin.Context, id string) { c.JSON(http.StatusOK, gin.H{"status": "ok"}) } -func (m *MockServerInterface) GenerateAPIKey(c *gin.Context, id string) { - m.GenerateAPIKeyCalled = true - c.JSON(http.StatusCreated, gin.H{"status": "created"}) -} - func (m *MockServerInterface) RevokeAPIKey(c *gin.Context, id string, apiKeyName string) { m.RevokeAPIKeyCalled = true c.JSON(http.StatusOK, gin.H{"status": "revoked"}) @@ -1670,7 +1678,7 @@ func TestServerInterfaceWrapper_APIKeyRoutes(t *testing.T) { assert.True(t, mockServer.ListAPIKeysCalled) }) - t.Run("GenerateAPIKey", func(t *testing.T) { + t.Run("CreateAPIKey", func(t *testing.T) { router := gin.New() mockServer := &MockServerInterface{} RegisterHandlers(router, mockServer) @@ -1680,7 +1688,7 @@ func TestServerInterfaceWrapper_APIKeyRoutes(t *testing.T) { router.ServeHTTP(w, req) assert.Equal(t, http.StatusCreated, w.Code) - assert.True(t, mockServer.GenerateAPIKeyCalled) + assert.True(t, mockServer.CreateAPIKeyCalled) }) t.Run("RevokeAPIKey", func(t *testing.T) { diff --git a/gateway/gateway-controller/pkg/api/handlers/handlers_test.go b/gateway/gateway-controller/pkg/api/handlers/handlers_test.go index 7d490b070..d3b66a0ff 100644 --- a/gateway/gateway-controller/pkg/api/handlers/handlers_test.go +++ b/gateway/gateway-controller/pkg/api/handlers/handlers_test.go @@ -1086,7 +1086,7 @@ func TestDeleteMCPProxyNotFound(t *testing.T) { t.Skip("Skipping test that requires full deployment service setup") } -// TestGenerateAPIKeyNoAuth tests GenerateAPIKey without authentication +// TestGenerateAPIKeyNoAuth tests CreateAPIKey without authentication func TestGenerateAPIKeyNoAuth(t *testing.T) { server := createTestAPIServer() @@ -1094,12 +1094,12 @@ func TestGenerateAPIKeyNoAuth(t *testing.T) { c, w := createTestContextWithHeader("POST", "/apis/test-handle/api-keys", body, map[string]string{ "Content-Type": "application/json", }) - server.GenerateAPIKey(c, "test-handle") + server.CreateAPIKey(c, "test-handle") assert.Equal(t, http.StatusUnauthorized, w.Code) } -// TestGenerateAPIKeyInvalidAuthContext tests GenerateAPIKey with invalid auth context +// TestGenerateAPIKeyInvalidAuthContext tests CreateAPIKey with invalid auth context func TestGenerateAPIKeyInvalidAuthContext(t *testing.T) { server := createTestAPIServer() @@ -1108,12 +1108,12 @@ func TestGenerateAPIKeyInvalidAuthContext(t *testing.T) { "Content-Type": "application/json", }) c.Set(constants.AuthContextKey, "invalid-context") // Wrong type - server.GenerateAPIKey(c, "test-handle") + server.CreateAPIKey(c, "test-handle") assert.Equal(t, http.StatusInternalServerError, w.Code) } -// TestGenerateAPIKeyInvalidBody tests GenerateAPIKey with invalid body +// TestGenerateAPIKeyInvalidBody tests CreateAPIKey with invalid body func TestGenerateAPIKeyInvalidBody(t *testing.T) { server := createTestAPIServer() @@ -1124,7 +1124,7 @@ func TestGenerateAPIKeyInvalidBody(t *testing.T) { UserID: "test-user", Roles: []string{"admin"}, }) - server.GenerateAPIKey(c, "test-handle") + server.CreateAPIKey(c, "test-handle") assert.Equal(t, http.StatusBadRequest, w.Code) } @@ -2119,7 +2119,7 @@ func TestAPIKeyServiceNotConfigured(t *testing.T) { panicked = true } }() - server.GenerateAPIKey(c, "test-handle") + server.CreateAPIKey(c, "test-handle") }() if !panicked { assert.True(t, w.Code >= http.StatusBadRequest) diff --git a/gateway/gateway-controller/pkg/controlplane/client_integration_test.go b/gateway/gateway-controller/pkg/controlplane/client_integration_test.go index a626c4417..19dac9ba3 100644 --- a/gateway/gateway-controller/pkg/controlplane/client_integration_test.go +++ b/gateway/gateway-controller/pkg/controlplane/client_integration_test.go @@ -73,7 +73,7 @@ func createTestClientWithHost(t *testing.T, host string) *Client { }, } - return NewClient(cfg, logger, store, nil, nil, nil, routerConfig) + return NewClient(cfg, logger, store, nil, nil, nil, routerConfig, nil, nil) } func TestClient_ConnectToMockServer(t *testing.T) { @@ -217,7 +217,7 @@ func TestClient_calculateNextRetryDelay_EdgeCases(t *testing.T) { ReconnectMax: 10 * time.Millisecond, } routerConfig := &config.RouterConfig{} - client := NewClient(cfg, logger, store, nil, nil, nil, routerConfig) + client := NewClient(cfg, logger, store, nil, nil, nil, routerConfig, nil, nil) // Test multiple retries for i := 0; i < 20; i++ { diff --git a/gateway/gateway-controller/pkg/controlplane/controlplane_test.go b/gateway/gateway-controller/pkg/controlplane/controlplane_test.go index 65d91e0be..ff9fbea1f 100644 --- a/gateway/gateway-controller/pkg/controlplane/controlplane_test.go +++ b/gateway/gateway-controller/pkg/controlplane/controlplane_test.go @@ -159,7 +159,7 @@ func createTestClient(t *testing.T) *Client { }, } - return NewClient(cfg, logger, store, nil, nil, nil, routerConfig) + return NewClient(cfg, logger, store, nil, nil, nil, routerConfig, nil, nil) } func TestNewClient(t *testing.T) { @@ -264,7 +264,7 @@ func TestClient_isShuttingDown_ContextCancelled(t *testing.T) { } routerConfig := &config.RouterConfig{} - client := NewClient(cfg, logger, store, nil, nil, nil, routerConfig) + client := NewClient(cfg, logger, store, nil, nil, nil, routerConfig, nil, nil) // Cancel context client.cancel() @@ -325,7 +325,7 @@ func TestClient_Start_NoToken(t *testing.T) { } routerConfig := &config.RouterConfig{} - client := NewClient(cfg, logger, store, nil, nil, nil, routerConfig) + client := NewClient(cfg, logger, store, nil, nil, nil, routerConfig, nil, nil) // Start should return nil and not attempt connection when no token err := client.Start() diff --git a/gateway/gateway-controller/pkg/storage/sqlite_test.go b/gateway/gateway-controller/pkg/storage/sqlite_test.go index ddb41ba02..048124863 100644 --- a/gateway/gateway-controller/pkg/storage/sqlite_test.go +++ b/gateway/gateway-controller/pkg/storage/sqlite_test.go @@ -74,7 +74,7 @@ func TestSQLiteStorage_SchemaInitialization(t *testing.T) { var version int err = storage.db.QueryRow("PRAGMA user_version").Scan(&version) assert.NilError(t, err) - assert.Equal(t, version, 5) // Current schema version + assert.Equal(t, version, 6) // Current schema version // Verify tables exist tables := []string{ @@ -119,7 +119,7 @@ func TestSQLiteStorage_SchemaVersionUpgrade(t *testing.T) { var version int err = storage.db.QueryRow("PRAGMA user_version").Scan(&version) assert.NilError(t, err) - assert.Equal(t, version, 5) + assert.Equal(t, version, 6) } func TestSQLiteStorage_DeleteConfig_NotFound(t *testing.T) { diff --git a/gateway/gateway-controller/pkg/utils/api_key_test.go b/gateway/gateway-controller/pkg/utils/api_key_test.go index 947ff226d..a7de92455 100644 --- a/gateway/gateway-controller/pkg/utils/api_key_test.go +++ b/gateway/gateway-controller/pkg/utils/api_key_test.go @@ -55,9 +55,9 @@ func TestAPIKeyGenerationParams(t *testing.T) { Roles: []string{"developer"}, } - params := APIKeyGenerationParams{ + params := APIKeyCreationParams{ Handle: "test-api-handle", - Request: api.APIKeyGenerationRequest{}, + Request: api.APIKeyCreationRequest{}, User: user, CorrelationID: "corr-123", Logger: logger, @@ -497,7 +497,7 @@ func TestBuildAPIKeyResponse(t *testing.T) { } t.Run("Nil API key returns error response", func(t *testing.T) { - response := service.buildAPIKeyResponse(nil, "test-handle", "") + response := service.buildAPIKeyResponse(nil, "test-handle", "", false) assert.Equal(t, "error", response.Status) assert.Equal(t, "API key is nil", response.Message) }) @@ -515,7 +515,7 @@ func TestBuildAPIKeyResponse(t *testing.T) { } plainKey := "apip_plain123456789" - response := service.buildAPIKeyResponse(apiKey, "test-handle", plainKey) + response := service.buildAPIKeyResponse(apiKey, "test-handle", plainKey, false) assert.Equal(t, "success", response.Status) assert.NotNil(t, response.ApiKey) assert.Equal(t, "my-test-key", response.ApiKey.Name) @@ -533,7 +533,7 @@ func TestBuildAPIKeyResponse(t *testing.T) { CreatedBy: "test-user", } - response := service.buildAPIKeyResponse(apiKey, "test-handle", "") + response := service.buildAPIKeyResponse(apiKey, "test-handle", "", false) assert.Equal(t, "success", response.Status) assert.NotNil(t, response.ApiKey) assert.Nil(t, response.ApiKey.ApiKey) // Should not expose hashed key diff --git a/gateway/policy-engine/Makefile b/gateway/policy-engine/Makefile index 66e6011dc..f962d9ec1 100644 --- a/gateway/policy-engine/Makefile +++ b/gateway/policy-engine/Makefile @@ -119,6 +119,7 @@ build-coverage-image: @echo "Building Policy Engine coverage image: $(RUNTIME_IMAGE)-coverage:$(RUNTIME_VERSION)" DOCKER_BUILDKIT=1 docker build -f Dockerfile \ --build-context sdk=../../sdk \ + --build-context common=../../common \ --build-context gateway-builder=../gateway-builder \ --build-context policy-engine=. \ --build-context policies=../policies \ diff --git a/gateway/policy-engine/go.mod b/gateway/policy-engine/go.mod index 0c7d49cfa..c42fbcaac 100644 --- a/gateway/policy-engine/go.mod +++ b/gateway/policy-engine/go.mod @@ -13,7 +13,7 @@ require ( github.com/knadh/koanf/v2 v2.3.0 github.com/moesif/moesifapi-go v1.1.5 github.com/prometheus/client_golang v1.23.2 - github.com/wso2/api-platform/common v0.0.0 + github.com/stretchr/testify v1.11.1 github.com/wso2/api-platform/sdk v0.3.1 go.opentelemetry.io/otel v1.39.0 go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracegrpc v1.39.0 @@ -31,6 +31,7 @@ require ( github.com/cenkalti/backoff/v5 v5.0.3 // indirect github.com/cespare/xxhash/v2 v2.3.0 // indirect github.com/cncf/xds/go v0.0.0-20251022180443-0feb69152e9f // indirect + github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc // indirect github.com/envoyproxy/protoc-gen-validate v1.2.1 // indirect github.com/fsnotify/fsnotify v1.9.0 // indirect github.com/go-logr/logr v1.4.3 // indirect @@ -42,6 +43,7 @@ require ( github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 // indirect github.com/pelletier/go-toml/v2 v2.2.4 // indirect github.com/planetscale/vtprotobuf v0.6.1-0.20240319094008-0393e58bdf10 // indirect + github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 // indirect github.com/prometheus/client_model v0.6.2 // indirect github.com/prometheus/common v0.66.1 // indirect github.com/prometheus/procfs v0.16.1 // indirect diff --git a/sdk/gateway/policy/v1alpha/api_key.go b/sdk/gateway/policy/v1alpha/api_key.go index 2d9f097e6..210abb0ae 100644 --- a/sdk/gateway/policy/v1alpha/api_key.go +++ b/sdk/gateway/policy/v1alpha/api_key.go @@ -26,6 +26,7 @@ import ( "encoding/json" "errors" "fmt" + "log" "strings" "sync" "time" @@ -149,19 +150,18 @@ func (aks *APIkeyStore) StoreAPIKey(apiId string, apiKey *APIKey) error { } if existingKeyID != "" { - // Remove old external key index entry if it exists + // Remove old external key index entry if it exists (use IndexKey only; do not compute from APIKey for external keys—APIKey may be hashed) oldKey := aks.apiKeysByAPI[apiId][existingKeyID] if oldKey != nil && oldKey.Source == "external" { - var oldIndexKey string if oldKey.IndexKey != "" { - oldIndexKey = oldKey.IndexKey - } else { - oldIndexKey = computeExternalKeyIndexKey(oldKey.APIKey) - if oldIndexKey == "" { - return fmt.Errorf("failed to compute index key") + if aks.externalKeyIndex[apiId] != nil { + delete(aks.externalKeyIndex[apiId], oldKey.IndexKey) } + } else { + // Legacy external key with hashed APIKey and no IndexKey; cannot compute index from hash—skip delete to avoid removing wrong entry + log.Printf("[WARN] legacy external API key missing IndexKey during replace: apiId=%s existingKeyID=%s (index entry not removed; consider re-storing key with IndexKey set for cleanup)", + apiId, existingKeyID) } - delete(aks.externalKeyIndex[apiId], oldIndexKey) } // Update the existing entry in apiKeysByAPI From 3b3a9404215eafbec3c32d87604b94cb36451e3e Mon Sep 17 00:00:00 2001 From: Thushani Jayasekera Date: Tue, 3 Feb 2026 09:48:11 +0530 Subject: [PATCH 10/14] Enhance API key handling by updating --- .../pkg/controlplane/client.go | 4 +-- sdk/gateway/policy/v1alpha/api_key.go | 25 ++++++++----------- 2 files changed, 12 insertions(+), 17 deletions(-) diff --git a/gateway/gateway-controller/pkg/controlplane/client.go b/gateway/gateway-controller/pkg/controlplane/client.go index 02949e5f4..1e75ffa5e 100644 --- a/gateway/gateway-controller/pkg/controlplane/client.go +++ b/gateway/gateway-controller/pkg/controlplane/client.go @@ -682,8 +682,8 @@ func (c *Client) handleAPIKeyCreatedEvent(event map[string]interface{}) { } } - // Validate DisplayName - required field - if strings.TrimSpace(*keyCreatedEvent.Payload.DisplayName) != "" { + // Validate DisplayName - optional field (pointer may be nil) + if keyCreatedEvent.Payload.DisplayName != nil && strings.TrimSpace(*keyCreatedEvent.Payload.DisplayName) != "" { // Validate the display name format if err := utils.ValidateDisplayName(*keyCreatedEvent.Payload.DisplayName); err != nil { baseLogger.Error("API key created event has invalid display_name", diff --git a/sdk/gateway/policy/v1alpha/api_key.go b/sdk/gateway/policy/v1alpha/api_key.go index 210abb0ae..6a0d9b6ae 100644 --- a/sdk/gateway/policy/v1alpha/api_key.go +++ b/sdk/gateway/policy/v1alpha/api_key.go @@ -305,9 +305,10 @@ func (aks *APIkeyStore) RevokeAPIKey(apiId, providedAPIKey string) error { // If not found via local key lookup, try external key index for O(1) lookup if matchedKey == nil { indexKey := computeExternalKeyIndexKey(providedAPIKey) + trimmedAPIKey := strings.TrimSpace(providedAPIKey) if keyID, exists := aks.externalKeyIndex[apiId][indexKey]; exists { if apiKey, ok := aks.apiKeysByAPI[apiId][*keyID]; ok { - if apiKey.Source == "external" && compareAPIKeys(providedAPIKey, apiKey.APIKey) { + if apiKey.Source == "external" && compareAPIKeys(trimmedAPIKey, apiKey.APIKey) { matchedKey = apiKey } } @@ -340,16 +341,13 @@ func (aks *APIkeyStore) RemoveAPIKeysByAPI(apiId string) error { // Remove from external key index for _, apiKey := range apiKeys { if apiKey.Source == "external" { - var indexKey string if apiKey.IndexKey != "" { - indexKey = apiKey.IndexKey + delete(aks.externalKeyIndex[apiKey.APIId], apiKey.IndexKey) } else { - indexKey = computeExternalKeyIndexKey(apiKey.APIKey) - if indexKey == "" { - return fmt.Errorf("failed to compute index key") - } + // Legacy external key with hashed APIKey and no IndexKey; cannot compute index from hash—skip delete to avoid removing wrong entry + log.Printf("[WARN] legacy external API key missing IndexKey during RemoveAPIKeysByAPI: apiId=%s keyID=%s (index entry not removed; consider re-storing key with IndexKey set for cleanup)", + apiKey.APIId, apiKey.ID) } - delete(aks.externalKeyIndex[apiKey.APIId], indexKey) } } @@ -554,15 +552,12 @@ func (aks *APIkeyStore) removeFromAPIMapping(apiKey *APIKey) { if aks.externalKeyIndex[apiKey.APIId] == nil { return } - var indexKey string if apiKey.IndexKey != "" { - indexKey = apiKey.IndexKey + delete(aks.externalKeyIndex[apiKey.APIId], apiKey.IndexKey) } else { - indexKey = computeExternalKeyIndexKey(apiKey.APIKey) - if indexKey == "" { - return - } + // Legacy external key with hashed APIKey and no IndexKey; cannot compute index from hash—skip delete to avoid removing wrong entry + log.Printf("[WARN] legacy external API key missing IndexKey during removeFromAPIMapping: apiId=%s keyID=%s (index entry not removed; consider re-storing key with IndexKey set for cleanup)", + apiKey.APIId, apiKey.ID) } - delete(aks.externalKeyIndex[apiKey.APIId], indexKey) } } From 681dbf4fd7fda635a96d13cb7cf30882edd26bb3 Mon Sep 17 00:00:00 2001 From: Thushani Jayasekera Date: Thu, 5 Feb 2026 11:17:13 +0530 Subject: [PATCH 11/14] Fix openapi and add configurable params for apikey and apikey name lengrh --- common/apikey/store.go | 14 +- gateway/gateway-controller/api/openapi.yaml | 40 +- .../pkg/api/generated/generated.go | 360 +++++++++--------- .../gateway-controller/pkg/config/config.go | 17 + .../pkg/constants/constants.go | 9 +- .../gateway-controller/pkg/models/api_key.go | 18 +- .../gateway-controller/pkg/utils/api_key.go | 8 +- .../pkg/utils/api_key_validation.go | 74 ++-- gateway/policies/policy-manifest.yaml | 2 +- platform-api/src/internal/dto/apikey.go | 14 +- sdk/gateway/policy/v1alpha/api_key.go | 14 +- sdk/gateway/policyengine/v1/api_key_xds.go | 24 +- 12 files changed, 323 insertions(+), 271 deletions(-) diff --git a/common/apikey/store.go b/common/apikey/store.go index a9142af50..646776210 100644 --- a/common/apikey/store.go +++ b/common/apikey/store.go @@ -40,9 +40,9 @@ type APIKey struct { // Name of the API key (URL-safe identifier, auto-generated, immutable) Name string `json:"name" yaml:"name"` // DisplayName is the human-readable name (user-provided, mutable) - DisplayName string `json:"display_name" yaml:"display_name"` + DisplayName string `json:"displayName" yaml:"displayName"` // ApiKey API key with apip_ prefix - APIKey string `json:"api_key" yaml:"api_key"` + APIKey string `json:"apiKey" yaml:"apiKey"` // APIId Unique identifier of the API that the key is associated with APIId string `json:"apiId" yaml:"apiId"` // Operations List of API operations the key will have access to @@ -50,17 +50,17 @@ type APIKey struct { // Status of the API key Status APIKeyStatus `json:"status" yaml:"status"` // CreatedAt Timestamp when the API key was generated - CreatedAt time.Time `json:"created_at" yaml:"created_at"` + CreatedAt time.Time `json:"createdAt" yaml:"createdAt"` // CreatedBy User who created the API key - CreatedBy string `json:"created_by" yaml:"created_by"` + CreatedBy string `json:"createdBy" yaml:"createdBy"` // UpdatedAt Timestamp when the API key was last updated - UpdatedAt time.Time `json:"updated_at" yaml:"updated_at"` + UpdatedAt time.Time `json:"updatedAt" yaml:"updatedAt"` // ExpiresAt Expiration timestamp (null if no expiration) - ExpiresAt *time.Time `json:"expires_at" yaml:"expires_at"` + ExpiresAt *time.Time `json:"expiresAt" yaml:"expiresAt"` // Source tracking for external key support ("local" | "external") Source string `json:"source" yaml:"source"` // IndexKey Pre-computed hash for O(1) lookup (external plain text keys only) - IndexKey string `json:"index_key" yaml:"index_key"` + IndexKey string `json:"indexKey" yaml:"indexKey"` } // APIKeyStatus Status of the API key diff --git a/gateway/gateway-controller/api/openapi.yaml b/gateway/gateway-controller/api/openapi.yaml index 0374d4c61..066222d0b 100644 --- a/gateway/gateway-controller/api/openapi.yaml +++ b/gateway/gateway-controller/api/openapi.yaml @@ -2307,17 +2307,17 @@ components: minLength: 3 maxLength: 63 example: my-production-key - api_key: + apiKey: type: string - minLength: 20 + minLength: 36 description: | Optional plain-text API key value for external key injection. If provided, this key will be used instead of generating a new one. The key will be hashed before storage. The key can be in any format - (minimum 20 characters). Use this for injecting externally generated + (minimum 36 characters). Use this for injecting externally generated API keys. example: "cloud-apim-key-abc123xyz789" - expires_in: + expiresIn: type: object description: Expiration duration for the API key properties: @@ -2339,12 +2339,12 @@ components: required: - unit - duration - expires_at: + expiresAt: type: string format: date-time - description: Expiration timestamp. If both expires_in and expires_at are provided, expires_at takes precedence. + description: Expiration timestamp. If both expiresIn and expiresAt are provided, expiresAt takes precedence. example: "2026-12-08T10:30:00Z" - external_ref_id: + externalRefId: type: string description: | External reference ID for the API key. @@ -2360,11 +2360,11 @@ components: message: type: string example: API key generated successfully - remaining_api_key_quota: + remainingApiKeyQuota: type: integer description: Remaining API key quota for the user example: 9 - api_key: + apiKey: $ref: '#/components/schemas/APIKey' required: - status @@ -2386,7 +2386,7 @@ components: minLength: 1 maxLength: 100 example: My Production Key - api_key: + apiKey: type: string description: Generated API key with apip_ prefix example: "apip_1234567890abcdef1234567890abcdef1234567890abcdef1234567890abcdef" @@ -2406,16 +2406,16 @@ components: - revoked - expired example: active - created_at: + createdAt: type: string format: date-time description: Timestamp when the API key was generated example: "2025-12-08T10:30:00Z" - created_by: + createdBy: type: string description: Identifier of the user who generated the API key example: api_consumer - expires_at: + expiresAt: type: string format: date-time nullable: true @@ -2428,7 +2428,7 @@ components: - local - external example: local - external_ref_id: + externalRefId: type: string description: External reference ID for the API key example: "cloud-apim-key-98765" @@ -2437,14 +2437,14 @@ components: - apiId - operations - status - - created_at - - created_by - - expires_at + - createdAt + - createdBy + - expiresAt - source APIKeyRegenerationRequest: type: object properties: - expires_in: + expiresIn: type: object description: Expiration duration for the API key properties: @@ -2466,7 +2466,7 @@ components: required: - unit - duration - expires_at: + expiresAt: type: string format: date-time description: Expiration timestamp @@ -2475,7 +2475,7 @@ components: allOf: - $ref: "#/components/schemas/APIKeyCreationRequest" required: - - api_key + - apiKey APIKeyRevocationResponse: type: object properties: diff --git a/gateway/gateway-controller/pkg/api/generated/generated.go b/gateway/gateway-controller/pkg/api/generated/generated.go index cdca7d8c2..42b76327a 100644 --- a/gateway/gateway-controller/pkg/api/generated/generated.go +++ b/gateway/gateway-controller/pkg/api/generated/generated.go @@ -385,22 +385,22 @@ type APIKey struct { ApiId string `json:"apiId" yaml:"apiId"` // ApiKey Generated API key with apip_ prefix - ApiKey *string `json:"api_key,omitempty" yaml:"api_key,omitempty"` + ApiKey *string `json:"apiKey,omitempty" yaml:"apiKey,omitempty"` // CreatedAt Timestamp when the API key was generated - CreatedAt time.Time `json:"created_at" yaml:"created_at"` + CreatedAt time.Time `json:"createdAt" yaml:"createdAt"` // CreatedBy Identifier of the user who generated the API key - CreatedBy string `json:"created_by" yaml:"created_by"` + CreatedBy string `json:"createdBy" yaml:"createdBy"` // DisplayName Human-readable name for the API key (user-provided, mutable) DisplayName *string `json:"displayName,omitempty" yaml:"displayName,omitempty"` // ExpiresAt Expiration timestamp (null if no expiration) - ExpiresAt *time.Time `json:"expires_at" yaml:"expires_at"` + ExpiresAt *time.Time `json:"expiresAt" yaml:"expiresAt"` // ExternalRefId External reference ID for the API key - ExternalRefId *string `json:"external_ref_id,omitempty" yaml:"external_ref_id,omitempty"` + ExternalRefId *string `json:"externalRefId,omitempty" yaml:"externalRefId,omitempty"` // Name URL-safe identifier for the API key (auto-generated from displayName, immutable, used as path parameter) Name string `json:"name" yaml:"name"` @@ -426,15 +426,15 @@ type APIKeyCreationRequest struct { // ApiKey Optional plain-text API key value for external key injection. // If provided, this key will be used instead of generating a new one. // The key will be hashed before storage. The key can be in any format - // (minimum 20 characters). Use this for injecting externally generated + // (minimum 36 characters). Use this for injecting externally generated // API keys. - ApiKey *string `json:"api_key,omitempty" yaml:"api_key,omitempty"` + ApiKey *string `json:"apiKey,omitempty" yaml:"apiKey,omitempty"` // DisplayName Human-readable name for the API key (1-100 characters) DisplayName *string `json:"displayName,omitempty" yaml:"displayName,omitempty"` - // ExpiresAt Expiration timestamp. If both expires_in and expires_at are provided, expires_at takes precedence. - ExpiresAt *time.Time `json:"expires_at,omitempty" yaml:"expires_at,omitempty"` + // ExpiresAt Expiration timestamp. If both expiresIn and expiresAt are provided, expiresAt takes precedence. + ExpiresAt *time.Time `json:"expiresAt,omitempty" yaml:"expiresAt,omitempty"` // ExpiresIn Expiration duration for the API key ExpiresIn *struct { @@ -443,12 +443,12 @@ type APIKeyCreationRequest struct { // Unit Time unit for expiration Unit APIKeyCreationRequestExpiresInUnit `json:"unit" yaml:"unit"` - } `json:"expires_in,omitempty" yaml:"expires_in,omitempty"` + } `json:"expiresIn,omitempty" yaml:"expiresIn,omitempty"` // ExternalRefId External reference ID for the API key. // This field is optional and used for tracing purposes only. // The gateway generates its own internal ID for tracking. - ExternalRefId *string `json:"external_ref_id,omitempty" yaml:"external_ref_id,omitempty"` + ExternalRefId *string `json:"externalRefId,omitempty" yaml:"externalRefId,omitempty"` // Name Identifier of the API key. If not provided, a default identifier will be generated Name *string `json:"name,omitempty" yaml:"name,omitempty"` @@ -460,11 +460,11 @@ type APIKeyCreationRequestExpiresInUnit string // APIKeyCreationResponse defines model for APIKeyCreationResponse. type APIKeyCreationResponse struct { // ApiKey Details of an API key - ApiKey *APIKey `json:"api_key,omitempty" yaml:"api_key,omitempty"` + ApiKey *APIKey `json:"apiKey,omitempty" yaml:"apiKey,omitempty"` Message string `json:"message" yaml:"message"` // RemainingApiKeyQuota Remaining API key quota for the user - RemainingApiKeyQuota *int `json:"remaining_api_key_quota,omitempty" yaml:"remaining_api_key_quota,omitempty"` + RemainingApiKeyQuota *int `json:"remainingApiKeyQuota,omitempty" yaml:"remainingApiKeyQuota,omitempty"` Status string `json:"status" yaml:"status"` } @@ -480,7 +480,7 @@ type APIKeyListResponse struct { // APIKeyRegenerationRequest defines model for APIKeyRegenerationRequest. type APIKeyRegenerationRequest struct { // ExpiresAt Expiration timestamp - ExpiresAt *time.Time `json:"expires_at,omitempty" yaml:"expires_at,omitempty"` + ExpiresAt *time.Time `json:"expiresAt,omitempty" yaml:"expiresAt,omitempty"` // ExpiresIn Expiration duration for the API key ExpiresIn *struct { @@ -489,7 +489,7 @@ type APIKeyRegenerationRequest struct { // Unit Time unit for expiration Unit APIKeyRegenerationRequestExpiresInUnit `json:"unit" yaml:"unit"` - } `json:"expires_in,omitempty" yaml:"expires_in,omitempty"` + } `json:"expiresIn,omitempty" yaml:"expiresIn,omitempty"` } // APIKeyRegenerationRequestExpiresInUnit Time unit for expiration @@ -3028,171 +3028,171 @@ var swaggerSpec = []string{ "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", } // GetSwagger returns the content of the embedded swagger specification file diff --git a/gateway/gateway-controller/pkg/config/config.go b/gateway/gateway-controller/pkg/config/config.go index 951e8c25b..b3afa8881 100644 --- a/gateway/gateway-controller/pkg/config/config.go +++ b/gateway/gateway-controller/pkg/config/config.go @@ -343,6 +343,8 @@ type ControlPlaneConfig struct { type APIKeyConfig struct { APIKeysPerUserPerAPI int `koanf:"api_keys_per_user_per_api"` // Number of API keys allowed per user per API Algorithm string `koanf:"algorithm"` // Hashing algorithm to use + MinKeyLength int `koanf:"min_key_length"` // Minimum length for external API key values + MaxKeyLength int `koanf:"max_key_length"` // Maximum length for external API key values } // LoadConfig loads configuration from file, environment variables, and defaults @@ -564,6 +566,8 @@ func defaultConfig() *Config { APIKey: APIKeyConfig{ APIKeysPerUserPerAPI: 10, Algorithm: constants.HashingAlgorithmSHA256, + MinKeyLength: constants.DefaultMinAPIKeyLength, + MaxKeyLength: constants.DefaultMaxAPIKeyLength, }, }, Analytics: AnalyticsConfig{ @@ -1217,6 +1221,19 @@ func (c *Config) validateAPIKeyConfig() error { return fmt.Errorf("api_key.api_keys_per_user_per_api must be a positive integer, got: %d", c.GatewayController.APIKey.APIKeysPerUserPerAPI) } + + // Default min/max key lengths if not configured + if c.GatewayController.APIKey.MinKeyLength <= 0 { + c.GatewayController.APIKey.MinKeyLength = constants.DefaultMinAPIKeyLength + } + if c.GatewayController.APIKey.MaxKeyLength <= 0 { + c.GatewayController.APIKey.MaxKeyLength = constants.DefaultMaxAPIKeyLength + } + if c.GatewayController.APIKey.MinKeyLength > c.GatewayController.APIKey.MaxKeyLength { + return fmt.Errorf("api_key.min_key_length (%d) must not exceed api_key.max_key_length (%d)", + c.GatewayController.APIKey.MinKeyLength, c.GatewayController.APIKey.MaxKeyLength) + } + // If hashing is enabled but no algorithm is provided, default to SHA256 if c.GatewayController.APIKey.Algorithm == "" { c.GatewayController.APIKey.Algorithm = constants.HashingAlgorithmSHA256 diff --git a/gateway/gateway-controller/pkg/constants/constants.go b/gateway/gateway-controller/pkg/constants/constants.go index 0bb306bc3..45e42e236 100644 --- a/gateway/gateway-controller/pkg/constants/constants.go +++ b/gateway/gateway-controller/pkg/constants/constants.go @@ -139,8 +139,13 @@ const ( APIKeySeparator = "_" // API Key length constants - MIN_API_KEY_LENGTH = 20 - MAX_API_KEY_LENGTH = 128 + DefaultMinAPIKeyLength = 36 + DefaultMaxAPIKeyLength = 128 + + // API Key name and display name length constants + APIKeyNameMinLength = 3 + APIKeyNameMaxLength = 63 + DisplayNameMaxLength = 100 // HashingAlgorithm constants HashingAlgorithmSHA256 = "sha256" diff --git a/gateway/gateway-controller/pkg/models/api_key.go b/gateway/gateway-controller/pkg/models/api_key.go index 02ce7d657..e3cae0f43 100644 --- a/gateway/gateway-controller/pkg/models/api_key.go +++ b/gateway/gateway-controller/pkg/models/api_key.go @@ -35,24 +35,24 @@ const ( type APIKey struct { ID string `json:"id" db:"id"` Name string `json:"name" db:"name"` // URL-safe identifier (auto-generated, immutable) - DisplayName string `json:"display_name" db:"display_name"` // Human-readable name (user-provided, mutable) - APIKey string `json:"api_key" db:"api_key"` // Stores hashed API key - MaskedAPIKey string `json:"masked_api_key" db:"masked_api_key"` // Stores masked API key for display + DisplayName string `json:"displayName" db:"display_name"` // Human-readable name (user-provided, mutable) + APIKey string `json:"apiKey" db:"api_key"` // Stores hashed API key + MaskedAPIKey string `json:"maskedApiKey" db:"masked_api_key"` // Stores masked API key for display PlainAPIKey string `json:"-" db:"-"` // Temporary field for plain API key (not persisted) APIId string `json:"apiId" db:"apiId"` Operations string `json:"operations" db:"operations"` Status APIKeyStatus `json:"status" db:"status"` - CreatedAt time.Time `json:"created_at" db:"created_at"` - CreatedBy string `json:"created_by" db:"created_by"` - UpdatedAt time.Time `json:"updated_at" db:"updated_at"` - ExpiresAt *time.Time `json:"expires_at" db:"expires_at"` + CreatedAt time.Time `json:"createdAt" db:"created_at"` + CreatedBy string `json:"createdBy" db:"created_by"` + UpdatedAt time.Time `json:"updatedAt" db:"updated_at"` + ExpiresAt *time.Time `json:"expiresAt" db:"expires_at"` Unit *string `json:"-" db:"expires_in_unit"` Duration *int `json:"-" db:"expires_in_duration"` // Source tracking for external key support Source string `json:"source" db:"source"` // "local" | "external" - ExternalRefId *string `json:"external_ref_id" db:"external_ref_id"` // Cloud APIM key ID or other external reference - IndexKey *string `json:"index_key" db:"index_key"` // Pre-computed SHA-256 hash for O(1) lookup (external plain text keys only) + ExternalRefId *string `json:"externalRefId" db:"external_ref_id"` // Cloud APIM key ID or other external reference + IndexKey *string `json:"indexKey" db:"index_key"` // Pre-computed SHA-256 hash for O(1) lookup (external plain text keys only) } // IsValid checks if the API key is valid (active and not expired) diff --git a/gateway/gateway-controller/pkg/utils/api_key.go b/gateway/gateway-controller/pkg/utils/api_key.go index 3a37f5477..3b5c87983 100644 --- a/gateway/gateway-controller/pkg/utils/api_key.go +++ b/gateway/gateway-controller/pkg/utils/api_key.go @@ -912,7 +912,7 @@ func (s *APIKeyService) createAPIKeyFromRequest(handle string, request *api.APIK if request.ApiKey != nil { // External key injection: use provided key AS-IS providedKey := strings.TrimSpace(*request.ApiKey) - if err := ValidateAPIKeyValue(providedKey); err != nil { + if err := s.ValidateAPIKeyValue(providedKey); err != nil { return nil, err } // Use the key as-is - we don't dictate format for external keys @@ -1163,7 +1163,7 @@ func (s *APIKeyService) updateAPIKeyFromRequest(existingKey *models.APIKey, requ } plainAPIKeyValue := strings.TrimSpace(*request.ApiKey) - if err := ValidateAPIKeyValue(plainAPIKeyValue); err != nil { + if err := s.ValidateAPIKeyValue(plainAPIKeyValue); err != nil { return nil, fmt.Errorf("invalid API key value: %w", err) } @@ -1915,9 +1915,9 @@ func (s *APIKeyService) generateUniqueAPIKeyName(apiId, displayName string, maxR uniqueName := baseName + "-" + suffix // Enforce max length (name field is typically 63 chars max) - if len(uniqueName) > apiKeyNameMaxLength { + if len(uniqueName) > constants.APIKeyNameMaxLength { // Truncate base name to make room for suffix - truncatedBase := baseName[:apiKeyNameMaxLength-len(suffix)-1] + truncatedBase := baseName[:constants.APIKeyNameMaxLength-len(suffix)-1] uniqueName = truncatedBase + "-" + suffix } diff --git a/gateway/gateway-controller/pkg/utils/api_key_validation.go b/gateway/gateway-controller/pkg/utils/api_key_validation.go index e90bba9d6..b8b5c8a7c 100644 --- a/gateway/gateway-controller/pkg/utils/api_key_validation.go +++ b/gateway/gateway-controller/pkg/utils/api_key_validation.go @@ -19,6 +19,8 @@ package utils import ( + "crypto/rand" + "encoding/hex" "fmt" "regexp" "strings" @@ -27,12 +29,6 @@ import ( "github.com/wso2/api-platform/gateway/gateway-controller/pkg/constants" ) -const ( - apiKeyNameMinLength = 3 - apiKeyNameMaxLength = 63 - displayNameMaxLength = 100 -) - var ( // validAPIKeyNameRegex matches lowercase alphanumeric with hyphens (not at start/end, no consecutive) validAPIKeyNameRegex = regexp.MustCompile(`^[a-z0-9]+(-[a-z0-9]+)*$`) @@ -44,17 +40,28 @@ var ( // ValidateAPIKeyValue validates a plain API key value for creation or update. // Use this for both REST create/update and external events (apikey.created, apikey.updated). +// Min and max length are read from the service's APIKeyConfig; defaults are used if not configured. // Returns a descriptive error if the key is empty, too short, or too long. // Note: Expects the caller to trim whitespace before validation. -func ValidateAPIKeyValue(plainKey string) error { +func (s *APIKeyService) ValidateAPIKeyValue(plainKey string) error { + minLength := constants.DefaultMinAPIKeyLength + maxLength := constants.DefaultMaxAPIKeyLength + if s.apiKeyConfig != nil { + if s.apiKeyConfig.MinKeyLength > 0 { + minLength = s.apiKeyConfig.MinKeyLength + } + if s.apiKeyConfig.MaxKeyLength > 0 { + maxLength = s.apiKeyConfig.MaxKeyLength + } + } if plainKey == "" { return fmt.Errorf("API key cannot be empty") } - if len(plainKey) < constants.MIN_API_KEY_LENGTH { - return fmt.Errorf("API key is too short (minimum %d characters required)", constants.MIN_API_KEY_LENGTH) + if len(plainKey) < minLength { + return fmt.Errorf("API key is too short (minimum %d characters required)", minLength) } - if len(plainKey) > constants.MAX_API_KEY_LENGTH { - return fmt.Errorf("API key is too long (maximum %d characters allowed)", constants.MAX_API_KEY_LENGTH) + if len(plainKey) > maxLength { + return fmt.Errorf("API key is too long (maximum %d characters allowed)", maxLength) } return nil } @@ -69,8 +76,8 @@ func ValidateDisplayName(displayName string) error { } runeCount := utf8.RuneCountInString(trimmed) - if runeCount > displayNameMaxLength { - return fmt.Errorf("display name is too long (%d characters, maximum %d allowed)", runeCount, displayNameMaxLength) + if runeCount > constants.DisplayNameMaxLength { + return fmt.Errorf("display name is too long (%d characters, maximum %d allowed)", runeCount, constants.DisplayNameMaxLength) } return nil } @@ -87,11 +94,11 @@ func ValidateAPIKeyName(name string) error { if name == "" { return fmt.Errorf("API key name cannot be empty") } - if len(name) < apiKeyNameMinLength { - return fmt.Errorf("API key name is too short (minimum %d characters required)", apiKeyNameMinLength) + if len(name) < constants.APIKeyNameMinLength { + return fmt.Errorf("API key name is too short (minimum %d characters required)", constants.APIKeyNameMinLength) } - if len(name) > apiKeyNameMaxLength { - return fmt.Errorf("API key name is too long (maximum %d characters allowed)", apiKeyNameMaxLength) + if len(name) > constants.APIKeyNameMaxLength { + return fmt.Errorf("API key name is too long (maximum %d characters allowed)", constants.APIKeyNameMaxLength) } if !validAPIKeyNameRegex.MatchString(name) { return fmt.Errorf("API key name must be lowercase alphanumeric with hyphens (no consecutive hyphens, cannot start/end with hyphen)") @@ -127,16 +134,39 @@ func GenerateAPIKeyName(displayName string) (string, error) { name = strings.Trim(name, "-") // Enforce max length - if len(name) > apiKeyNameMaxLength { - name = name[:apiKeyNameMaxLength] + if len(name) > constants.APIKeyNameMaxLength { + name = name[:constants.APIKeyNameMaxLength] // Trim trailing hyphen if truncation created one name = strings.TrimRight(name, "-") } - // If name is too short after sanitization, return error - if len(name) < apiKeyNameMinLength { - return "", fmt.Errorf("generated name '%s' is too short (minimum %d characters required after sanitization)", name, apiKeyNameMinLength) + // If name is too short after sanitization, pad with random hex characters + if len(name) < constants.APIKeyNameMinLength { + padding, err := randomHexString(constants.APIKeyNameMinLength - len(name)) + if err != nil { + return "", fmt.Errorf("failed to generate random padding for short name: %w", err) + } + if name == "" { + name = padding + } else { + name = name + "-" + padding + } + // Trim again to max length in case padding pushed it over + if len(name) > constants.APIKeyNameMaxLength { + name = name[:constants.APIKeyNameMaxLength] + name = strings.TrimRight(name, "-") + } } return name, nil } + +// randomHexString returns a lowercase hex string of exactly n characters. +func randomHexString(n int) (string, error) { + // Each byte encodes to 2 hex chars, so we need ceil(n/2) bytes + b := make([]byte, (n+1)/2) + if _, err := rand.Read(b); err != nil { + return "", err + } + return hex.EncodeToString(b)[:n], nil +} diff --git a/gateway/policies/policy-manifest.yaml b/gateway/policies/policy-manifest.yaml index a884cb864..c26a9d2c6 100644 --- a/gateway/policies/policy-manifest.yaml +++ b/gateway/policies/policy-manifest.yaml @@ -23,7 +23,7 @@ policies: - name: json-to-xml gomodule: github.com/wso2/gateway-controllers/policies/json-to-xml@v0 - name: jwt-auth - gomodule: github.com/wso2/gateway-controllers/policies/jwt-auth@v0.1.1 + gomodule: github.com/wso2/gateway-controllers/policies/jwt-auth@v0 - name: log-message gomodule: github.com/wso2/gateway-controllers/policies/log-message@v0 - name: mcp-acl-list diff --git a/platform-api/src/internal/dto/apikey.go b/platform-api/src/internal/dto/apikey.go index 1982dfa46..620417be5 100644 --- a/platform-api/src/internal/dto/apikey.go +++ b/platform-api/src/internal/dto/apikey.go @@ -27,16 +27,16 @@ type CreateAPIKeyRequest struct { DisplayName string `json:"displayName,omitempty"` // ApiKey is the plain text API key value that will be hashed before storage - ApiKey string `json:"api_key" binding:"required"` + ApiKey string `json:"apiKey" binding:"required"` // ExternalRefId is an optional reference ID for tracing purposes (from external platforms) - ExternalRefId *string `json:"external_ref_id,omitempty"` + ExternalRefId *string `json:"externalRefId,omitempty"` // Operations specifies which API operations this key can access (default: "*" for all) Operations string `json:"operations,omitempty"` // ExpiresAt is the optional expiration time in ISO 8601 format - ExpiresAt *string `json:"expires_at,omitempty"` + ExpiresAt *string `json:"expiresAt,omitempty"` } // CreateAPIKeyResponse represents the response after registering an API key. @@ -48,17 +48,17 @@ type CreateAPIKeyResponse struct { Message string `json:"message"` // KeyId is the internal ID generated by the gateway for tracking - KeyId string `json:"key_id,omitempty"` + KeyId string `json:"keyId,omitempty"` } // UpdateAPIKeyRequest represents the request to update/regenerate an API key. // This is used when external platforms rotate API keys on hybrid gateways. type UpdateAPIKeyRequest struct { // ApiKey is the new plain text API key value that will be hashed before storage - ApiKey string `json:"api_key" binding:"required"` + ApiKey string `json:"apiKey" binding:"required"` // ExpiresAt is the optional expiration time in ISO 8601 format - ExpiresAt *string `json:"expires_at,omitempty"` + ExpiresAt *string `json:"expiresAt,omitempty"` } // UpdateAPIKeyResponse represents the response after updating an API key. @@ -70,7 +70,7 @@ type UpdateAPIKeyResponse struct { Message string `json:"message"` // KeyId is the internal ID of the updated key - KeyId string `json:"key_id,omitempty"` + KeyId string `json:"keyId,omitempty"` } // RevokeAPIKeyResponse represents the response after revoking an API key. diff --git a/sdk/gateway/policy/v1alpha/api_key.go b/sdk/gateway/policy/v1alpha/api_key.go index 6a0d9b6ae..3bf42cb09 100644 --- a/sdk/gateway/policy/v1alpha/api_key.go +++ b/sdk/gateway/policy/v1alpha/api_key.go @@ -41,9 +41,9 @@ type APIKey struct { // Name of the API key (URL-safe identifier, auto-generated, immutable) Name string `json:"name" yaml:"name"` // DisplayName is the human-readable name (user-provided, mutable) - DisplayName string `json:"display_name" yaml:"display_name"` + DisplayName string `json:"displayName" yaml:"displayName"` // ApiKey API key with apip_ prefix - APIKey string `json:"api_key" yaml:"api_key"` + APIKey string `json:"apiKey" yaml:"apiKey"` // APIId Unique identifier of the API that the key is associated with APIId string `json:"apiId" yaml:"apiId"` // Operations List of API operations the key will have access to @@ -51,17 +51,17 @@ type APIKey struct { // Status of the API key Status APIKeyStatus `json:"status" yaml:"status"` // CreatedAt Timestamp when the API key was generated - CreatedAt time.Time `json:"created_at" yaml:"created_at"` + CreatedAt time.Time `json:"createdAt" yaml:"createdAt"` // CreatedBy User who created the API key - CreatedBy string `json:"created_by" yaml:"created_by"` + CreatedBy string `json:"createdBy" yaml:"createdBy"` // UpdatedAt Timestamp when the API key was last updated - UpdatedAt time.Time `json:"updated_at" yaml:"updated_at"` + UpdatedAt time.Time `json:"updatedAt" yaml:"updatedAt"` // ExpiresAt Expiration timestamp (null if no expiration) - ExpiresAt *time.Time `json:"expires_at" yaml:"expires_at"` + ExpiresAt *time.Time `json:"expiresAt" yaml:"expiresAt"` // Source tracking for external key support ("local" | "external") Source string `json:"source" yaml:"source"` // IndexKey Pre-computed hash for O(1) lookup (external plain text keys only) - IndexKey string `json:"index_key" yaml:"index_key"` + IndexKey string `json:"indexKey" yaml:"indexKey"` } // APIKeyStatus Status of the API key diff --git a/sdk/gateway/policyengine/v1/api_key_xds.go b/sdk/gateway/policyengine/v1/api_key_xds.go index e68a3dd49..24730df24 100644 --- a/sdk/gateway/policyengine/v1/api_key_xds.go +++ b/sdk/gateway/policyengine/v1/api_key_xds.go @@ -40,19 +40,19 @@ type APIKeyOperation struct { Operation APIKeyOperationType `json:"operation" yaml:"operation"` // APIKey contains the API key data (for store operations) - APIKey *APIKeyData `json:"api_key,omitempty" yaml:"api_key,omitempty"` + APIKey *APIKeyData `json:"apiKey,omitempty" yaml:"apiKey,omitempty"` // APIId of the API associated with the operation - APIId string `json:"api_id" yaml:"api_id"` + APIId string `json:"apiId" yaml:"apiId"` // APIKeyValue for revoke operations (the actual key value to revoke) - APIKeyValue string `json:"api_key_value,omitempty" yaml:"api_key_value,omitempty"` + APIKeyValue string `json:"apiKeyValue,omitempty" yaml:"apiKeyValue,omitempty"` // Timestamp of the operation Timestamp time.Time `json:"timestamp" yaml:"timestamp"` // CorrelationID for tracking the operation - CorrelationID string `json:"correlation_id" yaml:"correlation_id"` + CorrelationID string `json:"correlationId" yaml:"correlationId"` } // APIKeyData represents an API key for xDS transmission @@ -64,10 +64,10 @@ type APIKeyData struct { Name string `json:"name" yaml:"name"` // APIKey value with apip_ prefix - APIKey string `json:"api_key" yaml:"api_key"` + APIKey string `json:"apiKey" yaml:"apiKey"` // APIId of the API the key is associated with - APIId string `json:"api_id" yaml:"api_id"` + APIId string `json:"apiId" yaml:"apiId"` // Operations List of API operations the key will have access to (JSON array string) Operations string `json:"operations" yaml:"operations"` @@ -76,22 +76,22 @@ type APIKeyData struct { Status string `json:"status" yaml:"status"` // CreatedAt Timestamp when the API key was generated - CreatedAt time.Time `json:"created_at" yaml:"created_at"` + CreatedAt time.Time `json:"createdAt" yaml:"createdAt"` // CreatedBy User who created the API key - CreatedBy string `json:"created_by" yaml:"created_by"` + CreatedBy string `json:"createdBy" yaml:"createdBy"` // UpdatedAt Timestamp when the API key was last updated - UpdatedAt time.Time `json:"updated_at" yaml:"updated_at"` + UpdatedAt time.Time `json:"updatedAt" yaml:"updatedAt"` // ExpiresAt Expiration timestamp (null if no expiration) - ExpiresAt *time.Time `json:"expires_at" yaml:"expires_at"` + ExpiresAt *time.Time `json:"expiresAt" yaml:"expiresAt"` // Source tracking for external key support ("local" | "external") Source string `json:"source" yaml:"source"` // IndexKey Pre-computed hash for O(1) lookup (external plain text keys only) - IndexKey string `json:"index_key" yaml:"index_key"` + IndexKey string `json:"indexKey" yaml:"indexKey"` } // APIKeyOperationBatch represents a batch of API key operations @@ -101,7 +101,7 @@ type APIKeyOperationBatch struct { Operations []APIKeyOperation `json:"operations" yaml:"operations"` // BatchID uniquely identifies this batch - BatchID string `json:"batch_id" yaml:"batch_id"` + BatchID string `json:"batchId" yaml:"batchId"` // Version represents the version of this batch Version int64 `json:"version" yaml:"version"` From dce3c1bcb93b229867b204c9f6ae21bf957f5470 Mon Sep 17 00:00:00 2001 From: Thushani Jayasekera Date: Thu, 5 Feb 2026 11:59:03 +0530 Subject: [PATCH 12/14] Update documentation --- common/apikey/store.go | 13 +- .../gateway/policies/apikey-authentication.md | 213 ++++++++++-------- .../gateway-controller/pkg/utils/api_key.go | 2 +- sdk/gateway/policy/v1alpha/api_key.go | 16 +- 4 files changed, 136 insertions(+), 108 deletions(-) diff --git a/common/apikey/store.go b/common/apikey/store.go index 646776210..bb7c2d598 100644 --- a/common/apikey/store.go +++ b/common/apikey/store.go @@ -131,6 +131,9 @@ func (aks *APIkeyStore) StoreAPIKey(apiId string, apiKey *APIKey) error { return fmt.Errorf("API key cannot be nil") } + // Normalize the API key value before storing + apiKey.APIKey = strings.TrimSpace(apiKey.APIKey) + // Require non-empty IndexKey for external keys before any writes (no replacement from hashed APIKey) if apiKey.Source == "external" && strings.TrimSpace(apiKey.IndexKey) == "" { return fmt.Errorf("%w: external API key requires non-empty IndexKey", ErrInvalidInput) @@ -200,6 +203,9 @@ func (aks *APIkeyStore) ValidateAPIKey(apiId, apiOperation, operationMethod, pro aks.mu.Lock() defer aks.mu.Unlock() + // Normalize the provided API key + providedAPIKey = strings.TrimSpace(providedAPIKey) + var targetAPIKey *APIKey // Try to parse as local key (format: key_id) @@ -219,12 +225,11 @@ func (aks *APIkeyStore) ValidateAPIKey(apiId, apiOperation, operationMethod, pro if indexKey == "" { return false, fmt.Errorf("API key is empty") } - trimmedAPIKey := strings.TrimSpace(providedAPIKey) keyID, exists := aks.externalKeyIndex[apiId][indexKey] if exists { // Found in index, retrieve the key if apiKey, ok := aks.apiKeysByAPI[apiId][*keyID]; ok { - if apiKey.Source == "external" && compareAPIKeys(trimmedAPIKey, apiKey.APIKey) { + if apiKey.Source == "external" && compareAPIKeys(providedAPIKey, apiKey.APIKey) { targetAPIKey = apiKey } } @@ -284,8 +289,10 @@ func (aks *APIkeyStore) RevokeAPIKey(apiId, providedAPIKey string) error { aks.mu.Lock() defer aks.mu.Unlock() - var matchedKey *APIKey + // Normalize the provided API key + providedAPIKey = strings.TrimSpace(providedAPIKey) + var matchedKey *APIKey // Try to parse as local key (format: key_id); empty Source treated as "local" parsedAPIkey, ok := parseAPIKey(providedAPIKey) diff --git a/docs/gateway/policies/apikey-authentication.md b/docs/gateway/policies/apikey-authentication.md index 0d9e1f445..3d062a094 100644 --- a/docs/gateway/policies/apikey-authentication.md +++ b/docs/gateway/policies/apikey-authentication.md @@ -257,7 +257,7 @@ Generate a new API key for a specific API. ```json { "displayName": "weather-api-key", - "expires_in": { + "expiresIn": { "duration": 30, "unit": "days" } @@ -270,10 +270,10 @@ Generate a new API key for a specific API. |----------------------|------|----------|-------------| | `displayName` | string | No | Custom name for the API key. If not provided, a default name will be generated | | `name` | string | No | Identifier of the API key. If not provided, a default identifier will be generated | -| `expires_at` | string (ISO 8601) | No | Specific expiration timestamp for the API key. If both `expires_in` and `expires_at` are provided, `expires_at` takes precedence | -| `expires_in` | object | No | Relative expiration time from creation | -| `expires_in.duration` | integer | Yes (if expiresIn used) | Duration value | -| `expires_in.unit` | string | Yes (if expiresIn used) | Time unit: `seconds`, `minutes`, `hours`, `days`, `weeks`, `months` | +| `expiresAt` | string (ISO 8601) | No | Specific expiration timestamp for the API key. If both `expiresIn` and `expiresAt` are provided, `expiresAt` takes precedence | +| `expiresIn` | object | No | Relative expiration time from creation | +| `expiresIn.duration` | integer | Yes (if expiresIn used) | Duration value | +| `expiresIn.unit` | string | Yes (if expiresIn used) | Time unit: `seconds`, `minutes`, `hours`, `days`, `weeks`, `months` | #### Example Request @@ -284,7 +284,7 @@ curl -X POST "http://localhost:9090/apis/weather-api-v1.0/api-keys" \ -u "username:password" \ -d '{ "displayName": "production-key", - "expires_in": { + "expiresIn": { "duration": 90, "unit": "days" } @@ -298,7 +298,7 @@ curl -X POST "http://localhost:9090/apis/weather-api-v1.0/api-keys" \ -H "Authorization: Bearer " \ -d '{ "displayName": "production-key", - "expires_in": { + "expiresIn": { "duration": 90, "unit": "days" } @@ -311,17 +311,17 @@ curl -X POST "http://localhost:9090/apis/weather-api-v1.0/api-keys" \ { "status": "success", "message": "API key generated successfully", - "remaining_api_key_quota": 9, - "api_key": { + "remainingApiKeyQuota": 9, + "apiKey": { "name": "production-key", "displayName": "production-key", - "api_key": "apip_<64_hex>_<22_chars>", + "apiKey": "apip_<64_hex>_<22_chars>", "apiId": "weather-api-v1.0", "operations": "[\"*\"]", "status": "active", - "created_at": "2025-12-22T13:02:24.504957558Z", - "created_by": "john", - "expires_at": "2025-12-23T13:02:24.504957558Z" + "createdAt": "2025-12-22T13:02:24.504957558Z", + "createdBy": "john", + "expiresAt": "2025-12-23T13:02:24.504957558Z" } } ``` @@ -332,20 +332,20 @@ curl -X POST "http://localhost:9090/apis/weather-api-v1.0/api-keys" \ |-------|------|------------------------------------------------| | `status` | string | Operation status (`success`) | | `message` | string | Detailed message of the status | -| `remaining_api_key_quota` | integer | Remaining API key quota for the user | -| `api_key.displayName` | string | Name of the generated API key | -| `api_key.name` | string | Identifier of the generated API key | -| `api_key.apiId` | string | API identifier | -| `api_key.api_key` | string | The actual API key value (starts with `apip_`) | -| `api_key.status` | string | Key status (`active`) | -| `api_key.created_at` | string | ISO 8601 timestamp of creation | -| `api_key.created_by` | string | User who created the key | -| `api_key.expires_at` | string | ISO 8601 expiration timestamp (if set) | -| `api_key.operations` | string | Allowed operations (currently `["*"]` for all) | +| `remainingApiKeyQuota` | integer | Remaining API key quota for the user | +| `apiKey.displayName` | string | Name of the generated API key | +| `apiKey.name` | string | Identifier of the generated API key | +| `apiKey.apiId` | string | API identifier | +| `apiKey.apiKey` | string | The actual API key value (starts with `apip_`) | +| `apiKey.status` | string | Key status (`active`) | +| `apiKey.createdAt` | string | ISO 8601 timestamp of creation | +| `apiKey.createdBy` | string | User who created the key | +| `apiKey.expiresAt` | string | ISO 8601 expiration timestamp (if set) | +| `apiKey.operations` | string | Allowed operations (currently `["*"]` for all) | ### Inject API Key -This operation uses the same endpoint as [Generate API Key](#generate-api-key) (`POST /apis/{id}/api-keys`). The behavior is determined by the presence of the `api_key` field in the request body: omit `api_key` to generate a system key, or include `api_key` to inject an external key. See the request body examples in each section for the differing payloads. +This operation uses the same endpoint as [Generate API Key](#generate-api-key) (`POST /apis/{id}/api-keys`). The behavior is determined by the presence of the `apiKey` field in the request body: omit `apiKey` to generate a system key, or include `apiKey` to inject an external key. See the request body examples in each section for the differing payloads. Inject an externally generated API key for a specific API. @@ -362,11 +362,11 @@ Inject an externally generated API key for a specific API. ```json { "name": "weather-api-key", - "expires_in": { + "expiresIn": { "duration": 30, "unit": "days" }, - "api_key": "apip_1234567890abcdef1234567890abcdef1234567890abcdef1234567890abcdef" + "apiKey": "apip_1234567890abcdef1234567890abcdef1234567890abcdef1234567890abcdef" } ``` @@ -376,11 +376,11 @@ Inject an externally generated API key for a specific API. |----------------------|------|----------|-------------| | `displayName` | string | No | Custom name for the API key. If not provided, a default name will be generated | | `name` | string | No | Identifier of the API key. If not provided, a default identifier will be generated | -| `api_key` | string | No | The API key value to inject. Injected keys can be externally generated and are not required to use the platform `apip_` prefix; platform-generated keys do use the `apip_` prefix. See [Update API Key](#update-api-key) for the same `api_key` semantics when updating. | -| `expires_at` | string (ISO 8601) | No | Specific expiration timestamp for the API key. If both `expires_in` and `expires_at` are provided, `expires_at` takes precedence | -| `expires_in` | object | No | Relative expiration time from creation | -| `expires_in.duration` | integer | Yes (if expiresIn used) | Duration value | -| `expires_in.unit` | string | Yes (if expiresIn used) | Time unit: `seconds`, `minutes`, `hours`, `days`, `weeks`, `months` | +| `apiKey` | string | No | The API key value to inject. Injected keys can be externally generated and are not required to use the platform `apip_` prefix; platform-generated keys do use the `apip_` prefix. See [Update API Key](#update-api-key) for the same `apiKey` semantics when updating. | +| `expiresAt` | string (ISO 8601) | No | Specific expiration timestamp for the API key. If both `expiresIn` and `expiresAt` are provided, `expiresAt` takes precedence | +| `expiresIn` | object | No | Relative expiration time from creation | +| `expiresIn.duration` | integer | Yes (if expiresIn used) | Duration value | +| `expiresIn.unit` | string | Yes (if expiresIn used) | Time unit: `seconds`, `minutes`, `hours`, `days`, `weeks`, `months` | #### Example Request @@ -391,8 +391,8 @@ curl -X POST "http://localhost:9090/apis/weather-api-v1.0/api-keys" \ -u "username:password" \ -d '{ "displayName": "production-key", - "api_key": "apip_1234567890abcdef1234567890abcdef1234567890abcdef1234567890abcdef", - "expires_in": { + "apiKey": "apip_1234567890abcdef1234567890abcdef1234567890abcdef1234567890abcdef", + "expiresIn": { "duration": 90, "unit": "days" } @@ -406,8 +406,8 @@ curl -X POST "http://localhost:9090/apis/weather-api-v1.0/api-keys" \ -H "Authorization: Bearer " \ -d '{ "displayName": "production-key", - "api_key": "apip_1234567890abcdef1234567890abcdef1234567890abcdef1234567890abcdef", - "expires_in": { + "apiKey": "apip_1234567890abcdef1234567890abcdef1234567890abcdef1234567890abcdef", + "expiresIn": { "duration": 90, "unit": "days" } @@ -420,16 +420,16 @@ curl -X POST "http://localhost:9090/apis/weather-api-v1.0/api-keys" \ { "status": "success", "message": "API key generated successfully", - "remaining_api_key_quota": 9, - "api_key": { + "remainingApiKeyQuota": 9, + "apiKey": { "displayName": "production-key", "name": "production-key", "apiId": "weather-api-v1.0", "operations": "[\"*\"]", "status": "active", - "created_at": "2025-12-22T13:02:24.504957558Z", - "created_by": "john", - "expires_at": "2025-12-23T13:02:24.504957558Z" + "createdAt": "2025-12-22T13:02:24.504957558Z", + "createdBy": "john", + "expiresAt": "2025-12-23T13:02:24.504957558Z" } } ``` @@ -441,16 +441,16 @@ curl -X POST "http://localhost:9090/apis/weather-api-v1.0/api-keys" \ |-------|------|------------------------------------------------| | `status` | string | Operation status (`success`) | | `message` | string | Detailed message of the status | -| `remaining_api_key_quota` | integer | Remaining API key quota for the user | -| `api_key.name` | string | Identifier of the generated API key | -| `api_key.displayName` | string | Display name of the generated API key | -| `api_key.apiId` | string | API identifier | -| `api_key.api_key` | string | The actual API key value (format may vary) | -| `api_key.status` | string | Key status (`active`) | -| `api_key.created_at` | string | ISO 8601 timestamp of creation | -| `api_key.created_by` | string | User who created the key | -| `api_key.expires_at` | string | ISO 8601 expiration timestamp (if set) | -| `api_key.operations` | string | Allowed operations (currently `["*"]` for all) | +| `remainingApiKeyQuota` | integer | Remaining API key quota for the user | +| `apiKey.name` | string | Identifier of the generated API key | +| `apiKey.displayName` | string | Display name of the generated API key | +| `apiKey.apiId` | string | API identifier | +| `apiKey.apiKey` | string | The actual API key value (format may vary) | +| `apiKey.status` | string | Key status (`active`) | +| `apiKey.createdAt` | string | ISO 8601 timestamp of creation | +| `apiKey.createdBy` | string | User who created the key | +| `apiKey.expiresAt` | string | ISO 8601 expiration timestamp (if set) | +| `apiKey.operations` | string | Allowed operations (currently `["*"]` for all) | ### Update API Key @@ -474,8 +474,8 @@ Update an existing API key with a new externally provided API key value and opti ```json { "displayName": "updated-weather-key", - "api_key": "apip_newvalue1234567890abcdef1234567890abcdef1234567890abcdef1234567890", - "expires_in": { + "apiKey": "apip_newvalue1234567890abcdef1234567890abcdef1234567890abcdef1234567890", + "expiresIn": { "duration": 60, "unit": "days" } @@ -487,11 +487,11 @@ Update an existing API key with a new externally provided API key value and opti | Field | Type | Required | Description | |-------|------|----------|-------------| | `displayName` | string | No | Display name for the API key | -| `api_key` | string | Yes | The new API key value to set. Must meet minimum length requirements and can be any format (not restricted to platform-generated format) | -| `expires_at` | string (ISO 8601) | No | Specific expiration timestamp. If both `expires_at` and `expires_in` are provided, `expires_at` takes precedence. Omitting both `expires_at` and `expires_in` clears the key's expiration (no expiry). | -| `expires_in` | object | No | Relative expiration time from now. Omitting both `expires_at` and `expires_in` removes the API key's expiration (UpdateAPIKey clears expiry when `request.ExpiresAt` and `request.ExpiresIn` are both nil). | -| `expires_in.duration` | integer | Yes (if expiresIn used) | Duration value | -| `expires_in.unit` | string | Yes (if expiresIn used) | Time unit: `seconds`, `minutes`, `hours`, `days`, `weeks`, `months` | +| `apiKey` | string | Yes | The new API key value to set. Must meet minimum length requirements (default: 36 characters) and can be any format (not restricted to platform-generated format) | +| `expiresAt` | string (ISO 8601) | No | Specific expiration timestamp. If both `expiresAt` and `expiresIn` are provided, `expiresAt` takes precedence. Omitting both `expiresAt` and `expiresIn` clears the key's expiration (no expiry). | +| `expiresIn` | object | No | Relative expiration time from now. Omitting both `expiresAt` and `expiresIn` removes the API key's expiration (UpdateAPIKey clears expiry when `request.ExpiresAt` and `request.ExpiresIn` are both nil). | +| `expiresIn.duration` | integer | Yes (if expiresIn used) | Duration value | +| `expiresIn.unit` | string | Yes (if expiresIn used) | Time unit: `seconds`, `minutes`, `hours`, `days`, `weeks`, `months` | #### Example Request @@ -502,8 +502,8 @@ curl -X PUT "http://localhost:9090/apis/weather-api-v1.0/api-keys/production-key -u "username:password" \ -d '{ "displayName": "updated-production-key", - "api_key": "apip_abc123def456789abc123def456789abc123def456789abc123def456789abc12", - "expires_in": { + "apiKey": "apip_abc123def456789abc123def456789abc123def456789abc123def456789abc12", + "expiresIn": { "duration": 60, "unit": "days" } @@ -517,8 +517,8 @@ curl -X PUT "http://localhost:9090/apis/weather-api-v1.0/api-keys/production-key -H "Authorization: Bearer " \ -d '{ "displayName": "updated-production-key", - "api_key": "apip_abc123def456789abc123def456789abc123def456789abc123def456789abc12", - "expires_in": { + "apiKey": "apip_abc123def456789abc123def456789abc123def456789abc123def456789abc12", + "expiresIn": { "duration": 60, "unit": "days" } @@ -531,19 +531,19 @@ curl -X PUT "http://localhost:9090/apis/weather-api-v1.0/api-keys/production-key { "status": "success", "message": "API key updated successfully", - "remaining_api_key_quota": 9, - "api_key": { + "remainingApiKeyQuota": 9, + "apiKey": { "name": "production-key", "displayName": "updated-production-key", - "api_key": "apip_abc123def456789abc123def456789abc123def456789abc123def456789abc12", + "apiKey": "apip_abc123def456789abc123def456789abc123def456789abc123def456789abc12", "apiId": "weather-api-v1.0", "operations": "[\"*\"]", "status": "active", - "created_at": "2025-12-22T12:26:47.626109914Z", - "created_by": "john", - "updated_at": "2025-12-22T14:30:15.123456789Z", - "updated_by": "john", - "expires_at": "2026-02-20T14:30:15.123456789Z" + "createdAt": "2025-12-22T12:26:47.626109914Z", + "createdBy": "john", + "updatedAt": "2025-12-22T14:30:15.123456789Z", + "updatedBy": "john", + "expiresAt": "2026-02-20T14:30:15.123456789Z" } } ``` @@ -554,18 +554,18 @@ curl -X PUT "http://localhost:9090/apis/weather-api-v1.0/api-keys/production-key |-------|------|-------------| | `status` | string | Operation status (`success`) | | `message` | string | Detailed message of the status | -| `remaining_api_key_quota` | integer | Remaining API key quota for the user (unchanged by update) | -| `api_key.name` | string | Identifier of the API key (unchanged) | -| `api_key.displayName` | string | Updated display name of the API key | -| `api_key.api_key` | string | The new API key value | -| `api_key.apiId` | string | API identifier (unchanged) | -| `api_key.status` | string | Key status (`active`) | -| `api_key.created_at` | string | Original ISO 8601 timestamp of creation (unchanged) | -| `api_key.created_by` | string | Original user who created the key (unchanged) | -| `api_key.updated_at` | string | ISO 8601 timestamp of the update | -| `api_key.updated_by` | string | User who updated the key | -| `api_key.expires_at` | string | Updated ISO 8601 expiration timestamp (if provided) | -| `api_key.operations` | string | Allowed operations (currently `["*"]` for all) | +| `remainingApiKeyQuota` | integer | Remaining API key quota for the user (unchanged by update) | +| `apiKey.name` | string | Identifier of the API key (unchanged) | +| `apiKey.displayName` | string | Updated display name of the API key | +| `apiKey.apiKey` | string | The new API key value | +| `apiKey.apiId` | string | API identifier (unchanged) | +| `apiKey.status` | string | Key status (`active`) | +| `apiKey.createdAt` | string | Original ISO 8601 timestamp of creation (unchanged) | +| `apiKey.createdBy` | string | Original user who created the key (unchanged) | +| `apiKey.updatedAt` | string | ISO 8601 timestamp of the update | +| `apiKey.updatedBy` | string | User who updated the key | +| `apiKey.expiresAt` | string | Updated ISO 8601 expiration timestamp (if provided) | +| `apiKey.operations` | string | Allowed operations (currently `["*"]` for all) | ### List API Keys @@ -603,23 +603,23 @@ curl -X GET "http://localhost:9090/apis/weather-api-v1.0/api-keys" \ "apiKeys": [ { "name": "test-key", - "api_key": "apip_3521f3*********", + "apiKey": "apip_3521f3*********", "apiId": "weather-api-v1.0", "operations": "[\"*\"]", "status": "active", - "created_at": "2025-12-22T13:02:24.504957558Z", - "created_by": "john", - "expires_at": "2025-12-23T13:02:24.504957558Z" + "createdAt": "2025-12-22T13:02:24.504957558Z", + "createdBy": "john", + "expiresAt": "2025-12-23T13:02:24.504957558Z" }, { "name": "production-key", - "api_key": "apip_18dfd4*********", + "apiKey": "apip_18dfd4*********", "apiId": "weather-api-v1.0", "operations": "[\"*\"]", "status": "active", - "created_at": "2025-12-22T13:02:24.504957558Z", - "created_by": "admin", - "expires_at": "2026-03-22T13:02:24.504957558Z" + "createdAt": "2025-12-22T13:02:24.504957558Z", + "createdBy": "admin", + "expiresAt": "2026-03-22T13:02:24.504957558Z" } ] } @@ -653,7 +653,7 @@ Only the user who created the key can perform this operation. ```json { - "expires_in": { + "expiresIn": { "duration": 60, "unit": "days" } @@ -670,7 +670,7 @@ curl -X POST "http://localhost:9090/apis/weather-api-v1.0/api-keys/production-ke -H "Content-Type: application/json" \ -u "username:password" \ -d '{ - "expires_in": { + "expiresIn": { "duration": 60, "unit": "days" } @@ -683,7 +683,7 @@ curl -X POST "http://localhost:9090/apis/weather-api-v1.0/api-keys/production-ke -H "Content-Type: application/json" \ -H "Authorization: Bearer " \ -d '{ - "expires_in": { + "expiresIn": { "duration": 60, "unit": "days" } @@ -696,16 +696,16 @@ curl -X POST "http://localhost:9090/apis/weather-api-v1.0/api-keys/production-ke { "status": "success", "message": "API key generated successfully", - "remaining_api_key_quota": 9, - "api_key": { + "remainingApiKeyQuota": 9, + "apiKey": { "name": "production-key", - "api_key": "apip_18dfd4da48f276043b32d37_bhuced7y3gfd8r4w8bcf4wg", + "apiKey": "apip_18dfd4da48f276043b32d37_bhuced7y3gfd8r4w8bcf4wg", "apiId": "weather-api-v1.0", "operations": "[\"*\"]", "status": "active", - "created_at": "2025-12-22T12:26:47.626109914Z", - "created_by": "thivindu", - "expires_at": "2026-11-17T12:26:47.626109914Z" + "createdAt": "2025-12-22T12:26:47.626109914Z", + "createdBy": "thivindu", + "expiresAt": "2026-11-17T12:26:47.626109914Z" } } ``` @@ -746,7 +746,7 @@ curl -X DELETE "http://localhost:9090/apis/weather-api-v1.0/api-keys/production- { "status": "success", "message": "API key revoked successfully", - "remaining_api_key_quota": 9 + "remainingApiKeyQuota": 9 } ``` @@ -801,7 +801,7 @@ The API key management system includes quota controls to limit the number of API - **Revocation Impact**: Revoking an API key increases the available quota for that user #### Response Fields: -API key generation and regeneration responses include a `remaining_api_key_quota` field that shows how many additional API keys the user can create for the specific API. +API key generation and regeneration responses include a `remainingApiKeyQuota` field that shows how many additional API keys the user can create for the specific API. ### API Key Format @@ -811,6 +811,19 @@ All generated API keys follow a consistent format: - **Total Length**: 92 characters - **Example**: `apip_b9abae64a955aded2eb700aff88235ce3f7e6a8ca0f2f52ba31f73bcbb960360_jh~cPInvccQ09goMO5-4mQ` +### API Key Validation + +The platform enforces length constraints on API key values to ensure security and compatibility: + +| Setting | Config Key | Default | Description | +|---------|-----------|---------|-------------| +| Minimum Key Length | `min_key_length` | 36 | Minimum number of characters required for an API key value. The default of 36 matches UUID length. | +| Maximum Key Length | `max_key_length` | 128 | Maximum number of characters allowed for an API key value. | + +These values can be configured in the gateway controller configuration under the `api_key` section. If not configured, the defaults are used. When both are configured, `min_key_length` must be less than or equal to `max_key_length`. + +**Note**: These constraints apply to both injected (externally provided) API keys and system-generated API keys. + ### API Key Security The platform implements comprehensive security measures for API key management: diff --git a/gateway/gateway-controller/pkg/utils/api_key.go b/gateway/gateway-controller/pkg/utils/api_key.go index 3b5c87983..e92e88592 100644 --- a/gateway/gateway-controller/pkg/utils/api_key.go +++ b/gateway/gateway-controller/pkg/utils/api_key.go @@ -661,7 +661,7 @@ func (s *APIKeyService) RegenerateAPIKey(params APIKeyRegenerationParams) (*APIK regeneratedKey, err := s.regenerateAPIKey(existingKey, params.Request, user.UserID, logger) if err != nil { // Check if this is a duplicate key error - if strings.Contains(err.Error(), "API key value already exists") { + if errors.Is(err, storage.ErrConflict) { // For local key regeneration, retry with a new generated key logger.Warn("API key collision detected during regeneration, retrying", slog.String("handle", params.Handle), diff --git a/sdk/gateway/policy/v1alpha/api_key.go b/sdk/gateway/policy/v1alpha/api_key.go index 3bf42cb09..0f70fd080 100644 --- a/sdk/gateway/policy/v1alpha/api_key.go +++ b/sdk/gateway/policy/v1alpha/api_key.go @@ -128,6 +128,10 @@ func (aks *APIkeyStore) StoreAPIKey(apiId string, apiKey *APIKey) error { if apiKey == nil { return fmt.Errorf("API key cannot be nil") } + + // Normalize the API key value before storing + apiKey.APIKey = strings.TrimSpace(apiKey.APIKey) + // External keys require non-empty IndexKey for fast lookup; fail fast before any writes if apiKey.Source == "external" && strings.TrimSpace(apiKey.IndexKey) == "" { return fmt.Errorf("external API key requires non-empty IndexKey for fast lookup") @@ -207,6 +211,9 @@ func (aks *APIkeyStore) ValidateAPIKey(apiId, apiOperation, operationMethod, pro aks.mu.Lock() defer aks.mu.Unlock() + // Normalize the provided API key + providedAPIKey = strings.TrimSpace(providedAPIKey) + var targetAPIKey *APIKey // Try to parse as local key (format: key_id) @@ -226,12 +233,11 @@ func (aks *APIkeyStore) ValidateAPIKey(apiId, apiOperation, operationMethod, pro if indexKey == "" { return false, fmt.Errorf("API key is empty") } - trimmedAPIKey := strings.TrimSpace(providedAPIKey) keyID, exists := aks.externalKeyIndex[apiId][indexKey] if exists { // Found in index, retrieve the key if apiKey, ok := aks.apiKeysByAPI[apiId][*keyID]; ok { - if apiKey.Source == "external" && compareAPIKeys(trimmedAPIKey, apiKey.APIKey) { + if apiKey.Source == "external" && compareAPIKeys(providedAPIKey, apiKey.APIKey) { targetAPIKey = apiKey } } @@ -291,6 +297,9 @@ func (aks *APIkeyStore) RevokeAPIKey(apiId, providedAPIKey string) error { aks.mu.Lock() defer aks.mu.Unlock() + // Normalize the provided API key + providedAPIKey = strings.TrimSpace(providedAPIKey) + var matchedKey *APIKey // Try to parse as local key (format: key_id); empty Source treated as "local" @@ -305,10 +314,9 @@ func (aks *APIkeyStore) RevokeAPIKey(apiId, providedAPIKey string) error { // If not found via local key lookup, try external key index for O(1) lookup if matchedKey == nil { indexKey := computeExternalKeyIndexKey(providedAPIKey) - trimmedAPIKey := strings.TrimSpace(providedAPIKey) if keyID, exists := aks.externalKeyIndex[apiId][indexKey]; exists { if apiKey, ok := aks.apiKeysByAPI[apiId][*keyID]; ok { - if apiKey.Source == "external" && compareAPIKeys(trimmedAPIKey, apiKey.APIKey) { + if apiKey.Source == "external" && compareAPIKeys(providedAPIKey, apiKey.APIKey) { matchedKey = apiKey } } From f4c8f99201ab725613f38e13c7cba2ec670424cb Mon Sep 17 00:00:00 2001 From: Thushani Jayasekera Date: Thu, 5 Feb 2026 13:01:17 +0530 Subject: [PATCH 13/14] Update go.mod and go.sum file for integration test failures --- common/apikey/store.go | 2 +- gateway/gateway-builder/go.mod | 3 +++ gateway/gateway-builder/go.sum | 6 ++++++ gateway/gateway-controller/go.sum | 1 + gateway/gateway-controller/pkg/utils/api_key.go | 15 +++++++++++++++ gateway/it/suite_test.go | 2 +- 6 files changed, 27 insertions(+), 2 deletions(-) diff --git a/common/apikey/store.go b/common/apikey/store.go index bb7c2d598..08e48baf9 100644 --- a/common/apikey/store.go +++ b/common/apikey/store.go @@ -294,7 +294,7 @@ func (aks *APIkeyStore) RevokeAPIKey(apiId, providedAPIKey string) error { var matchedKey *APIKey - // Try to parse as local key (format: key_id); empty Source treated as "local" + // Try to parse as local key (format: key_id); only keys with Source == "local" are accepted parsedAPIkey, ok := parseAPIKey(providedAPIKey) if ok { apiKey, exists := aks.apiKeysByAPI[apiId][parsedAPIkey.ID] diff --git a/gateway/gateway-builder/go.mod b/gateway/gateway-builder/go.mod index f7d890920..211d33741 100644 --- a/gateway/gateway-builder/go.mod +++ b/gateway/gateway-builder/go.mod @@ -3,12 +3,15 @@ module github.com/wso2/api-platform/gateway/gateway-builder go 1.25.1 require ( + github.com/stretchr/testify v1.11.1 github.com/wso2/api-platform/sdk v0.0.0 golang.org/x/mod v0.30.0 gopkg.in/yaml.v3 v3.0.1 ) require ( + github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc // indirect + github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 // indirect golang.org/x/crypto v0.46.0 // indirect golang.org/x/sys v0.39.0 // indirect gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c // indirect diff --git a/gateway/gateway-builder/go.sum b/gateway/gateway-builder/go.sum index 4446ba779..2aeb24586 100644 --- a/gateway/gateway-builder/go.sum +++ b/gateway/gateway-builder/go.sum @@ -1,3 +1,5 @@ +github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc h1:U9qPSI2PIWSS1VwoXQT9A3Wy9MM3WgvqSxFWenqJduM= +github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/kr/pretty v0.2.1/go.mod h1:ipq/a2n7PKx3OHsz4KJII5eveXtPO4qwEXGdVfWzfnI= github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE= github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk= @@ -5,8 +7,12 @@ github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ= github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI= github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY= github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE= +github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 h1:Jamvg5psRIccs7FGNTlIRMkT8wgtp5eCXdBlqhYGL6U= +github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= github.com/rogpeppe/go-internal v1.14.1 h1:UQB4HGPB6osV0SQTLymcB4TgvyWu6ZyliaW0tI/otEQ= github.com/rogpeppe/go-internal v1.14.1/go.mod h1:MaRKkUm5W0goXpeCfT7UZI6fk/L7L7so1lCWt35ZSgc= +github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U= +github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U= golang.org/x/crypto v0.46.0 h1:cKRW/pmt1pKAfetfu+RCEvjvZkA9RimPbh7bhFjGVBU= golang.org/x/crypto v0.46.0/go.mod h1:Evb/oLKmMraqjZ2iQTwDwvCtJkczlDuTmdJXoZVzqU0= golang.org/x/mod v0.30.0 h1:fDEXFVZ/fmCKProc/yAXXUijritrDzahmwwefnjoPFk= diff --git a/gateway/gateway-controller/go.sum b/gateway/gateway-controller/go.sum index fc74cd051..1bad9a65f 100644 --- a/gateway/gateway-controller/go.sum +++ b/gateway/gateway-controller/go.sum @@ -226,3 +226,4 @@ gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= gotest.tools/v3 v3.5.2 h1:7koQfIKdy+I8UTetycgUqXWSDwpgv193Ka+qRsmBY8Q= +gotest.tools/v3 v3.5.2/go.mod h1:LtdLGcnqToBH83WByAAi/wiwSFCArdFIUV/xxN4pcjA= diff --git a/gateway/gateway-controller/pkg/utils/api_key.go b/gateway/gateway-controller/pkg/utils/api_key.go index e92e88592..d63696709 100644 --- a/gateway/gateway-controller/pkg/utils/api_key.go +++ b/gateway/gateway-controller/pkg/utils/api_key.go @@ -1984,6 +1984,14 @@ func (s *APIKeyService) CreateExternalAPIKeyFromEvent( correlationID string, logger *slog.Logger, ) (*APIKeyCreationResult, error) { + if request == nil { + logger.Error("nil APIKeyCreationRequest", + slog.String("api_id", handle), + slog.String("correlation_id", correlationID), + ) + return nil, fmt.Errorf("nil APIKeyCreationRequest for api %s", handle) + } + logger.Info("Creating external API key from event", slog.String("api_id", handle), slog.Bool("has_expiry", request.ExpiresAt != nil), @@ -2048,6 +2056,13 @@ func (s *APIKeyService) UpdateExternalAPIKeyFromEvent( correlationID string, logger *slog.Logger, ) error { + if request == nil { + logger.Error("nil APIKeyCreationRequest", + slog.String("api_id", handle), + slog.String("correlation_id", correlationID), + ) + return fmt.Errorf("nil APIKeyCreationRequest for api %s", handle) + } apiKeyUpdateParams := APIKeyUpdateParams{ Handle: handle, diff --git a/gateway/it/suite_test.go b/gateway/it/suite_test.go index c0847e825..fef492443 100644 --- a/gateway/it/suite_test.go +++ b/gateway/it/suite_test.go @@ -105,7 +105,7 @@ func TestFeatures(t *testing.T) { "features/api-management.feature", "features/list-policies.feature", "features/api-keys.feature", - "features/api-management.feature", + "features/api-with-policies.feature", "features/llm-proxies.feature", "features/search-deployments.feature", "features/policy-engine-admin.feature", From 31630ff4c53ee91f2d50441be3b3f0c1c31d246f Mon Sep 17 00:00:00 2001 From: Thushani Jayasekera Date: Thu, 5 Feb 2026 15:31:48 +0530 Subject: [PATCH 14/14] Fix api-keys.feature file --- .../pkg/utils/api_key_validation.go | 12 ++++++------ gateway/it/features/api-keys.feature | 10 +++++----- 2 files changed, 11 insertions(+), 11 deletions(-) diff --git a/gateway/gateway-controller/pkg/utils/api_key_validation.go b/gateway/gateway-controller/pkg/utils/api_key_validation.go index b8b5c8a7c..cc6f3c356 100644 --- a/gateway/gateway-controller/pkg/utils/api_key_validation.go +++ b/gateway/gateway-controller/pkg/utils/api_key_validation.go @@ -30,8 +30,8 @@ import ( ) var ( - // validAPIKeyNameRegex matches lowercase alphanumeric with hyphens (not at start/end, no consecutive) - validAPIKeyNameRegex = regexp.MustCompile(`^[a-z0-9]+(-[a-z0-9]+)*$`) + // validAPIKeyNameRegex matches lowercase alphanumeric with hyphens and underscores (not at start/end, no consecutive separators) + validAPIKeyNameRegex = regexp.MustCompile(`^[a-z0-9]+([_-][a-z0-9]+)*$`) // invalidCharsRegex matches any character that is not alphanumeric, hyphen, underscore, or space invalidCharsRegex = regexp.MustCompile(`[^a-z0-9\-_ ]`) // multipleHyphensRegex matches consecutive hyphens @@ -85,10 +85,10 @@ func ValidateDisplayName(displayName string) error { // ValidateAPIKeyName validates a user-provided API key name. // Name must be: // - Lowercase only -// - Alphanumeric with hyphens allowed +// - Alphanumeric with hyphens and underscores allowed // - No special characters -// - No consecutive hyphens -// - Cannot start or end with hyphen +// - No consecutive separators (hyphens/underscores) +// - Cannot start or end with hyphen or underscore // - Length between 3 and 63 characters func ValidateAPIKeyName(name string) error { if name == "" { @@ -101,7 +101,7 @@ func ValidateAPIKeyName(name string) error { return fmt.Errorf("API key name is too long (maximum %d characters allowed)", constants.APIKeyNameMaxLength) } if !validAPIKeyNameRegex.MatchString(name) { - return fmt.Errorf("API key name must be lowercase alphanumeric with hyphens (no consecutive hyphens, cannot start/end with hyphen)") + return fmt.Errorf("API key name must be lowercase alphanumeric with hyphens or underscores (no consecutive separators, cannot start/end with hyphen or underscore)") } return nil } diff --git a/gateway/it/features/api-keys.feature b/gateway/it/features/api-keys.feature index fea821838..780d1a431 100644 --- a/gateway/it/features/api-keys.feature +++ b/gateway/it/features/api-keys.feature @@ -58,9 +58,9 @@ Feature: API Key Management Operations Then the response status should be 201 And the response should be valid JSON And the JSON response field "status" should be "success" - And the JSON response should have field "api_key" - And the JSON response should have field "api_key.name" - And the JSON response should have field "api_key.api_key" + And the JSON response should have field "apiKey" + And the JSON response should have field "apiKey.name" + And the JSON response should have field "apiKey.apiKey" # List API keys - should have 1 key When I send a GET request to the "gateway-controller" service at "/apis/apikey-lifecycle-api/api-keys" @@ -77,7 +77,7 @@ Feature: API Key Management Operations Then the response status should be 200 And the response should be valid JSON And the JSON response field "status" should be "success" - And the JSON response should have field "api_key.api_key" + And the JSON response should have field "apiKey.apiKey" # Revoke API key When I send a DELETE request to the "gateway-controller" service at "/apis/apikey-lifecycle-api/api-keys/test-key-1" @@ -212,7 +212,7 @@ Feature: API Key Management Operations Then the response status should be 201 And the response should be valid JSON And the JSON response field "status" should be "success" - And the JSON response should have field "api_key" + And the JSON response should have field "apiKey" # Cleanup When I delete the API "key-validation-api" Then the response should be successful